Files
zeekay 017aeda6bf Bound what a graph request may resolve, and tell a caller only what was meant for it
Red's review of v1.37.42 found that a small request could make a graph
resolve without end: thirteen fully connected objects with `next`
nested four deep is 28,561 objects from a few dozen bytes, and the
static bounds cannot see a fan-out that lives in the data.

- fieldLimit is now also a runtime budget: every resolver call and
  every object reached counts, across the whole request. Past it the
  context every resolver holds is cancelled, no further call is made,
  and the request is refused whole.
- A query's root fields resolve rootParallel (8) at a time, not all 64
  at once.
- A caller reads the message of an HTTPError with a 4xx status and
  nothing else: any other error, and a panic, is logged with the
  request id and answered as "internal error (request <id>)". The
  POST handler takes or mints X-Request-Id and sets it on the request
  and the answer.
- Selection.Bind refuses in the schema's words ("argument "limit" of
  "assertions" takes Int, not "x""), as 400s; a local op's arguments
  get the same wording.
- Group.MountGraphFor mounts a graph behind a group's middleware.
- Identity(ctx) is what a Call would carry as identity, for a relay
  that strips the identity it copies.
- A list of structs is projected member by member, which every
  `{ items { name } }` on a local op used to refuse, and a resolver's
  struct answer is checked against its type.
2026-10-19 02:22:44 -07:00

634 lines
26 KiB
Go

