carlos / platform Public

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
Paul Campbell pushed by paul@keymail.dev 9e2c95df061bbf4c4374563d57c781cce6491932 parent 380acab
8 files changed, +239 −9
  • internal/activator/backend_exec.go +2 −1
  • internal/controlsock/controlsock.go +31 −0
  • internal/edge/edge.go +2 −1
  • internal/host/socket_advice.go +5 −1
  • internal/provision/provision.go +12 −5
  • internal/release/versions.go +3 −1
  • internal/unixdial/unixdial.go +75 −0
  • internal/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)
+ }
+}