// Package unixdial is the one way the edge and the agent dial a unix // socket. Two checks ride on every dial (isolated-exec design §5.4): // // - the reserved control endpoint is refused BEFORE connect(): a registry // row pointing rt.Socket at the root control channel must never be // proxied to, however it got there; // - the listener must answer to the path that was dialled: after // connect() and before a byte is sent, getpeername() names the path // the LISTENER bound, which is its real path however it was reached. // A symlink at /run/carlos/.sock pointing at a sibling's socket // or at the control socket therefore yields a mismatch, and the // connection is closed unused. // // The first check is purely lexical: it filepath.Cleans the dialled // string and compares it to the control directory, before any connect. // A relative path, or a symlinked parent directory that only resolves to // the control directory at connect time, is not caught here — it is // still caught by the second check, once getpeername() names the // control listener's real bound path after connect. A listener bound by // a relative path is refused by the second check for the same reason: // the kernel reports the bind string verbatim, and a relative name can // never filepath.Clean-equal the absolute path Checked was dialled with. // // Credentials are deliberately not consulted: a listener systemd created // and passed to a DynamicUser service reports root in SO_PEERCRED, and // that shape (carlos-app@.socket) must keep working (codex R4-1). package unixdial import ( "context" "errors" "fmt" "net" "path/filepath" "strings" "github.com/carlosframework/platform/internal/controlsock" ) // ErrControlEndpoint is returned, before any connect, for a path under // provision.ControlSocketDir (internal/controlsock.Dir; provision // re-exports it — see internal/controlsock's doc comment for why this // package imports controlsock directly rather than provision). var ErrControlEndpoint = errors.New("unixdial: refusing to dial the instance control channel as an upstream") // ErrPeerMismatch is returned, after connect and before any byte is sent, // when the listener's bound path is not the path that was dialled. var ErrPeerMismatch = errors.New("unixdial: listener is bound to a different path than the one dialled") // Checked dials path as a unix stream socket with d and applies both // checks. Connect errors (ENOENT, ECONNREFUSED) are returned unwrapped so // callers' retry logic keeps seeing them. func Checked(ctx context.Context, d *net.Dialer, path string) (net.Conn, error) { if controlsock.IsEndpoint(path) { return nil, fmt.Errorf("%w: %s", ErrControlEndpoint, path) } if d == nil { d = &net.Dialer{} } conn, err := d.DialContext(ctx, "unix", path) if err != nil { return nil, err } if err := verifyPeer(conn, path); err != nil { conn.Close() return nil, err } return conn, nil } // verifyPeer compares the peer address of a connected unix socket with the // dialled path. net's UnixConn fills RemoteAddr from getpeername(2) at // connect time, which is the listener's bound address. // // Only the DIALLED side is cleaned (want, below); the peer name the kernel // reports must equal want BYTE FOR BYTE. Cleaning got as well would let a // listener whose bound path merely normalizes to the same string (a // different-but-equivalent spelling of it) pass as a match, which is more // than getpeername() actually promised: the kernel reports the bind string // verbatim, not a normalized one, so an exact match is the only comparison // that means what this check claims to mean. A peer name containing a ".." // path component is refused outright for the same reason a relative bind // path already is: it is never a path this package's callers bind by, so // its presence means the socket was reached some other way than intended. func verifyPeer(conn net.Conn, dialled string) error { ra := conn.RemoteAddr() ua, ok := ra.(*net.UnixAddr) if !ok || ua == nil { return fmt.Errorf("%w: peer address %v is not a unix address", ErrPeerMismatch, ra) } want := filepath.Clean(dialled) got := ua.Name if got == "" || got[0] == '@' || hasDotDotComponent(got) || got != want { return fmt.Errorf("%w: dialled %s, listener is bound to %q", ErrPeerMismatch, want, got) } return nil } // hasDotDotComponent reports whether name has a ".." path component, // checked without cleaning it first: cleaning would resolve the very // traversal this exists to catch before it could ever be seen. func hasDotDotComponent(name string) bool { for _, part := range strings.Split(name, string(filepath.Separator)) { if part == ".." { return true } } return false }