package zip
import (
"cmp"
"context"
"net/http"
"strconv"
"strings"
"sync"
"github.com/valyala/fasthttp"
"github.com/zap-proto/fiber/v3"
)
// Who is calling — two different questions, answered by two different
// authorities, and never by the caller itself.
//
// WHO the request is for — the gateway's assertion, carried in the identity
// headers below. It was minted upstream, at the
// edge, against an IdP; a service forwards it
// unchanged (see Ctx.Forward) but cannot mint one.
// WHAT is calling — the kernel's attestation, read off the unix
// socket as the peer credential (see Peer). It
// cannot be set, spoofed or forwarded by the peer,
// because the peer never sends it: the kernel
// answers on its behalf.
//
// The second is why a service-to-service call over a unix socket needs no
// credential of its own. An argv flag, an environment variable or a shared
// secret would all be things the caller states about itself; SO_PEERCRED is
// the one thing about a caller that the caller does not get to say.
// The gateway-injected identity headers, named once. Ctx.Org and friends read
// these, Ctx.Forward propagates exactly these, and nothing else in zip spells
// them.
//
// The set is what a gateway can assert and a callee can act on, which is more
// than a subject: WHOSE tenant (org, project), WHICH person (id, name, email),
// WHICH account pays (the billing-account claim), and the two admin scopes —
// which are distinct authorities and must never collapse into one another.
// Owner names the org a principal belongs to, and a deployment that reserves one
// org for platform operators reads platform sudo off THAT and never off
// IsOrgAdmin, which says only "administers their own org". A plane that
// forwarded one and dropped the other would let a callee read "holds an org" as
// "administers it", or worse, as "administers the fleet".
const (
HeaderOrg = "X-Org-Id"
HeaderProject = "X-Project-Id"
HeaderUser = "X-User-Id"
HeaderUserName = "X-User-Name"
HeaderUserEmail = "X-User-Email"
HeaderUserOwner = "X-User-Owner"
// HeaderActedBy names the principal ACTING when a call is made on another
// org's behalf. Present only for an impersonation ([ActingAs]); its absence
// is the ordinary case and means the caller is acting as itself.
HeaderActedBy = "X-Acted-By"
HeaderUserAdmin = "X-User-IsAdmin"
HeaderUserOrgAdmin = "X-User-IsOrgAdmin"
HeaderRequestID = "X-Request-Id"
// HeaderAccount carries the principal's signed billing-account claim: the
// account inside its org that a charge is billed to. zip carries it as the
// gateway minted it and never interprets it; a callee resolving who pays reads
// it through [Caller.Account].
HeaderAccount = "X-Billing-Account-Id"
)
// identityHeaders is the set Call forwards — the caller's identity and the
// request id that ties the two hops together in the logs. It is exactly the
// list above and nothing beyond it: anything else a callee needs is an
// argument, and a header that is not identity has no business travelling
// implicitly.
//
// Forwarding a SUBSET is the failure this list exists to prevent. A callee
// resolving a billing subject prefers the minted name over the opaque id, and
// one gating a privileged surface reads the owner; drop either from the plane
// and the same handler decides differently depending on whether it was reached
// over REST or over a call — silently, and in the direction that bills or
// admits the wrong principal.
var identityHeaders = [...]string{
HeaderOrg, HeaderProject,
HeaderUser, HeaderUserName, HeaderUserEmail, HeaderUserOwner,
HeaderUserAdmin, HeaderUserOrgAdmin,
HeaderRequestID,
// The billing account travels with the identity it belongs to. A callee that
// resolves the payer without it falls back to a rule that ignores the claim,
// and bills a different account than the same request resolves over REST.
HeaderAccount,
// The impersonation travels WITH the identity it re-points, or the next hop
// sees a clean call from the target org and the audit trail ends at this
// process. That is precisely the failure this exists to close.
HeaderActedBy,
}
// callerKey carries the live request into a handler's context. The REQUEST is
// attached rather than a copy of what a handler might want from it, because
// copying five headers onto every typed request to serve the few that forward
// them is the wrong trade — this way the cost is one small value and the reads
// are lazy.
type callerKey struct{}
// callerContext binds the in-flight request to the context typed handlers
// receive, so the two callers of an op — the REST route and the op-call plane —
// both hand the handler a context that can answer who is calling.
//
// Both transports reuse the underlying RequestCtx for the next request: fasthttp
// pools it, and the ZAP server keeps one per connection. So the request is held
// through an [inflight] the request itself owns, which the server closes when it
// resets the request for the next one. A context kept past its request then
// reaches no request at all, and answers as a call whose request has ended,
// instead of reading whichever request the RequestCtx is serving now.
func callerContext(fc fiber.Ctx) context.Context {
rc := fc.RequestCtx()
if rc == nil {
return fc.Context()
}
in, _ := rc.UserValue(inflightKey{}).(*inflight)
if in == nil {
// ONE value carrying both facts, because this is the only place that holds
// the fiber Ctx and therefore the only place that can resolve the caller's
// IP under the app's trust configuration.
in = &inflight{rc: rc, ip: fc.IP()}
rc.SetUserValue(inflightKey{}, in)
}
return context.WithValue(fc.Context(), callerKey{}, in)
}
// inflight is what a handler's context carries about the call in progress: the
// request itself, and the caller's IP as fiber resolved it — which honours a
// proxy header only where the app trusts one. See [Caller.IP].
//
// It lives in the request's user values, so the server Closes it when it resets
// the request (fasthttp closes every io.Closer there, before the headers are
// reset). rc is read and cleared under mu, so no reader is inside the headers
// when the server reuses them.
type inflight struct {
mu sync.Mutex
rc *fasthttp.RequestCtx // nil once the request has ended
ip string
}
// inflightKey is the request user value holding its inflight.
type inflightKey struct{}
// Close ends the context's reach into the request. The server calls it.
func (in *inflight) Close() error {
in.mu.Lock()
in.rc = nil
in.mu.Unlock()
return nil
}
// request runs fn on the request behind ctx, holding it so the server cannot
// reset it underneath. behind reports whether a request was ever behind ctx;
// ended, whether that request is over, in which case fn does not run.
func request(ctx context.Context, fn func(rc *fasthttp.RequestCtx)) (behind, ended bool) {
in, _ := ctx.Value(callerKey{}).(*inflight)
if in == nil {
return false, false
}
in.mu.Lock()
defer in.mu.Unlock()
if in.rc == nil {
return true, true
}
fn(in.rc)
return true, false
}
// Header reads one header of the request behind a handler's context, and "" when
// there is no request behind it — a frame that arrived over ZAP, a command, a
// background job.
//
// It exists for the caller that has to REACH another service over HTTP on this
// caller's behalf and therefore needs the credential the caller presented, which
// [CallerOf] deliberately does not carry: Caller is the identity a request was
// resolved TO, and handing that back would let a service mint its own answer to
// the question it was supposed to ask.
func Header(ctx context.Context, name string) string {
get := headerOf(ctx)
if get == nil {
return ""
}
return get(name)
}
// Headers is every header of the request behind a handler's context, copied, and
// nil when there is no request behind it.
//
// It is [Header] for the caller that relays the request whole: a host that
// answers one address by asking the process that owns the operation, and must
// present the caller to that process exactly as the caller presented itself —
// the credential, the cookies, and whatever the edge in front of it set.
func Headers(ctx context.Context) http.Header {
var h http.Header
request(ctx, func(rc *fasthttp.RequestCtx) {
h = http.Header{}
for k, v := range rc.Request.Header.All() {
h.Add(string(k), string(v))
}
})
return h
}
// headerOf is the declared-header reader an op's invoke seam takes, resolved
// from the context rather than from a transport's own request object.
//
// It is a function rather than a map because the transports answer differently
// and all of them answer honestly: where a request is behind the context it
// reads that request's headers; where none is — a frame that arrived over ZAP,
// a command, a background job — it is nil and a `header:` field keeps whatever
// the arguments supplied. Reading it from the ctx is what lets the MCP door take
// a frame: the door no longer needs to hold an HTTP request to say what it knows
// about headers, it needs only to say honestly that it knows nothing.
func headerOf(ctx context.Context) func(string) string {
if behind, _ := request(ctx, func(*fasthttp.RequestCtx) {}); !behind {
return nil
}
return func(k string) (v string) {
request(ctx, func(rc *fasthttp.RequestCtx) { v = string(rc.Request.Header.Peek(k)) })
return v
}
}
// Forward returns a context that carries this request's identity onward, so a
// [Call] made with it reaches the next service as the same caller. Use it when
// an untyped handler calls another app; a typed handler's ctx already carries
// it.
//
// out, err := zip.Call[In, Out](c.Forward(), conn, "flags_bool", &in)
//
// This is propagation, not minting: the headers are the gateway's assertion,
// passed along exactly as received.
func (c *Ctx) Forward() context.Context { return callerContext(c.fc) }
// forwardIdentity copies the caller's identity onto an outbound request. A ctx
// with no request behind it (a background job, a test) forwards nothing, which
// is the honest answer — an unattributed call should look unattributed rather
// than borrow the identity of whatever ran last. A background caller that
// legitimately acts FOR someone says so with [WithCaller], and an inbound
// request always wins over what it said.
//
// A caller derived with [ActingAs] wins over both, exactly as it does in
// [CallerOf]: the next hop receives the org being acted for, the actor under
// [HeaderActedBy], and no project, so the callee reads the same caller this
// process reads. It is written whole from the derived caller rather than merged
// onto the request's headers, so no field of the original request rides beside it.
func forwardIdentity(ctx context.Context, req *fasthttp.Request) {
identity(ctx, req.Header.Set)
}
// Identity is the identity a [Call] made with ctx would carry, as headers: the
// caller the gateway asserted on the request behind ctx — or the org an
// [ActingAs] re-points it to, with the actor — together with the request id and
// the trace context, and nothing else.
//
// It is for a caller that relays a request by hand rather than through Call: it
// takes the relayed request's identity headers OUT, whatever they say, and puts
// these in, so the next hop is told who is calling by the rule Call follows and
// never by a header that happened to be on the request.
func Identity(ctx context.Context) http.Header {
h := http.Header{}
identity(ctx, h.Set)
return h
}
// identity writes the caller's identity through set: the rule [Call] and
// [Identity] share, so a hand relay and a Call cannot carry different callers.
func identity(ctx context.Context, set func(name, value string)) {
// The request's headers are copied while it is held. A request that has ended
// forwards no identity at all: it is not this call's to state, and stating
// nothing is what an unattributed call should look like.
var trace string
var ids [][2]string
behind, ended := request(ctx, func(rc *fasthttp.RequestCtx) {
if v := rc.Request.Header.Peek(HeaderTrace); len(v) > 0 {
trace = string(v)
}
for _, h := range identityHeaders {
if v := rc.Request.Header.Peek(h); len(v) > 0 {
ids = append(ids, [2]string{h, string(v)})
}
}
})
live := behind && !ended
// Trace context travels too, and it is listed apart from the identity headers
// because it is not a claim about anyone. Identity is the gateway's assertion,
// forwarded because a callee has to know who it is acting for; [HeaderTrace] is
// this hop's position in a trace, forwarded so the next hop can say it came from
// here. Folding it into the identity list would make "what may a caller assert
// about itself" a question with two different answers.
if trace != "" {
set(HeaderTrace, trace)
}
if Traceparent != nil {
if v := Traceparent(ctx); v != "" {
set(HeaderTrace, v)
}
}
if acting, ok := ctx.Value(actingKey{}).(Caller); ok {
for h, v := range acting.headers() {
set(h, v)
}
return
}
if live {
for _, kv := range ids {
set(kv[0], kv[1])
}
return
}
if behind {
return
}
stated, ok := ctx.Value(statedKey{}).(Caller)
if !ok {
return
}
for h, v := range stated.headers() {
set(h, v)
}
}
// statedKey carries an explicitly stated caller — see [WithCaller].
type statedKey struct{}
// actingKey carries a caller DERIVED from an authenticated one — see [ActingAs].
// Unexported and written in exactly one place, which is what keeps the
// derivation the only way to obtain one.
type actingKey struct{}
// WithCaller states who a [Call] made with this context acts for, for a caller
// that has no inbound request to propagate.
//
// The background work a service does is not all unattributed. A grant issued
// when an org opens, a meter that debits after the response has already gone
// out, a reactor draining a queue — each acts FOR a tenant, and the callee has
// to know which one to write to the right books. Without this the only place
// left to put the org is the argument, and an org in the argument is an org the
// caller chose: any caller could then name any tenant and be believed.
//
// ctx := zip.WithCaller(context.Background(), zip.Caller{Org: org})
// _, err := zip.Call[GrantIn, GrantOut](ctx, conn, "finance_grant", &in)
//
// An inbound request always wins. [forwardIdentity] prefers the gateway's
// assertion and reads this only when there is none, so this can supply an
// identity where none exists but can never override or launder one — which is
// what keeps [Ctx.Forward]'s guarantee intact. It is a statement by one of our
// own processes, trusted exactly as far as the socket's peer credential makes
// it trustworthy (see [Peer]), and never as far as a gateway's assertion.
func WithCaller(ctx context.Context, c Caller) context.Context {
return context.WithValue(ctx, statedKey{}, c)
}
// Detach returns a context for work that outlives the request behind ctx.
//
// go react(zip.Detach(ctx), ev)
//
// The server recycles a request once its handler returns, so a goroutine still
// holding the handler's context reads whatever request the server put there next
// — its headers, and so its caller. Detach reads the caller while the request is
// still this one, states it on a context that no longer reaches the request, and
// drops the request's cancellation. A context with no request behind it keeps the
// caller it already stated.
//
// It launders nothing: the caller is the one this context already answered with,
// and a stated caller is read only where no request is behind the context.
func Detach(ctx context.Context) context.Context {
who := CallerOf(ctx)
who = Caller{
Org: strings.Clone(who.Org), Project: strings.Clone(who.Project),
User: strings.Clone(who.User), Name: strings.Clone(who.Name),
Email: strings.Clone(who.Email), Owner: strings.Clone(who.Owner),
Admin: who.Admin, OrgAdmin: who.OrgAdmin,
RequestID: strings.Clone(who.RequestID), ActedBy: strings.Clone(who.ActedBy),
Account: strings.Clone(who.Account), IP: strings.Clone(who.IP),
}
ctx = context.WithValue(context.WithoutCancel(ctx), callerKey{}, (*inflight)(nil))
return WithCaller(ctx, who)
}
// ActingAs derives a caller acting on ANOTHER org's behalf, and is the one way
// to re-point the principal on a context that has a request behind it.
//
// ctx, err := zip.ActingAs(ctx, targetOrg)
//
// # Why this is not the laundering hole
//
// [WithCaller] is read only where there is no request, deliberately, so that
// stating an identity can never override an authenticated one. ActingAs does not
// weaken that: it cannot INVENT a caller, only re-point one that is already
// there. Called on a context with no authenticated principal it refuses, so the
// floor is unchanged — you can still only act with authority you arrived with.
//
// What it adds is that the derived caller carries BOTH facts. Org becomes the
// org being acted upon; [Caller.ActedBy] records who is acting, and travels on
// the wire with the rest of the identity. An impersonation is therefore
// distinguishable from an identity at every hop and in every audit row, which
// is the property that makes it safe to allow at all.
//
// # What it does NOT do
//
// It does not authorise. Whether this principal may act for that org is a policy
// question about roles and tenancy that belongs to the application, and zip has
// no view on it — an [Authorizer] is where that decision goes. ActingAs supplies
// the vocabulary and the audit trail; it does not grant anything.
func ActingAs(ctx context.Context, org string) (context.Context, error) {
if org == "" {
return ctx, ErrBadRequest("zip: ActingAs needs the org being acted for")
}
base := CallerOf(ctx)
if base.User == "" && base.Org == "" {
// Nothing authenticated to derive from. Refusing here is what keeps this
// from becoming a way to state an identity out of nothing.
return ctx, ErrUnauthorized("zip: ActingAs needs an authenticated caller to derive from")
}
acting := base
acting.Org = org
acting.Project = "" // a project is scoped to the original org, never to the target
// Administering one's own org says nothing about the org acted for, so the derived
// caller does not carry it there. Platform authority (Owner, Admin) is the
// principal's own and stays.
acting.OrgAdmin = false
if base.ActedBy != "" {
// Already an impersonation: the ORIGINAL actor stays the actor, so a
// chain of hops cannot quietly reassign who is responsible.
acting.ActedBy = base.ActedBy
} else {
acting.ActedBy = cmp.Or(base.User, base.Org)
}
return context.WithValue(ctx, actingKey{}, acting), nil
}
// Caller is the gateway's assertion about who a request is for, read as one
// value. It is what [Ctx.Forward] propagates and what [Call] carries.
//
// Empty fields mean the gateway said nothing — local dev, a direct ingress, or
// a call with no request behind it. Treat empty as unauthenticated, never as
// permitted.
// The two admin fields are separate authorities and reading one for the other
// is a privilege escalation: OrgAdmin says a person administers THEIR OWN org,
// Owner says which org that is. Admin is platform authority, which the gateway
// mints only for a person whose Owner is the org a deployment reserves for its
// operators, never for a membership of it.
type Caller struct {
Org string
Project string
User string
Name string
Email string
Owner string
Admin bool
OrgAdmin bool
RequestID string
// ActedBy is the principal ACTING when this call is made on another org's
// behalf, and empty in the ordinary case where the caller acts as itself.
//
// It is what makes an impersonation distinguishable from an identity: Org
// says whose data this call is for, ActedBy says who chose to touch it. A
// gate that must not be reachable by impersonation reads this; an audit row
// that omits it records the wrong actor.
ActedBy string
// Account is the principal's signed billing-account claim, as the gateway
// minted it: which account inside its org pays. Empty when the credential
// carried no claim. A callee accepts it only where it names an account in the
// ledger being charged, because the claim belongs to the principal, not to
// whichever org the call acts for.
Account string
// IP is where this call came FROM, and it is the one field here that is not
// a header the caller stated about itself — it is what the connection says.
//
// By default it is the socket peer, which cannot be spoofed and, behind a
// load balancer, is the load balancer. zip does not silently believe
// X-Forwarded-For: an IP that quietly reports the proxy is worse than none,
// because someone will rate-limit or audit on it, and an XFF that anyone can
// set is attacker-controlled input wearing the costume of a fact.
//
// A deployment that IS behind a proxy opts in explicitly, and the opt-in is
// allowlist-gated: set [Config.TrustProxy] with [Config.TrustedProxies] and
// [Config.ProxyHeader], and the forwarded value is honoured only when the
// peer is one of the named proxies. Anywhere else it falls back to the peer.
//
// Empty when there is no connection behind the context — a background
// caller, or a command — for the same reason every other field here is:
// nothing is known, so nothing is claimed.
IP string
}
// headers renders a stated caller onto the wire. Empty fields are omitted
// rather than sent blank, so "said nothing" and "said empty" stay the same
// thing on both sides of the call.
func (c Caller) headers() map[string]string {
h := make(map[string]string, len(identityHeaders))
for k, v := range map[string]string{
HeaderOrg: c.Org,
HeaderProject: c.Project,
HeaderUser: c.User,
HeaderUserName: c.Name,
HeaderUserEmail: c.Email,
HeaderUserOwner: c.Owner,
HeaderRequestID: c.RequestID,
HeaderActedBy: c.ActedBy,
HeaderAccount: c.Account,
} {
if v != "" {
h[k] = v
}
}
if c.Admin {
h[HeaderUserAdmin] = "true"
}
if c.OrgAdmin {
h[HeaderUserOrgAdmin] = "true"
}
return h
}
// CallerOf returns the identity forwarded with this call. It is the
// context-shaped read of the identity an untyped handler gets field by field
// off its [Ctx] — one accessor per surface, over the same headers:
//
// func listFlags(ctx context.Context, in *ListIn) (*ListOut, error) {
// org := zip.CallerOf(ctx).Org
// if org == "" {
// return nil, zip.ErrUnauthorized("no org")
// }
// ...
// }
//
// A context with no request behind it reads back whatever [WithCaller] stated
// on it, so what a background caller says it acts for is what the code running
// under that context sees — one value, written and read the same way, rather
// than a statement that only becomes visible one hop later.
func CallerOf(ctx context.Context) Caller {
// A DERIVED caller wins over the request, and nothing else does. It can only
// exist where [ActingAs] made one, and ActingAs can only re-point an
// identity that was already authenticated — so this is not a door a stated
// caller can walk through. See the laundering note on [WithCaller].
if acting, ok := ctx.Value(actingKey{}).(Caller); ok {
return acting
}
// A request that has ended has no caller to give: the RequestCtx is serving
// someone else by now, and borrowing that identity is the one wrong answer.
var c Caller
behind, ended := request(ctx, func(rc *fasthttp.RequestCtx) {
h := &rc.Request.Header
c = Caller{
Org: string(h.Peek(HeaderOrg)),
Project: string(h.Peek(HeaderProject)),
User: string(h.Peek(HeaderUser)),
Name: string(h.Peek(HeaderUserName)),
Email: string(h.Peek(HeaderUserEmail)),
Owner: string(h.Peek(HeaderUserOwner)),
Admin: string(h.Peek(HeaderUserAdmin)) == "true",
OrgAdmin: string(h.Peek(HeaderUserOrgAdmin)) == "true",
RequestID: string(h.Peek(HeaderRequestID)),
ActedBy: string(h.Peek(HeaderActedBy)),
Account: string(h.Peek(HeaderAccount)),
}
})
switch {
case !behind:
stated, _ := ctx.Value(statedKey{}).(Caller)
return stated
case ended:
return Caller{}
}
c.IP = callerIP(ctx)
return c
}
// callerIP is what the connection said, resolved once at the seam. Empty when
// no connection is behind the context — a background caller, or a command.
func callerIP(ctx context.Context) string {
in, _ := ctx.Value(callerKey{}).(*inflight)
if in == nil {
return ""
}
return in.ip
}
// Peer is the kernel's attestation of the process at the other end of a unix
// socket: what it is, not what it claims. Read it to decide whether a caller
// may reach an op at all — a coarse, infrastructure-level gate under the
// per-user authorization that [Authorizer] performs on the decoded input.
type Peer struct {
PID int
UID int
GID int
}
// String renders a peer for a log line.
func (p *Peer) String() string {
if p == nil {
return "peer(unknown)"
}
return "pid=" + strconv.Itoa(p.PID) + " uid=" + strconv.Itoa(p.UID) + " gid=" + strconv.Itoa(p.GID)
}
// Local reports a call with no transport behind it: this process asking
// itself, through [Here]. A server that scopes by the peer's credential asks
// this first — there is no peer to read, and there is no one to refuse.
func Local(ctx context.Context) bool {
behind, _ := request(ctx, func(*fasthttp.RequestCtx) {})
return !behind
}
// PeerOf returns the credential of the process that made this call, or nil
// when there is nothing to attest — the request arrived over tcp, or the host
// OS does not report peer credentials (see peerOf).
//
// It is the typed-handler counterpart of [Ctx.Peer]: a typed op reads it off
// the ctx it was handed.
//
// if p := zip.PeerOf(ctx); p == nil || p.UID != wantUID {
// return nil, zip.ErrForbidden("not a fleet peer")
// }
func PeerOf(ctx context.Context) (p *Peer) {
request(ctx, func(rc *fasthttp.RequestCtx) { p = peerOf(rc.Conn()) })
return p
}
// Peer returns the credential of the process that made this request, or nil.
// The untyped-handler counterpart of [PeerOf].
func (c *Ctx) Peer() *Peer {
rc := c.fc.RequestCtx()
if rc == nil {
return nil
}
return peerOf(rc.Conn())
}