From 149547be5071e925997c5b3ce4298d7c8e29d70a Mon Sep 17 00:00:00 2001 From: Yann Hodique Date: Sat, 18 Jul 2026 07:49:19 +0200 Subject: [PATCH] feat(transport,auth,vterm): name stream correlation as a first-class seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth.Wrap keyed its identity map on transport.Stream.RemoteID() — a field documented "not for authentication" — so every backend quietly overloaded RemoteID with a correlation value (websocket's per-process token, unix's SO_PEERCRED string). vterm did the same to tie a peer's /mosey/control resize to its later /mosey/pty attach. The interface contradicted its use. Add Stream.CorrelationID(): an unauthenticated, per-dialer-instance handle that *links* streams to an Identity the handshake already established — it never grants identity. RemoteID reverts to a pure log tag. auth.Wrap and vterm both key on CorrelationID, and auth.Wrap fails closed on an empty handle. No wire change: the websocket subprotocol token and unix peercreds derivation are byte-identical; only where the value surfaces in Go moves. See docs/adr/0004-stream-correlation-seam.md and the CONTEXT.md Transport glossary. Backend correlation tests migrate from RemoteID to CorrelationID. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 70 ++++++++++ auth/wrap.go | 26 +++- auth/wrap_test.go | 161 +++++++++++++++++++++++ docs/adr/0004-stream-correlation-seam.md | 70 ++++++++++ transport/http2/http2.go | 7 +- transport/http2/stream.go | 28 ++-- transport/libp2p/libp2p.go | 9 +- transport/transport.go | 22 +++- transport/unix/stream.go | 31 +++-- transport/unix/unix.go | 10 +- transport/unix/unix_test.go | 21 ++- transport/websocket/stream.go | 32 +++-- transport/websocket/websocket.go | 2 +- transport/websocket/websocket_test.go | 42 ++++-- vterm/control.go | 15 ++- vterm/session.go | 76 +++++------ vterm/websocket_integration_test.go | 4 +- 17 files changed, 506 insertions(+), 120 deletions(-) create mode 100644 CONTEXT.md create mode 100644 auth/wrap_test.go create mode 100644 docs/adr/0004-stream-correlation-seam.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..2774641 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,70 @@ +# mosey + +Domain glossary for mosey — a remote PTY attach system with pluggable +transports and pluggable authentication. This file is the project's ubiquitous +language: term definitions only, no implementation detail. + +## Capabilities + +**Caps**: +The permission bitmask a peer holds over a session — some combination of Write, +Resize, and Forge. Deliberately does *not* include ownership; an owner is a +structural fact that implies the full cap set. +_Avoid_: permissions, rights, scopes, capability bits (when you mean the type). + +**Write / Resize / Forge**: +The three caps. Write: may send input to the PTY. Resize: may change the PTY +geometry. Forge: may sign further off-chain delegations rooted at this grant. +_Avoid_: type/input (for Write), resize-perm. + +**Owner**: +A peer with full control of a session — mode switching, client management, and +implicitly every Cap. Ownership is structural (who the session belongs to), not +a Cap bit; it travels alongside Caps rather than inside the bitmask. +_Avoid_: admin, root, superuser. + +**Grant**: +The result of resolving a peer's authority: an Owner flag paired with a Caps +set. What an Authenticator produces and what enforcement reads. +_Avoid_: permission set, ACL entry. + +## Auth + +**Authenticator**: +The pluggable credential model that runs the handshake and yields an Identity. +Three exist: PSK, workspace cert, and wallet delegation. +_Avoid_: auth provider, credential handler. + +**Identity**: +The auth-layer result of a successful handshake — a Label plus the peer's Grant +(Owner + Caps). The zero value means unauthenticated. +_Avoid_: principal, user, subject. + +**Delegation**: +A signed statement that attenuates authority from one wallet to another for a +session — the wallet-auth building block folded into a chain to compute Caps. +_Avoid_: grant (reserve that for the resolved Owner+Caps result), token. + +## Transport + +**Stream**: +One bidirectional byte channel between two peers, handed out by a Transport +backend (libp2p mux stream, HTTP/2 stream, WebSocket, unix socket). The unit the +auth wrap, vterm, and attach client program against — never a specific backend. +_Avoid_: connection, channel, socket. + +**CorrelationID**: +An unauthenticated, per-dialer-instance handle a Stream carries so the auth wrap +can *link* streams — recognise that a later application stream comes from the +same party as an earlier auth-handshake stream. Stable for the dialer's lifetime +and identical across every stream that dialer opens (granularity: one client +process). It only *links* streams to an Identity the handshake already +established; it never itself *grants* identity. Empty when a backend cannot +correlate. +_Avoid_: session, session id, peer id, auth token, RemoteID. + +**RemoteID**: +A backend-specific, human-facing identifier for the remote peer (libp2p peer id, +remote address, TLS subject) used only for log tagging. May be empty. Explicitly +*not* an authentication or correlation input — that job belongs to CorrelationID. +_Avoid_: correlation id, peer key, identity. diff --git a/auth/wrap.go b/auth/wrap.go index 046cb9e..519419d 100644 --- a/auth/wrap.go +++ b/auth/wrap.go @@ -17,7 +17,7 @@ import ( // requested application protocol. Server-side: call [Wrapped.Serve] // once to install the /mosey/auth/ listener; subsequent application // Handle calls are gated on a successful prior handshake from the -// same remote peer (matched by [transport.Stream.RemoteID]). +// same remote peer (matched by [transport.Stream.CorrelationID]). // // Streams returned to handlers carry the peer's [Identity] — // retrieve it via [IdentityOf]. Streams from a peer that hasn't @@ -39,7 +39,7 @@ type Wrapped struct { auth Authenticator mu sync.Mutex - identities map[string]Identity // keyed by remote id + identities map[string]Identity // keyed by stream CorrelationID localIdentityMu sync.RWMutex localIdentity Identity // most recent ClientHandshake result @@ -55,9 +55,17 @@ func (w *Wrapped) Handle(proto string, h transport.Handler) { return } w.inner.Handle(proto, func(s transport.Stream) { - remote := s.RemoteID() + corr := s.CorrelationID() + if corr == "" { + // The backend can't correlate this stream to a prior + // handshake — fail closed. An empty key would otherwise + // alias every uncorrelatable stream together in the + // identity map. Silent close, same posture as below. + _ = s.Close() + return + } w.mu.Lock() - id, ok := w.identities[remote] + id, ok := w.identities[corr] w.mu.Unlock() if !ok { // No prior auth for this peer. Refuse silently — same @@ -160,8 +168,16 @@ func (w *Wrapped) Serve() { } return } + corr := s.CorrelationID() + if corr == "" { + // A handshake succeeded but the backend can't give us a + // correlation handle to key the identity on — so no + // application stream could ever be matched to it. Fail + // closed rather than store under an empty, aliasing key. + return + } w.mu.Lock() - w.identities[s.RemoteID()] = identity + w.identities[corr] = identity w.mu.Unlock() // Identity is now observable. Ack so the dialer's // io.ReadFull in Wrapped.Dial unblocks and proceeds. diff --git a/auth/wrap_test.go b/auth/wrap_test.go new file mode 100644 index 0000000..a33d625 --- /dev/null +++ b/auth/wrap_test.go @@ -0,0 +1,161 @@ +package auth + +import ( + "context" + "io" + "sync" + "testing" + + "github.com/firefly-engineering/mosey/api" + "github.com/firefly-engineering/mosey/transport" +) + +// fakeStream is a minimal [transport.Stream] whose CorrelationID is +// fixed by the test. Read returns EOF; Write and the rest are inert +// except that Close is observable. +type fakeStream struct { + correlation string + remote string + + mu sync.Mutex + closed bool +} + +func (s *fakeStream) Read([]byte) (int, error) { return 0, io.EOF } +func (s *fakeStream) Write(p []byte) (int, error) { return len(p), nil } +func (s *fakeStream) CloseWrite() error { return nil } +func (s *fakeStream) RemoteID() string { return s.remote } +func (s *fakeStream) CorrelationID() string { return s.correlation } + +func (s *fakeStream) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + s.closed = true + return nil +} + +func (s *fakeStream) isClosed() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.closed +} + +// fakeInner is a [transport.Transport] that only records the +// handlers registered on it, so a test can deliver a stream of its +// choosing to the handler auth.Wrap installed. +type fakeInner struct { + mu sync.Mutex + handlers map[string]transport.Handler +} + +func newFakeInner() *fakeInner { + return &fakeInner{handlers: map[string]transport.Handler{}} +} + +func (f *fakeInner) Schemes() []string { return nil } +func (f *fakeInner) Endpoints() []string { return nil } +func (f *fakeInner) Handle(proto string, h transport.Handler) { + f.mu.Lock() + defer f.mu.Unlock() + f.handlers[proto] = h +} +func (f *fakeInner) Unhandle(proto string) { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.handlers, proto) +} +func (f *fakeInner) Dial(context.Context, string, string) (transport.Stream, error) { + return nil, io.EOF +} +func (f *fakeInner) Close() error { return nil } + +func (f *fakeInner) deliver(proto string, s transport.Stream) { + f.mu.Lock() + h := f.handlers[proto] + f.mu.Unlock() + if h != nil { + h(s) + } +} + +// fakeAuth is an [Authenticator] whose ServerHandshake always +// succeeds with a fixed identity. +type fakeAuth struct{ id Identity } + +func (a fakeAuth) Name() string { return "fake" } +func (a fakeAuth) ClientHandshake(context.Context, io.ReadWriteCloser) (Identity, error) { + return a.id, nil +} +func (a fakeAuth) ServerHandshake(context.Context, io.ReadWriteCloser) (Identity, error) { + return a.id, nil +} + +// TestWrap_RefusesEmptyCorrelationOnAppStream proves the fail-closed +// guard: an application stream whose CorrelationID is empty is closed +// and never reaches the wrapped handler, even after a successful +// handshake stored an identity under a real key. +func TestWrap_RefusesEmptyCorrelationOnAppStream(t *testing.T) { + inner := newFakeInner() + w := Wrap(inner, fakeAuth{id: Identity{Label: "owner"}}) + w.Serve() + + // A real handshake stores an identity under a non-empty key. + w.Handle("/app", func(transport.Stream) { + t.Error("app handler ran for an empty-correlation stream") + }) + inner.deliver(api.ProtoAuth, &fakeStream{correlation: "peer-1"}) + + // An application stream with no correlation must be refused. + empty := &fakeStream{correlation: ""} + inner.deliver("/app", empty) + if !empty.isClosed() { + t.Error("empty-correlation app stream was not closed") + } +} + +// TestWrap_RefusesEmptyCorrelationOnHandshake proves the symmetric +// guard: a handshake that succeeds but yields an empty correlation +// stores nothing, so a later stream sharing that empty key is still +// refused rather than inheriting the identity. +func TestWrap_RefusesEmptyCorrelationOnHandshake(t *testing.T) { + inner := newFakeInner() + w := Wrap(inner, fakeAuth{id: Identity{Label: "owner"}}) + w.Serve() + + // Handshake succeeds but the stream can't correlate — store nothing. + inner.deliver(api.ProtoAuth, &fakeStream{correlation: ""}) + + w.Handle("/app", func(transport.Stream) { + t.Error("app handler ran despite no identity being stored") + }) + app := &fakeStream{correlation: ""} + inner.deliver("/app", app) + if !app.isClosed() { + t.Error("app stream was not closed after empty-correlation handshake") + } +} + +// TestWrap_CorrelatesMatchingStream is the positive control: an app +// stream whose CorrelationID matches a prior handshake reaches the +// handler carrying the stored identity. +func TestWrap_CorrelatesMatchingStream(t *testing.T) { + inner := newFakeInner() + w := Wrap(inner, fakeAuth{id: Identity{Label: "owner"}}) + w.Serve() + + inner.deliver(api.ProtoAuth, &fakeStream{correlation: "peer-1"}) + + var got Identity + ran := false + w.Handle("/app", func(s transport.Stream) { + ran = true + got = IdentityOf(s) + }) + inner.deliver("/app", &fakeStream{correlation: "peer-1"}) + if !ran { + t.Fatal("app handler did not run for a correlated stream") + } + if got.Label != "owner" { + t.Errorf("identity Label = %q, want owner", got.Label) + } +} diff --git a/docs/adr/0004-stream-correlation-seam.md b/docs/adr/0004-stream-correlation-seam.md new file mode 100644 index 0000000..1e6757b --- /dev/null +++ b/docs/adr/0004-stream-correlation-seam.md @@ -0,0 +1,70 @@ +# Stream correlation is a first-class seam, distinct from RemoteID + +`auth.Wrap` runs its handshake on a separate `ProtoAuth` stream, stores the +resulting `Identity` in a map, and then must recognise later application streams +as belonging to the same party. It keyed that map on `transport.Stream.RemoteID()` +— a field whose own doc says "used for log tagging; not for authentication." To +satisfy this unnamed requirement, each backend quietly overloaded `RemoteID()` +with a correlation value: websocket mints a random 128-bit per-process token and +smuggles it through `Sec-WebSocket-Protocol`, unix returns `uid=N:pid=M` from +SO_PEERCRED, http2 uses `RemoteAddr`, libp2p uses the peer id. The interface +contradicted its use, and every new backend had to reverse-engineer what auth +needed of it. + +We name the requirement instead of hiding it. + +## Decision + +- **Add `CorrelationID() string` to `transport.Stream`; keep `RemoteID()` for + logging.** Two methods, each with one job. `CorrelationID()` carries the auth + contract; `RemoteID()` reverts to the honest, possibly-empty, human-facing log + tag its doc already claimed it was. + +- **Correlation granularity is the dialer instance (one client process).** Two + streams with equal, non-empty `CorrelationID()` are guaranteed to come from the + same dialer, across as many connections as it opens. We rejected *per-connection* + (what the review's "same peer, this connection" phrasing implied — but websocket + deliberately correlates across connections, and tightening it would re-auth every + application stream) and *per-cryptographic-identity* (circular: for 3 of 4 + backends the whole point of the handshake is that identity isn't known until + after correlation has already linked the streams). + +- **`CorrelationID` links; the handshake grants.** The handle is an + *unauthenticated* linking key. It never itself confers identity — it only ties an + application stream to an `Identity` the handshake already established. This is the + contract line that keeps a linking token from being mistaken for a credential. + +- **Fail closed on empty, at `auth.Wrap`.** `auth.Wrap` refuses to store an + Identity under an empty key and refuses any application stream whose + `CorrelationID()` is empty. "Cannot correlate ⇒ cannot authenticate" lives once, + at the consumer, not scattered across four backends. Backends may still + pre-refuse for their own reasons. + +- **Unforgeability is a stated contract, per backend.** A backend may return a + non-empty `CorrelationID` only if that value is unforgeable or unguessable within + its trust domain: cryptographic (libp2p peer id), kernel-attested (unix + SO_PEERCRED), or ≥128-bit random (websocket token). `RemoteAddr`-grade handles + (http2) are the weakest rung and correlate only per-connection — acceptable, but + future backends get a clear bar. + +## Scope + +Interface change only. **No wire or protocol change:** the websocket +`mosey-peer-` subprotocol vehicle and the unix peercreds derivation stay +byte-identical; only where the value surfaces in Go moves (`RemoteID()` → +`CorrelationID()`). `auth.Wrap` rekeys its map. The backend tests that today +assert `RemoteID` stability/distinctness (`TestBackend_RemoteIDStableAcrossDials` +and friends) migrate to assert `CorrelationID`, since that is the property they +were always testing; `RemoteID` gets new, looser assertions. + +## Consequences + +- The `transport.Stream` interface stops contradicting its documented contract; + new backends implement a named requirement instead of guessing one. +- Correlation policy — the empty-guard, the link-not-grant rule — sits at one + seam (`auth.Wrap`) rather than being implied by four backends. +- **Known limitations, unchanged by this refactor and deferred:** the `auth.Wrap` + identity map never evicts, so (a) a websocket/unix peer's identity persists for + the wrapper's lifetime and (b) unix pid-reuse can let a recycled pid inherit a + dead process's identity. These pre-date this change; naming correlation does not + alter them. Map eviction is a separate decision. diff --git a/transport/http2/http2.go b/transport/http2/http2.go index 6dfcfbd..bac334d 100644 --- a/transport/http2/http2.go +++ b/transport/http2/http2.go @@ -287,9 +287,10 @@ func (b *Backend) serveHTTP(w http.ResponseWriter, r *http.Request) { flusher.Flush() stream := &serverStream{ - reader: r.Body, - writer: writeFlusher{w: w, f: flusher}, - remote: r.RemoteAddr, + reader: r.Body, + writer: writeFlusher{w: w, f: flusher}, + remote: r.RemoteAddr, + correlation: r.RemoteAddr, } h(stream) } diff --git a/transport/http2/stream.go b/transport/http2/stream.go index b0ecda4..da6e418 100644 --- a/transport/http2/stream.go +++ b/transport/http2/stream.go @@ -37,20 +37,26 @@ func (s *clientStream) Close() error { // what they send us. Symmetric with libp2p's CloseWrite. func (s *clientStream) CloseWrite() error { return s.writer.Close() } -// RemoteID returns the dialed host:port. HTTP/2 connections don't -// carry a richer peer identity in h2c mode; HTTPS variants can -// surface the TLS peer cert subject when that backend lands. +// RemoteID returns the dialed host:port, a log tag. HTTP/2 +// connections don't carry a richer peer identity in h2c mode; HTTPS +// variants can surface the TLS peer cert subject when that backend +// lands. func (s *clientStream) RemoteID() string { return s.remote } +// CorrelationID returns "" — the dialer's own streams are never +// looked up in a server-side identity map. +func (s *clientStream) CorrelationID() string { return "" } + var _ transport.Stream = (*clientStream)(nil) // serverStream is the listener-side [transport.Stream]: reads // drain the request body, writes go through the response body // (with explicit flushing so bytes leave the server promptly). type serverStream struct { - reader io.ReadCloser - writer writeFlusher - remote string + reader io.ReadCloser + writer writeFlusher + remote string + correlation string closeOnce sync.Once } @@ -76,10 +82,16 @@ func (s *serverStream) Close() error { func (s *serverStream) CloseWrite() error { return transport.ErrUnsupported } // RemoteID returns the client's [http.Request.RemoteAddr] — typically -// the peer's IP:port. HTTPS / mTLS deployments can plumb in cert -// subject identification later. +// the peer's IP:port — as a log tag. func (s *serverStream) RemoteID() string { return s.remote } +// CorrelationID returns the client's RemoteAddr: HTTP/2 multiplexes +// every stream of one connection over the same address, so it links +// streams at per-connection granularity — the weakest rung, spoofable +// only by controlling routing. HTTPS / mTLS deployments can plumb in +// cert-subject correlation later. See docs/adr/0004. +func (s *serverStream) CorrelationID() string { return s.correlation } + var _ transport.Stream = (*serverStream)(nil) // writeFlusher is the server-side writer: every Write is followed diff --git a/transport/libp2p/libp2p.go b/transport/libp2p/libp2p.go index ce7100c..dafe602 100644 --- a/transport/libp2p/libp2p.go +++ b/transport/libp2p/libp2p.go @@ -241,13 +241,18 @@ func (b *Backend) Close() error { } // streamAdapter wraps a libp2p [network.Stream] to satisfy -// [transport.Stream]. CloseWrite is native libp2p; RemoteID -// returns the remote peer's libp2p multihash for log tagging. +// [transport.Stream]. CloseWrite is native libp2p; the remote peer's +// libp2p multihash is both the log tag and the cryptographically +// attested correlation handle. type streamAdapter struct{ network.Stream } func (s *streamAdapter) CloseWrite() error { return s.Stream.CloseWrite() } func (s *streamAdapter) RemoteID() string { return s.Stream.Conn().RemotePeer().String() } +// CorrelationID returns the remote peer id — cryptographically +// attested, so it is a stable, unforgeable correlation handle. +func (s *streamAdapter) CorrelationID() string { return s.Stream.Conn().RemotePeer().String() } + // parseEndpoint accepts a "libp2p:..." URL or a bare multiaddr and // returns the dial target. func parseEndpoint(endpoint string) (peer.AddrInfo, error) { diff --git a/transport/transport.go b/transport/transport.go index 0ec04c7..fad6921 100644 --- a/transport/transport.go +++ b/transport/transport.go @@ -33,11 +33,25 @@ type Stream interface { CloseWrite() error // RemoteID returns a backend-specific identifier for the remote - // peer (libp2p peer id, TLS subject CN, etc.). Used for log - // tagging; not for authentication. May be empty when the - // backend can't attribute a stable identity (e.g. anonymous - // HTTP connections). + // peer (libp2p peer id, remote address, TLS subject, etc.), + // meant purely for log tagging. May be empty when the backend + // can't attribute one. Never an authentication or correlation + // input — that is [Stream.CorrelationID]'s job. RemoteID() string + + // CorrelationID returns an unauthenticated handle the auth wrap + // uses to link streams from the same dialer: two streams with + // equal, non-empty values are guaranteed to come from the same + // dialer instance (one client process), stable for that + // dialer's lifetime and identical across every stream it opens. + // It only *links* streams to an [auth] Identity the handshake + // already established — it never itself grants identity. A + // backend returns a non-empty value only if that value is + // unforgeable or unguessable within its trust domain + // (cryptographic, kernel-attested, or >=128-bit random); it + // returns "" when it cannot correlate, in which case the auth + // wrap refuses the stream. See docs/adr/0004-stream-correlation-seam.md. + CorrelationID() string } // ErrUnsupported is returned by optional [Stream] methods when the diff --git a/transport/unix/stream.go b/transport/unix/stream.go index af5db92..4e8ef80 100644 --- a/transport/unix/stream.go +++ b/transport/unix/stream.go @@ -11,8 +11,9 @@ import ( // length-prefixed protocol id already consumed. Implements // [transport.Stream] including half-close via UnixConn.CloseWrite. type stream struct { - conn *net.UnixConn - remote string + conn *net.UnixConn + remote string + correlation string closeOnce sync.Once closeErr error @@ -30,18 +31,24 @@ func (s *stream) Close() error { // can still read what they're sending us. func (s *stream) CloseWrite() error { return s.conn.CloseWrite() } -// RemoteID returns a string identifying the remote peer: -// -// - On the server side: "unix:uid=N:pid=M" derived from SO_PEERCRED -// (Linux) or getpeereid + LOCAL_PEERPID (macOS). Stable across -// repeated connections from the same caller process, so the auth -// layer can correlate the auth handshake stream with subsequent -// application streams. +// RemoteID returns a log tag for the remote peer: // +// - On the server side: "unix:uid=N:pid=M" from SO_PEERCRED (Linux) +// or getpeereid + LOCAL_PEERPID (macOS) — the most useful tag an +// AF_UNIX socket offers. // - On the client side: "unix://" — the path that was dialed. -// The client doesn't need a stable per-stream peer id (it knows -// its own identity via [auth.Wrapped.LocalIdentity]), so the path -// is the most useful thing to surface in logs. +// +// Purely for logs — auth correlation reads [stream.CorrelationID]. func (s *stream) RemoteID() string { return s.remote } +// CorrelationID returns the per-caller correlation handle: +// +// - On the server side: the "unix:uid=N:pid=M" peercreds string, +// kernel-attested and stable across repeated connections from the +// same caller process, so [auth.Wrap] links the auth handshake +// stream to subsequent application streams. +// - On the client side: "" — the dialer's own streams are never +// looked up in a server-side identity map. +func (s *stream) CorrelationID() string { return s.correlation } + var _ transport.Stream = (*stream)(nil) diff --git a/transport/unix/unix.go b/transport/unix/unix.go index d453c21..b5cd65e 100644 --- a/transport/unix/unix.go +++ b/transport/unix/unix.go @@ -219,13 +219,15 @@ func (b *Backend) handleConn(conn *net.UnixConn) { remote, err := peerCredsRemoteID(conn) if err != nil { // Couldn't read peer creds — refuse the stream rather than - // hand the handler a connection with an empty RemoteID, which - // would alias with every other no-creds connection in the - // auth identity map. + // hand the handler a connection with an empty CorrelationID, + // which would alias with every other no-creds connection in + // the auth identity map. _ = conn.Close() return } - h(&stream{conn: conn, remote: remote}) + // The peercreds string is both the log tag and the kernel-attested + // correlation handle; auth.Wrap keys on CorrelationID. + h(&stream{conn: conn, remote: remote, correlation: remote}) } // parseUnixEndpoint accepts either `unix:///path/to/sock` or a bare diff --git a/transport/unix/unix_test.go b/transport/unix/unix_test.go index 25ccfed..9f03f43 100644 --- a/transport/unix/unix_test.go +++ b/transport/unix/unix_test.go @@ -62,11 +62,13 @@ func TestBackend_DialEchoesBytes(t *testing.T) { } } -// TestBackend_RemoteIDStableAcrossDials proves the server-side -// RemoteID is the same across two streams from the same caller +// TestBackend_CorrelationIDStableAcrossDials proves the server-side +// CorrelationID is the same across two streams from the same caller // process. This is the property [auth.Wrap] depends on to correlate // the auth handshake stream with the subsequent application stream. -func TestBackend_RemoteIDStableAcrossDials(t *testing.T) { +// For unix the kernel-attested peercreds string is both the +// correlation handle and the RemoteID log tag, so they match. +func TestBackend_CorrelationIDStableAcrossDials(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -81,7 +83,12 @@ func TestBackend_RemoteIDStableAcrossDials(t *testing.T) { ) server.Handle(testProto, func(s transport.Stream) { seenMu.Lock() - seen = append(seen, s.RemoteID()) + // Assert RemoteID mirrors CorrelationID for unix, then track + // the correlation handle for the stability checks below. + if s.RemoteID() != s.CorrelationID() { + t.Errorf("unix RemoteID %q != CorrelationID %q", s.RemoteID(), s.CorrelationID()) + } + seen = append(seen, s.CorrelationID()) seenMu.Unlock() // Write one byte back so the client can synchronize on it // before closing — closing the stream too eagerly races the @@ -118,13 +125,13 @@ func TestBackend_RemoteIDStableAcrossDials(t *testing.T) { seenMu.Lock() defer seenMu.Unlock() if len(seen) != 2 { - t.Fatalf("seen %d RemoteIDs, want 2: %v", len(seen), seen) + t.Fatalf("seen %d CorrelationIDs, want 2: %v", len(seen), seen) } if seen[0] != seen[1] { - t.Errorf("RemoteID changed between dials: %q vs %q", seen[0], seen[1]) + t.Errorf("CorrelationID changed between dials: %q vs %q", seen[0], seen[1]) } if !strings.HasPrefix(seen[0], "unix:uid=") || !strings.Contains(seen[0], ":pid=") { - t.Errorf("RemoteID shape = %q, want unix:uid=N:pid=M", seen[0]) + t.Errorf("CorrelationID shape = %q, want unix:uid=N:pid=M", seen[0]) } } diff --git a/transport/websocket/stream.go b/transport/websocket/stream.go index 689347a..50f8bb7 100644 --- a/transport/websocket/stream.go +++ b/transport/websocket/stream.go @@ -18,8 +18,9 @@ import ( // pumpStream shape (one goroutine reads, another writes). We don't // take any locks of our own. type stream struct { - conn *websocket.Conn - remote string + conn *websocket.Conn + remote string + correlation string // curReader holds the io.Reader for the current binary message // while bytes from it remain. Refreshed when EOF is reached. @@ -78,21 +79,26 @@ func (s *stream) Close() error { // caller from quietly believing they sent a one-way FIN. func (s *stream) CloseWrite() error { return transport.ErrUnsupported } -// RemoteID returns a string identifying the remote peer: -// -// - On the server side: "ws-peer:" derived from the -// `Sec-WebSocket-Protocol: mosey-peer-` value the -// dialer offered. Stable across repeated connections from the -// same backend, so [auth.Wrap] correlates the auth handshake -// stream with subsequent application streams. +// RemoteID returns a log tag for the remote peer: // +// - On the server side: the peer's remote address (host:port). // - On the client side: "ws://host" or "wss://host" — the URL -// base that was dialed. The client doesn't need a stable -// per-stream peer id (it knows its own identity via -// [auth.Wrapped.LocalIdentity]); the URL is the most useful -// thing to surface in logs. +// base that was dialed. +// +// Purely for logs — auth correlation reads [stream.CorrelationID]. func (s *stream) RemoteID() string { return s.remote } +// CorrelationID returns the per-dialer correlation handle: +// +// - On the server side: "ws-peer:" derived from the +// `Sec-WebSocket-Protocol: mosey-peer-` value the dialer +// offered — a >=128-bit random token, stable across every +// connection from the same backend, so [auth.Wrap] links the +// auth handshake stream to subsequent application streams. +// - On the client side: "" — the dialer's own streams are never +// looked up in a server-side identity map. +func (s *stream) CorrelationID() string { return s.correlation } + // normalizeReadErr maps gorilla's "normal close" / "going away" // codes to io.EOF so the caller sees a clean end-of-stream rather // than a wrapped error. Any other failure is returned as-is. diff --git a/transport/websocket/websocket.go b/transport/websocket/websocket.go index f19eec1..7d97a8a 100644 --- a/transport/websocket/websocket.go +++ b/transport/websocket/websocket.go @@ -283,7 +283,7 @@ func (b *Backend) serveHTTP(w http.ResponseWriter, r *http.Request) { // Upgrader has already written an HTTP error response. return } - h(&stream{conn: conn, remote: "ws-peer:" + peerToken}) + h(&stream{conn: conn, remote: r.RemoteAddr, correlation: "ws-peer:" + peerToken}) } // selectPeerToken scans the Sec-WebSocket-Protocol values for the diff --git a/transport/websocket/websocket_test.go b/transport/websocket/websocket_test.go index 89b3c83..bb501b2 100644 --- a/transport/websocket/websocket_test.go +++ b/transport/websocket/websocket_test.go @@ -69,11 +69,13 @@ func TestBackend_DialEchoesBytes(t *testing.T) { } } -// TestBackend_RemoteIDStableAcrossDials proves the server-side -// RemoteID is the same across two streams from the same backend. +// TestBackend_CorrelationIDStableAcrossDials proves the server-side +// CorrelationID is the same across two streams from the same backend. // This is the property [auth.Wrap] depends on to correlate the -// auth handshake with the subsequent application stream. -func TestBackend_RemoteIDStableAcrossDials(t *testing.T) { +// auth handshake with the subsequent application stream. It also +// checks RemoteID is now a plain log tag (a remote address, not the +// correlation token). +func TestBackend_CorrelationIDStableAcrossDials(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -82,13 +84,15 @@ func TestBackend_RemoteIDStableAcrossDials(t *testing.T) { server, endpoint := newServer(t, ctx, nil) var ( - seen []string - seenMu sync.Mutex - ready = make(chan struct{}, 2) + seen []string + remotes []string + seenMu sync.Mutex + ready = make(chan struct{}, 2) ) server.Handle(testProto, func(s transport.Stream) { seenMu.Lock() - seen = append(seen, s.RemoteID()) + seen = append(seen, s.CorrelationID()) + remotes = append(remotes, s.RemoteID()) seenMu.Unlock() // Sync byte so the client can be sure the handler ran // before closing — closing too eagerly races the WS @@ -125,19 +129,29 @@ func TestBackend_RemoteIDStableAcrossDials(t *testing.T) { seenMu.Lock() defer seenMu.Unlock() if len(seen) != 2 { - t.Fatalf("seen %d RemoteIDs, want 2: %v", len(seen), seen) + t.Fatalf("seen %d CorrelationIDs, want 2: %v", len(seen), seen) } if seen[0] != seen[1] { - t.Errorf("RemoteID changed between dials: %q vs %q", seen[0], seen[1]) + t.Errorf("CorrelationID changed between dials: %q vs %q", seen[0], seen[1]) } if !strings.HasPrefix(seen[0], "ws-peer:") { - t.Errorf("RemoteID shape = %q, want ws-peer:", seen[0]) + t.Errorf("CorrelationID shape = %q, want ws-peer:", seen[0]) + } + // RemoteID is now just a log tag — a remote address, never the + // correlation token. + for i, r := range remotes { + if r == "" { + t.Errorf("RemoteID #%d empty, want a remote address", i) + } + if strings.HasPrefix(r, "ws-peer:") { + t.Errorf("RemoteID #%d = %q leaks the correlation token", i, r) + } } } // TestBackend_TwoBackendsHaveDifferentTokens covers the negative: // two separate dialer backends mint independent tokens, so the -// server sees different RemoteIDs. Without this property, +// server sees different CorrelationIDs. Without this property, // auth.Wrap would alias unrelated peers. func TestBackend_TwoBackendsHaveDifferentTokens(t *testing.T) { t.Parallel() @@ -154,7 +168,7 @@ func TestBackend_TwoBackendsHaveDifferentTokens(t *testing.T) { ) server.Handle(testProto, func(s transport.Stream) { seenMu.Lock() - seen = append(seen, s.RemoteID()) + seen = append(seen, s.CorrelationID()) seenMu.Unlock() _, _ = s.Write([]byte{0x01}) ready <- struct{}{} @@ -187,7 +201,7 @@ func TestBackend_TwoBackendsHaveDifferentTokens(t *testing.T) { seenMu.Lock() defer seenMu.Unlock() if seen[0] == seen[1] { - t.Errorf("distinct backends produced the same RemoteID %q", seen[0]) + t.Errorf("distinct backends produced the same CorrelationID %q", seen[0]) } } diff --git a/vterm/control.go b/vterm/control.go index 5f07cd2..cd21961 100644 --- a/vterm/control.go +++ b/vterm/control.go @@ -21,7 +21,8 @@ import ( // down. Always Closes the stream — backends decide whether that // translates to a clean half-close or a forceful tear-down. func (s *Session) handleControl(stream transport.Stream) { - remote := stream.RemoteID() + remote := stream.RemoteID() // log tag only + corr := stream.CorrelationID() // links this control stream to a PTY client identity := auth.IdentityOf(stream) s.logger.Info("control opened", "peer", remote, "role", identity.Label) defer func() { @@ -44,7 +45,7 @@ func (s *Session) handleControl(stream transport.Stream) { s.logger.Debug("control resize denied (no Resize cap)", "peer", remote, "role", identity.Label) continue } - if err := s.applyResize(remote, payload.Resize); err != nil { + if err := s.applyResize(corr, payload.Resize); err != nil { s.logger.Warn("control resize", "peer", remote, "err", err) } case *api.ControlMessage_Signal: @@ -68,8 +69,8 @@ func (s *Session) handleControl(stream transport.Stream) { prev := s.setMode(newMode) s.logger.Info("mode switched", "peer", remote, "from", prev, "to", newMode) case *api.ControlMessage_Demote: - if !s.demoteRemote(remote) { - s.logger.Debug("control demote: no PTY client for remote", "peer", remote) + if !s.demoteCorrelation(corr) { + s.logger.Debug("control demote: no PTY client for peer", "peer", remote) continue } s.logger.Info("client self-demoted to observer", "peer", remote) @@ -115,17 +116,17 @@ func (s *Session) handleControl(stream transport.Stream) { } } -// applyResize records remote's reported geometry against the +// applyResize records the peer's reported geometry against the // corresponding session client and re-derives the PTY's effective // size — min(cols, rows) across every client that has reported. A // resize of 0×0 from any client is ignored (the kernel accepts it // but curses apps go haywire). -func (s *Session) applyResize(remote string, r *api.Resize) error { +func (s *Session) applyResize(correlation string, r *api.Resize) error { cols, rows := r.GetCols(), r.GetRows() if cols == 0 || rows == 0 { return fmt.Errorf("resize ignored: zero dimension (cols=%d rows=%d)", cols, rows) } - appliedCols, appliedRows, err := s.applyResizeForRemote(remote, cols, rows) + appliedCols, appliedRows, err := s.applyResizeForCorrelation(correlation, cols, rows) if err != nil { return err } diff --git a/vterm/session.go b/vterm/session.go index 0e0c1e7..6e119db 100644 --- a/vterm/session.go +++ b/vterm/session.go @@ -82,11 +82,11 @@ type sessionClient struct { stream transport.Stream identity auth.Identity - // remote is the auth-layer remote id captured at admit time. + // correlation is the stream CorrelationID captured at admit time. // Used by control.go to map an inbound /mosey/control/ stream - // back to the right sessionClient when recording its - // per-client geometry. - remote string + // back to the right sessionClient when recording its per-client + // geometry — the same dialer correlates across both streams. + correlation string // outCh is the per-client live output channel. The session's // pty-pump fan-outs every byte-chunk onto every client's outCh @@ -140,7 +140,7 @@ func (s *Session) addClient(stream transport.Stream) *sessionClient { s.mu.Unlock() if c != nil && pendingCols > 0 && pendingRows > 0 { if err := applyPTYSize(s.ptyf, pendingCols, pendingRows); err != nil { - s.logger.Warn("apply cached resize on attach", "peer", c.remote, "err", err) + s.logger.Warn("apply cached resize on attach", "peer", c.correlation, "err", err) } } return c @@ -190,23 +190,23 @@ func (s *Session) addClientLocked(stream transport.Stream, identity auth.Identit s.nextID++ c := &sessionClient{ - id: s.nextID, - stream: stream, - identity: identity, - remote: stream.RemoteID(), - outCh: make(chan []byte, clientBufferChunks), - done: make(chan struct{}), - canWrite: canWrite, + id: s.nextID, + stream: stream, + identity: identity, + correlation: stream.CorrelationID(), + outCh: make(chan []byte, clientBufferChunks), + done: make(chan struct{}), + canWrite: canWrite, } - // Apply any resize the same remote sent on /mosey/control - // before its /mosey/pty stream landed. Drain on attach so a - // stale entry doesn't bias a future client that happens to - // reuse the remote string. + // Apply any resize the same peer sent on /mosey/control before + // its /mosey/pty stream landed. Drain on attach so a stale entry + // doesn't bias a future client that happens to reuse the + // correlation string. var ptyCols, ptyRows uint32 - if pending, ok := s.pendingResize[c.remote]; ok { + if pending, ok := s.pendingResize[c.correlation]; ok { c.cols = pending.cols c.rows = pending.rows - delete(s.pendingResize, c.remote) + delete(s.pendingResize, c.correlation) } s.clients[c.id] = c if s.mode == ModePrimaryObserver && canWrite { @@ -218,15 +218,15 @@ func (s *Session) addClientLocked(stream transport.Stream, identity auth.Identit return c, ptyCols, ptyRows } -// clientByRemoteIDLocked returns the most recently-added client -// whose RemoteID matches remote, or nil if none. Caller must hold -// s.mu. The "most recent" tiebreaker matters when one peer opens -// several streams — control messages apply to that peer's latest -// PTY attach. -func (s *Session) clientByRemoteIDLocked(remote string) *sessionClient { +// clientByCorrelationLocked returns the most recently-added client +// whose CorrelationID matches correlation, or nil if none. Caller +// must hold s.mu. The "most recent" tiebreaker matters when one peer +// opens several streams — control messages apply to that peer's +// latest PTY attach. +func (s *Session) clientByCorrelationLocked(correlation string) *sessionClient { var latest *sessionClient for _, c := range s.clients { - if c.remote != remote { + if c.correlation != correlation { continue } if latest == nil || c.id > latest.id { @@ -236,25 +236,25 @@ func (s *Session) clientByRemoteIDLocked(remote string) *sessionClient { return latest } -// applyResizeForRemote records the supplied cols/rows under the -// client owning remote, then recomputes the effective PTY size +// applyResizeForCorrelation records the supplied cols/rows under the +// client owning correlation, then recomputes the effective PTY size // (min across every client with non-zero geometry). Returns the // applied PTY size — zero/zero when no clients have reported a // geometry yet. // -// If no PTY client exists yet for remote, the resize is cached -// in pendingResize so the next addClient for the same remote can +// If no PTY client exists yet for correlation, the resize is cached +// in pendingResize so the next addClient for the same correlation can // apply it. Control and PTY streams arrive on independent // goroutines with no ordering guarantee — without the cache, // an initial attach.Run() resize that lands at the vterm before // its bridgeClient goroutine has registered the client gets // dropped, and the child stays at the PTY's 80×24 default until // a SIGWINCH (terminal wiggle) fires another resize. -func (s *Session) applyResizeForRemote(remote string, cols, rows uint32) (cols2, rows2 uint32, err error) { +func (s *Session) applyResizeForCorrelation(correlation string, cols, rows uint32) (cols2, rows2 uint32, err error) { s.mu.Lock() - c := s.clientByRemoteIDLocked(remote) + c := s.clientByCorrelationLocked(correlation) if c == nil { - s.pendingResize[remote] = pendingResize{cols: cols, rows: rows} + s.pendingResize[correlation] = pendingResize{cols: cols, rows: rows} s.mu.Unlock() return 0, 0, nil } @@ -306,11 +306,11 @@ func (s *Session) recomputeGeometryAfterRemoveLocked() { } // errResizeNoClient is the sentinel returned by -// applyResizeForRemote when the resize comes from a remote that +// applyResizeForCorrelation when the resize comes from a peer that // has no matching PTY client — typically because the peer opened // a control stream without a corresponding /mosey/pty/ session // (a misbehaving client or a race during teardown). -var errResizeNoClient = fmt.Errorf("resize: no PTY client for remote") +var errResizeNoClient = fmt.Errorf("resize: no PTY client for correlation") // setMode swaps the session's active mode. Owner-only; the // handler gates the check before calling this. The change applies @@ -325,14 +325,14 @@ func (s *Session) setMode(m Mode) Mode { return prev } -// demoteRemote drops the write capability of the client matching -// remote. If that client was the PrimaryObserver writer, the seat +// demoteCorrelation drops the write capability of the client matching +// correlation. If that client was the PrimaryObserver writer, the seat // becomes vacant. Returns true when a client was found + demoted, // false when no client matched (rare race during teardown). -func (s *Session) demoteRemote(remote string) bool { +func (s *Session) demoteCorrelation(correlation string) bool { s.mu.Lock() defer s.mu.Unlock() - c := s.clientByRemoteIDLocked(remote) + c := s.clientByCorrelationLocked(correlation) if c == nil { return false } diff --git a/vterm/websocket_integration_test.go b/vterm/websocket_integration_test.go index 6aa2b93..a0e6c14 100644 --- a/vterm/websocket_integration_test.go +++ b/vterm/websocket_integration_test.go @@ -18,8 +18,8 @@ import ( // PSK auth handshake → PTY bytes echo through `cat`. The WebSocket // backend's per-stream connection model only works for auth // correlation because the dialer offers a stable per-process token -// in `Sec-WebSocket-Protocol` and the server uses it as RemoteID; -// this test is the proof. +// in `Sec-WebSocket-Protocol` and the server exposes it as +// CorrelationID; this test is the proof. func TestVterm_WebSocket_AttachRoundTrip(t *testing.T) { t.Parallel()