carlos / platform Public

activator: normalize instance-socket mode to 0660 after health (issue #103)

The mode an instance socket carries after a successful wake is the
platform's, never the app's umask or post-bind chmod habit — titogo bound
0600 and stranded the sibling router dialing it. With bootstrap's setgid
/run/carlos the socket's group is carlos-sockets, so 0660 grants exactly
the enrolled consumers; on an unconverged box the group is carlos and the
wider mode grants nobody anything. Tested through the real spawn path via
a test-binary re-exec fake app that serves and tightens its own socket.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QytpMG8wVhBmWwz3uohtc7
Paul Campbell ab4ac51aa105955f1da7205ada9ad9907c311282 parent 8e60840
2 files changed, +105 −0
  • internal/activator/activator_test.go +89 −0
  • internal/activator/backend_exec.go +16 −0
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index d14d19014..59e9566d5 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -3051,3 +3051,92 @@ func TestSeedCreatedTheDB(t *testing.T) {
})
}
}
+
+// --- Instance-socket mode normalization (issue #103) -------------------------
+
+// TestMain lets this test binary double as a fake instance app: Start execs
+// the live/<host> symlink, and stageServingLive points that symlink at the
+// test binary itself with ACTIVATOR_FAKE_APP=1 in the inherited environment
+// (childEnv strips only CARLOS_* vars, so it flows through to the child).
+// The shell-script recorders elsewhere in this file can't answer the health
+// probe — serving HTTP on a unix socket needs a real program, and the test
+// binary is the one program every `go test` run is guaranteed to have.
+func TestMain(m *testing.M) {
+ if os.Getenv("ACTIVATOR_FAKE_APP") == "1" {
+ fakeAppMain()
+ return
+ }
+ os.Exit(m.Run())
+}
+
+// fakeAppMain is the app half: bind the --socket, tighten it to 0600 — the
+// exact titogo habit that stranded the sibling router in issue #103 — and
+// serve 200s until killed.
+func fakeAppMain() {
+ var socket string
+ for i, a := range os.Args {
+ if a == "--socket" && i+1 < len(os.Args) {
+ socket = os.Args[i+1]
+ }
+ }
+ if socket == "" {
+ os.Exit(2)
+ }
+ ln, err := net.Listen("unix", socket)
+ if err != nil {
+ os.Exit(1)
+ }
+ if err := os.Chmod(socket, 0o600); err != nil {
+ os.Exit(1)
+ }
+ _ = http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+}
+
+// stageServingLive points live/<host> at the test binary itself (see
+// TestMain) so Start spawns a child that actually binds and serves.
+func stageServingLive(t *testing.T, b *ExecBackend, host string) {
+ t.Helper()
+ self, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("ACTIVATOR_FAKE_APP", "1")
+ if err := os.MkdirAll(filepath.Join(b.OptDir, "live"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ os.Remove(b.liveLink(host))
+ if err := os.Symlink(self, b.liveLink(host)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestExecBackendStartNormalizesSocketMode pins the issue #103 contract: the
+// mode an instance socket carries after a successful wake is the PLATFORM's
+// (0660 — group members, i.e. enrolled carlos-sockets consumers, may dial),
+// never the app's own umask or chmod habit. The fake app deliberately
+// tightens its socket to 0600 after binding; Start must win.
+func TestExecBackendStartNormalizesSocketMode(t *testing.T) {
+ requireUnixSockets(t)
+ b := testExecBackend(t)
+ host := "mode.example.com"
+ stageServingLive(t, b, host)
+
+ rt := registry.Route{Host: host, Kind: "instance", Socket: tmpSock(t)}
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ proc, _, err := b.Start(ctx, rt)
+ if err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ defer proc.Stop(context.Background())
+
+ fi, err := os.Stat(rt.Socket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fi.Mode().Perm() != 0o660 {
+ t.Fatalf("socket mode after healthy Start = %o, want 660 — an app's own chmod must not strand a sibling consumer", fi.Mode().Perm())
+ }
+}
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 87d27c613..a3916b7ca 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -488,6 +488,22 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin
_ = p.Stop(stopCtx)
return nil, "", err
}
+ // The instance-socket mode is the PLATFORM's, not the app's (issue #103):
+ // normalize to 0660 so a deployment's enrolled cross-user consumers (the
+ // carlos-sockets group — group-inherited from the setgid /run/carlos,
+ // see host.tmpfilesConf) can dial, no matter what the child's umask or
+ // its own post-bind chmod produced — titogo tightened its socket to 0600
+ // and stranded the sibling router that proxies to it. After the health
+ // wait, so the app's own bind/chmod has already happened and this wins;
+ // and only here, on a child THIS backend spawned — an externally-served
+ // socket (the #17 advisory's territory) is never touched. On a box with
+ // no consumers group the socket's group is just `carlos`, so the wider
+ // mode grants nobody anything. Logged, never fatal: a box with no
+ // consumers must not lose a healthy wake to a normalization it doesn't
+ // need.
+ if err := os.Chmod(rt.Socket, 0o660); err != nil {
+ log.Printf("activator: normalize socket mode for %s: %v (enrolled consumers would get permission denied)", host, err)
+ }
return p, spawnVer, nil
}