unixdial: refuse the control endpoint and verify the listener's bound path on every unix dial (plan 1, task 3)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
8 files changed,
+239
−9
internal/activator/backend_exec.go+2 −1internal/controlsock/controlsock.go+31 −0internal/edge/edge.go+2 −1internal/host/socket_advice.go+5 −1internal/provision/provision.go+12 −5internal/release/versions.go+3 −1internal/unixdial/unixdial.go+75 −0internal/unixdial/unixdial_test.go+109 −0
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 3e0d41be6..7cafa366e 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -26,6 +26,7 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/schedule" |
| + "github.com/carlosframework/platform/internal/unixdial" |
| ) |
| // spawnHealthTimeout bounds how long a spawned instance may take to bind its |
| @@ -918,7 +919,7 @@ func socketClient(socket string) *http.Client { |
| Timeout: 3 * time.Second, |
| Transport: &http.Transport{ |
| DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| - return (&net.Dialer{}).DialContext(ctx, "unix", socket) |
| + return unixdial.Checked(ctx, &net.Dialer{}, socket) |
| }, |
| }, |
| } |
diff --git a/internal/controlsock/controlsock.go b/internal/controlsock/controlsock.go| new file mode 100644 |
| index 000000000..7d165da7e |
| --- /dev/null |
| +++ b/internal/controlsock/controlsock.go |
| @@ -0,0 +1,31 @@ |
| +// Package controlsock names the root instance-control channel (design |
| +// §5). It is a leaf package with no internal imports so both |
| +// internal/provision and internal/unixdial can depend on it without an |
| +// import cycle: provision already imports internal/release (for the |
| +// store), and internal/release's unix dial sites need |
| +// unixdial.Checked — which must know the control endpoint's path |
| +// without pulling provision (and therefore release) back in. |
| +package controlsock |
| + |
| +import ( |
| + "path/filepath" |
| + "strings" |
| +) |
| + |
| +// Dir and Path are the root instance-control channel (design §5). |
| +// Deliberately NOT under provision.SocketDir: an instance's namespace |
| +// replaces /run/carlos with an empty tmpfs, and nothing an instance can |
| +// reach must resolve to this path. |
| +const ( |
| + Dir = "/run/carlos-control" |
| + Path = Dir + "/control.sock" |
| +) |
| + |
| +// IsEndpoint reports whether path is the control channel or anything |
| +// else under its directory. The edge and the agent refuse to dial such a |
| +// path as an upstream (design §5.4): a registry row pointing at it is |
| +// refused before connect(). |
| +func IsEndpoint(path string) bool { |
| + clean := filepath.Clean(path) |
| + return clean == Dir || strings.HasPrefix(clean, Dir+"/") |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 16b7751a3..8f525f7a2 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -55,6 +55,7 @@ import ( |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/unixdial" |
| ) |
| // Default cache sizing for EnableCache: 4096 entries is comfortably above a |
| @@ -2648,7 +2649,7 @@ func dialUnixWithRetry(ctx context.Context, d *net.Dialer, socketPath string) (n |
| const window = 13 * time.Second |
| deadline := time.Now().Add(window) |
| for { |
| - conn, err := d.DialContext(ctx, "unix", socketPath) |
| + conn, err := unixdial.Checked(ctx, d, socketPath) |
| if err == nil { |
| return conn, nil |
| } |
diff --git a/internal/host/socket_advice.go b/internal/host/socket_advice.go| index 183097eb1..af3883cb5 100644 |
| --- a/internal/host/socket_advice.go |
| +++ b/internal/host/socket_advice.go |
| @@ -7,6 +7,7 @@ |
| package host |
| import ( |
| + "context" |
| "log" |
| "math/rand/v2" |
| "net" |
| @@ -14,6 +15,7 @@ import ( |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/unixdial" |
| ) |
| // warnExternalSockets returns the in-scope routes whose socket is being |
| @@ -47,7 +49,9 @@ func warnExternalSockets(routes []registry.Route, serving func(socket string) bo |
| // stale socket file nobody listens on refuses it — only the former is |
| // "served". Same-box unix accept, so the timeout is generous. |
| func socketServing(socket string) bool { |
| - c, err := net.DialTimeout("unix", socket, 500*time.Millisecond) |
| + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) |
| + defer cancel() |
| + c, err := unixdial.Checked(ctx, &net.Dialer{}, socket) |
| if err != nil { |
| return false |
| } |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index f8cb63063..aa2dcfe96 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -26,6 +26,7 @@ import ( |
| "golang.org/x/crypto/bcrypt" |
| "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/controlsock" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -1027,9 +1028,15 @@ const MaxUnixPath = 107 |
| // channel (design §5). Deliberately NOT under SocketDir: an instance's |
| // namespace replaces /run/carlos with an empty tmpfs, and nothing an |
| // instance can reach must resolve to this path. |
| +// |
| +// Defined in internal/controlsock (a leaf package with no imports) and |
| +// re-exported here unchanged: provision already imports internal/release |
| +// (for the store), and internal/unixdial — imported by internal/release's |
| +// unix dial sites — needs this path without pulling provision (and so |
| +// release) back in. |
| const ( |
| - ControlSocketDir = "/run/carlos-control" |
| - ControlSocketPath = ControlSocketDir + "/control.sock" |
| + ControlSocketDir = controlsock.Dir |
| + ControlSocketPath = controlsock.Path |
| ) |
| // InstanceID is the bounded per-host identifier every isolated-exec |
| @@ -1087,10 +1094,10 @@ func LogSocket(host, role string) string { |
| // IsControlEndpoint reports whether path is the control channel or |
| // anything else under its directory. The edge and the agent refuse to |
| // dial such a path as an upstream (design §5.4): a registry row pointing |
| -// at it is refused before connect(). |
| +// at it is refused before connect(). Delegates to internal/controlsock; |
| +// see the ControlSocketDir/ControlSocketPath comment above for why. |
| func IsControlEndpoint(path string) bool { |
| - clean := filepath.Clean(path) |
| - return clean == ControlSocketDir || strings.HasPrefix(clean, ControlSocketDir+"/") |
| + return controlsock.IsEndpoint(path) |
| } |
| // LongestSocketAddress is the longest unix socket path any helper here |
diff --git a/internal/release/versions.go b/internal/release/versions.go| index bc4f3d001..f33475e20 100644 |
| --- a/internal/release/versions.go |
| +++ b/internal/release/versions.go |
| @@ -24,6 +24,8 @@ import ( |
| "regexp" |
| "strings" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/unixdial" |
| ) |
| // OptDir is the on-box install root: CARLOS_OPT_DIR, default /opt/carlos. |
| @@ -369,7 +371,7 @@ func unixHTTPClient(socket string) *http.Client { |
| Timeout: 3 * time.Second, |
| Transport: &http.Transport{ |
| DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| - return (&net.Dialer{}).DialContext(ctx, "unix", socket) |
| + return unixdial.Checked(ctx, &net.Dialer{}, socket) |
| }, |
| }, |
| } |
diff --git a/internal/unixdial/unixdial.go b/internal/unixdial/unixdial.go| new file mode 100644 |
| index 000000000..8b2ae969b |
| --- /dev/null |
| +++ b/internal/unixdial/unixdial.go |
| @@ -0,0 +1,75 @@ |
| +// 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/<host>.sock pointing at a sibling's socket |
| +// or at the control socket therefore yields a mismatch, and the |
| +// connection is closed unused. |
| +// |
| +// 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" |
| + |
| + "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. |
| +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] == '@' || filepath.Clean(got) != want { |
| + return fmt.Errorf("%w: dialled %s, listener is bound to %q", ErrPeerMismatch, want, got) |
| + } |
| + return nil |
| +} |
diff --git a/internal/unixdial/unixdial_test.go b/internal/unixdial/unixdial_test.go| new file mode 100644 |
| index 000000000..b4ab7b8e8 |
| --- /dev/null |
| +++ b/internal/unixdial/unixdial_test.go |
| @@ -0,0 +1,109 @@ |
| +package unixdial |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +// short returns a socket path short enough for sockaddr_un under the |
| +// session's long TMPDIR (SDD lessons: run with TMPDIR=/tmp/g). |
| +func short(t *testing.T, name string) string { |
| + t.Helper() |
| + dir, err := os.MkdirTemp("", "ud") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(dir) }) |
| + return filepath.Join(dir, name) |
| +} |
| + |
| +func listen(t *testing.T, path string) net.Listener { |
| + t.Helper() |
| + ln, err := net.Listen("unix", path) |
| + if err != nil { |
| + t.Skipf("cannot bind a unix socket here (%v); run with TMPDIR=/tmp/g outside the sandbox", err) |
| + } |
| + t.Cleanup(func() { ln.Close() }) |
| + go func() { |
| + for { |
| + c, err := ln.Accept() |
| + if err != nil { |
| + return |
| + } |
| + c.Close() |
| + } |
| + }() |
| + return ln |
| +} |
| + |
| +func TestCheckedAcceptsTheListenerItDialled(t *testing.T) { |
| + path := short(t, "app.sock") |
| + listen(t, path) |
| + c, err := Checked(context.Background(), &net.Dialer{}, path) |
| + if err != nil { |
| + t.Fatalf("Checked: %v", err) |
| + } |
| + c.Close() |
| +} |
| + |
| +func TestCheckedRefusesASymlinkToAnotherListener(t *testing.T) { |
| + real := short(t, "other.sock") |
| + listen(t, real) |
| + link := short(t, "app.sock") |
| + if err := os.Symlink(real, link); err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := Checked(context.Background(), &net.Dialer{}, link) |
| + if c != nil { |
| + c.Close() |
| + } |
| + if !errors.Is(err, ErrPeerMismatch) { |
| + t.Fatalf("err = %v, want ErrPeerMismatch", err) |
| + } |
| +} |
| + |
| +func TestCheckedRefusesTheControlEndpointBeforeDialling(t *testing.T) { |
| + // No listener exists; the refusal must come from the path check, not |
| + // from a connect error. |
| + _, err := Checked(context.Background(), &net.Dialer{}, "/run/carlos-control/control.sock") |
| + if !errors.Is(err, ErrControlEndpoint) { |
| + t.Fatalf("err = %v, want ErrControlEndpoint", err) |
| + } |
| +} |
| + |
| +func TestCheckedAcceptsAListenerCreatedByAnotherProcessShape(t *testing.T) { |
| + // The carlos-app@.socket shape: the listener's creator is not the |
| + // process that serves it. Credentials are never consulted, only the |
| + // bound path, so this passes as long as the path matches. |
| + path := short(t, "apps.sock") |
| + listen(t, path) |
| + c, err := Checked(context.Background(), &net.Dialer{}, path) |
| + if err != nil { |
| + t.Fatalf("Checked: %v", err) |
| + } |
| + c.Close() |
| +} |
| + |
| +func TestCheckedAcceptsACustomPathOutsideRunCarlos(t *testing.T) { |
| + // codex R5-1: an operator's --socket /run/titogo/example.sock keeps |
| + // working; only the control endpoint is refused by path. |
| + path := short(t, "example.sock") |
| + listen(t, path) |
| + c, err := Checked(context.Background(), &net.Dialer{}, path) |
| + if err != nil { |
| + t.Fatalf("Checked: %v", err) |
| + } |
| + c.Close() |
| +} |
| + |
| +func TestCheckedPassesConnectErrorsThrough(t *testing.T) { |
| + path := short(t, "absent.sock") |
| + _, err := Checked(context.Background(), &net.Dialer{}, path) |
| + if err == nil || errors.Is(err, ErrPeerMismatch) || errors.Is(err, ErrControlEndpoint) { |
| + t.Fatalf("err = %v, want the raw connect error (ENOENT) so retry logic still sees it", err) |
| + } |
| +} |