securelisten-337
Squashed from securelisten-337 into main.
5 files changed,
+346
−1
internal/edge/purge_listen_linux.go+92 −0internal/edge/purge_listen_linux_test.go+230 −0internal/edge/purge_listen_unix.go+1 −1internal/provision/plan14_root_reads_test.go+17 −0internal/provision/testdata/plan14-root-reads-securelisten-337.tsv+6 −0
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (0 s)
ok amadan.net/carlos/platform.git/launchctx 0.022s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (12 s)
=== gofmt (0 s)
gofmt -l .: clean
=== rest (143 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.665s
ok github.com/carlosframework/platform/cmd/moveprobe 11.892s
ok github.com/carlosframework/platform/cmd/zddprobe 0.843s
ok github.com/carlosframework/platform/internal/activator 62.926s
ok github.com/carlosframework/platform/internal/agentsock 0.039s
ok github.com/carlosframework/platform/internal/alerting 0.005s
ok github.com/carlosframework/platform/internal/analytics 0.032s
ok github.com/carlosframework/platform/internal/appconfig 3.064s
ok github.com/carlosframework/platform/internal/apperr 0.156s
ok github.com/carlosframework/platform/internal/applog 0.289s
ok github.com/carlosframework/platform/internal/awsinv 0.005s
ok github.com/carlosframework/platform/internal/boxchoice 0.008s
ok github.com/carlosframework/platform/internal/boxid 0.949s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.011s
ok github.com/carlosframework/platform/internal/caps 0.018s
ok github.com/carlosframework/platform/internal/certwarm 0.003s
ok github.com/carlosframework/platform/internal/cgroup 0.879s
ok github.com/carlosframework/platform/internal/changelog 0.548s
ok github.com/carlosframework/platform/internal/claimactivation 1.350s
ok github.com/carlosframework/platform/internal/clamd 0.061s
? github.com/carlosframework/platform/internal/cliflag [no test files]
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.403s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.012s
ok github.com/carlosframework/platform/internal/dbverify 0.007s
ok github.com/carlosframework/platform/internal/dnsrec 0.005s
ok github.com/carlosframework/platform/internal/docsite 0.010s
ok github.com/carlosframework/platform/internal/domains 2.960s
ok github.com/carlosframework/platform/internal/doorkey 0.009s
ok github.com/carlosframework/platform/internal/economics 0.007s
ok github.com/carlosframework/platform/internal/edge 44.813s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.131s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.016s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/historyreadback 0.152s
ok github.com/carlosframework/platform/internal/host 120.746s
ok github.com/carlosframework/platform/internal/hostactors 6.003s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.005s
ok github.com/carlosframework/platform/internal/hostbox 2.926s
ok github.com/carlosframework/platform/internal/hostclaim 0.724s
ok github.com/carlosframework/platform/internal/hostenv 0.007s
ok github.com/carlosframework/platform/internal/hostevidence 0.007s
ok github.com/carlosframework/platform/internal/hostexec 5.168s
ok github.com/carlosframework/platform/internal/hostfence 0.040s
ok github.com/carlosframework/platform/internal/hostfiles 10.376s
ok github.com/carlosframework/platform/internal/hostfloor 0.016s
ok github.com/carlosframework/platform/internal/hostguard 1.021s
ok github.com/carlosframework/platform/internal/hostops 1.772s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.002s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostrelocate 0.563s
ok github.com/carlosframework/platform/internal/hostresolve 0.190s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.005s
ok github.com/carlosframework/platform/internal/instancectl 5.230s
ok github.com/carlosframework/platform/internal/instanceexec 2.854s
ok github.com/carlosframework/platform/internal/instancefs 0.006s
ok github.com/carlosframework/platform/internal/instancekey 0.017s
ok github.com/carlosframework/platform/internal/instanceupdates 0.013s
ok github.com/carlosframework/platform/internal/layout 1.683s
ok github.com/carlosframework/platform/internal/lbhealth 0.002s
ok github.com/carlosframework/platform/internal/lease 10.092s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.506s
ok github.com/carlosframework/platform/internal/ledger 0.021s
ok github.com/carlosframework/platform/internal/mail 0.218s
ok github.com/carlosframework/platform/internal/maintenance 0.107s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.550s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.075s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.542s
ok github.com/carlosframework/platform/internal/moves/archive 0.950s
ok github.com/carlosframework/platform/internal/objstore 0.271s
ok github.com/carlosframework/platform/internal/ops 0.191s
ok github.com/carlosframework/platform/internal/park 0.288s
ok github.com/carlosframework/platform/internal/plans 0.013s
ok github.com/carlosframework/platform/internal/privatefile 0.006s
ok github.com/carlosframework/platform/internal/provision 18.659s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.580s
ok github.com/carlosframework/platform/internal/relay 7.232s
ok github.com/carlosframework/platform/internal/release 24.558s
ok github.com/carlosframework/platform/internal/rollout 3.814s
ok github.com/carlosframework/platform/internal/rolloutwords 0.007s
ok github.com/carlosframework/platform/internal/rootaws 0.010s
ok github.com/carlosframework/platform/internal/rootparticipation 0.062s
ok github.com/carlosframework/platform/internal/rootstate 0.027s
ok github.com/carlosframework/platform/internal/runs 0.591s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.652s
ok github.com/carlosframework/platform/internal/seeds 0.019s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.346s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.175s
ok github.com/carlosframework/platform/internal/status 0.094s
ok github.com/carlosframework/platform/internal/steering 0.007s
ok github.com/carlosframework/platform/internal/stopevidence 0.291s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.005s
ok github.com/carlosframework/platform/internal/tunnelclient 0.007s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.667s
=== internal_console@0 (208 s)
ok github.com/carlosframework/platform/internal/console 206.445s
=== internal_console@1 (34 s)
ok github.com/carlosframework/platform/internal/console 32.117s
=== internal_console@2 (34 s)
ok github.com/carlosframework/platform/internal/console 31.945s
=== internal_console@3 (35 s)
ok github.com/carlosframework/platform/internal/console 33.756s
=== internal_console@4 (36 s)
ok github.com/carlosframework/platform/internal/console 34.256s
=== internal_console@5 (36 s)
ok github.com/carlosframework/platform/internal/console 34.755s
=== internal_console@6 (38 s)
ok github.com/carlosframework/platform/internal/console 36.010s
=== internal_console@7 (38 s)
ok github.com/carlosframework/platform/internal/console 36.075s
=== root@0 (32 s)
ok github.com/carlosframework/platform 30.682s
=== root@1 (44 s)
ok github.com/carlosframework/platform 42.571s
=== root@2 (44 s)
ok github.com/carlosframework/platform 42.728s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 49873058 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.023s
=== RUN TestT4StorageProbeMinio
2026/10/21 17:09:22 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E09A8871D9FC3A, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.021s
minio contract: PASS
diff --git a/internal/edge/purge_listen_linux.go b/internal/edge/purge_listen_linux.go| new file mode 100644 |
| index 000000000..e6ac55bee |
| --- /dev/null |
| +++ b/internal/edge/purge_listen_linux.go |
| @@ -0,0 +1,92 @@ |
| +//go:build linux |
| + |
| +package edge |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "net" |
| + "os" |
| + "syscall" |
| +) |
| + |
| +// SecureUnixListen binds socketPath as a unix socket reachable only by its |
| +// owner, removing any stale file left behind by a crashed previous instance. |
| +// |
| +// The owner-only guarantee is the whole point of the function and the reason |
| +// it is split out per-OS rather than shimmed at the syscall: a purge socket |
| +// that anyone on the box can connect to hands out exactly the wake-storm |
| +// lever this feature exists to remove. Making that guarantee unrepresentable |
| +// on an OS without umask is safer than making it a no-op there. |
| +// |
| +// On Linux the socket is made 0600 before it is bound: bind creates the |
| +// file with the socket's own mode less the umask, so it appears owner-only |
| +// at socketPath with no window in between. The file is bound where it will |
| +// be served because unixdial checks the path a listener was bound under |
| +// against the path it dialled, so binding elsewhere and renaming is out. |
| +// This replaces setting the process umask to 0177 around the bind: the |
| +// umask belongs to the whole process, so every file and directory any |
| +// other goroutine made in that window lost its group and other bits, and a |
| +// directory lost its execute bit with them (amadan #337). Nothing here |
| +// touches the umask. |
| +func SecureUnixListen(socketPath string) (net.Listener, error) { |
| + // An abstract address (a leading @) has no file and so no permissions: |
| + // any uid could connect to it. Refuse it rather than serve it. |
| + if socketPath == "" || socketPath[0] == '@' { |
| + return nil, fmt.Errorf("unix socket %q: need a filesystem path, which is what makes it owner-only", socketPath) |
| + } |
| + if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return nil, err |
| + } |
| + fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM|syscall.SOCK_CLOEXEC, 0) |
| + if err != nil { |
| + return nil, os.NewSyscallError("socket", err) |
| + } |
| + f := os.NewFile(uintptr(fd), socketPath) |
| + defer f.Close() // net.FileListener holds its own duplicate |
| + if err := syscall.Fchmod(fd, 0600); err != nil { |
| + return nil, os.NewSyscallError("fchmod", err) |
| + } |
| + if err := syscall.Bind(fd, &syscall.SockaddrUnix{Name: socketPath}); err != nil { |
| + return nil, &net.OpError{Op: "listen", Net: "unix", Addr: &net.UnixAddr{Name: socketPath, Net: "unix"}, Err: os.NewSyscallError("bind", err)} |
| + } |
| + bound := true |
| + defer func() { |
| + if bound { |
| + os.Remove(socketPath) |
| + } |
| + }() |
| + // The kernel caps the backlog at net.core.somaxconn. |
| + if err := syscall.Listen(fd, 4096); err != nil { |
| + return nil, os.NewSyscallError("listen", err) |
| + } |
| + // Fail closed if the file came out reachable by group or other: that |
| + // would mean the kernel ignored the socket's mode, and the window this |
| + // function exists to close was open. |
| + fi, err := os.Lstat(socketPath) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if fi.Mode().Type() != os.ModeSocket || fi.Mode().Perm()&0o077 != 0 { |
| + return nil, fmt.Errorf("unix socket %s was created %v, not owner-only", socketPath, fi.Mode()) |
| + } |
| + // The umask can only have taken bits away; give the owner back read |
| + // and write so it can connect. |
| + if err := os.Chmod(socketPath, 0600); err != nil { |
| + return nil, err |
| + } |
| + fl, err := net.FileListener(f) |
| + if err != nil { |
| + return nil, err |
| + } |
| + ln, ok := fl.(*net.UnixListener) |
| + if !ok { |
| + fl.Close() |
| + return nil, fmt.Errorf("unix socket %s: listener is %T, not a unix listener", socketPath, fl) |
| + } |
| + // A listener made from a file does not remove its path on Close; one |
| + // from net.Listen("unix") does, and callers rely on it. |
| + ln.SetUnlinkOnClose(true) |
| + bound = false |
| + return ln, nil |
| +} |
diff --git a/internal/edge/purge_listen_linux_test.go b/internal/edge/purge_listen_linux_test.go| new file mode 100644 |
| index 000000000..70b086839 |
| --- /dev/null |
| +++ b/internal/edge/purge_listen_linux_test.go |
| @@ -0,0 +1,230 @@ |
| +//go:build linux |
| + |
| +package edge |
| + |
| +import ( |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "syscall" |
| + "testing" |
| +) |
| + |
| +// amadan #337: binding must not change the process umask, so directories |
| +// another goroutine makes while sockets are being bound keep their mode. |
| +// Under the old umask-0177 bind, a 0755 MkdirAll racing it came out 0600. |
| +// Not parallel: it sets the process umask to a known value for its run. |
| +func TestSecureUnixListenLeavesTheUmaskAlone(t *testing.T) { |
| + old := syscall.Umask(0o022) |
| + t.Cleanup(func() { syscall.Umask(old) }) |
| + root := t.TempDir() |
| + if err := os.MkdirAll(filepath.Join(root, "l"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The directory maker runs for as long as the binder does, starting |
| + // together, so the two always overlap. |
| + const rounds = 300 |
| + start := make(chan struct{}) |
| + binding := make(chan struct{}) |
| + var wg sync.WaitGroup |
| + wg.Add(2) |
| + go func() { |
| + defer wg.Done() |
| + defer close(binding) |
| + <-start |
| + for i := range rounds { |
| + ln, err := SecureUnixListen(filepath.Join(root, "l", "s"+strconv.Itoa(i)+".sock")) |
| + if err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + ln.Close() |
| + } |
| + }() |
| + var dirs []string |
| + go func() { |
| + defer wg.Done() |
| + <-start |
| + for i := 0; ; i++ { |
| + select { |
| + case <-binding: |
| + return |
| + default: |
| + } |
| + d := filepath.Join(root, "d", strconv.Itoa(i)) |
| + if err := os.MkdirAll(d, 0o755); err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + dirs = append(dirs, d) |
| + } |
| + }() |
| + close(start) |
| + wg.Wait() |
| + if len(dirs) == 0 { |
| + t.Fatal("no directory was made while the binds ran") |
| + } |
| + for _, d := range dirs { |
| + fi, err := os.Stat(d) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := fi.Mode().Perm(); got != 0o755 { |
| + t.Fatalf("%s is %o, want 0755: a bind changed the process umask", d, got) |
| + } |
| + } |
| + if got := syscall.Umask(0o022); got != 0o022 { |
| + t.Fatalf("umask after the binds = %o, want 022", got) |
| + } |
| +} |
| + |
| +// The listener is reached at socketPath, reports it, removes it on Close, |
| +// and leaves no private directory behind. |
| +func TestSecureUnixListenServesAtItsPathAndLeavesNothingBehind(t *testing.T) { |
| + t.Parallel() |
| + dir := t.TempDir() |
| + sock := filepath.Join(dir, "c.sock") |
| + ln, err := SecureUnixListen(sock) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := ln.Addr().String(); got != sock { |
| + t.Errorf("Addr = %s, want %s", got, sock) |
| + } |
| + accepted := make(chan error, 1) |
| + go func() { |
| + c, err := ln.Accept() |
| + if err == nil { |
| + c.Close() |
| + } |
| + accepted <- err |
| + }() |
| + c, err := net.Dial("unix", sock) |
| + if err != nil { |
| + t.Fatalf("dial %s: %v", sock, err) |
| + } |
| + c.Close() |
| + if err := <-accepted; err != nil { |
| + t.Fatal(err) |
| + } |
| + if entries := names(t, dir); len(entries) != 1 || entries[0] != "c.sock" { |
| + t.Fatalf("directory holds %v, want only c.sock", entries) |
| + } |
| + if err := ln.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if entries := names(t, dir); len(entries) != 0 { |
| + t.Fatalf("after Close the directory holds %v, want nothing", entries) |
| + } |
| +} |
| + |
| +// A path that cannot be cleared (here: a directory with something in it) |
| +// fails before anything is bound, and is left as it was. |
| +func TestSecureUnixListenRefusesAPathItCannotClear(t *testing.T) { |
| + t.Parallel() |
| + dir := t.TempDir() |
| + sock := filepath.Join(dir, "c.sock") |
| + if err := os.MkdirAll(filepath.Join(sock, "occupied"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ln, err := SecureUnixListen(sock); err == nil { |
| + ln.Close() |
| + t.Fatal("bound over a non-empty directory") |
| + } |
| + if entries := names(t, dir); len(entries) != 1 || entries[0] != "c.sock" { |
| + t.Fatalf("directory holds %v after a failed bind, want only the original c.sock", entries) |
| + } |
| +} |
| + |
| +// An abstract address has no file, so no permissions: refused, not bound. |
| +func TestSecureUnixListenRefusesAnAbstractAddress(t *testing.T) { |
| + t.Parallel() |
| + for _, p := range []string{"@carlos-test-abstract", ""} { |
| + if ln, err := SecureUnixListen(p); err == nil { |
| + ln.Close() |
| + t.Errorf("SecureUnixListen(%q) bound; want a refusal", p) |
| + } |
| + } |
| +} |
| + |
| +// The listener is a *net.UnixListener, as net.Listen("unix") gives, so |
| +// callers keep SetDeadline, File and SetUnlinkOnClose. |
| +func TestSecureUnixListenGivesAUnixListener(t *testing.T) { |
| + t.Parallel() |
| + ln, err := SecureUnixListen(filepath.Join(t.TempDir(), "c.sock")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer ln.Close() |
| + if _, ok := ln.(*net.UnixListener); !ok { |
| + t.Fatalf("listener is %T, want *net.UnixListener", ln) |
| + } |
| +} |
| + |
| +// A second Close must not remove a socket a later listener has since bound |
| +// at the same path. |
| +func TestSecureUnixListenClosesItsPathOnce(t *testing.T) { |
| + t.Parallel() |
| + sock := filepath.Join(t.TempDir(), "c.sock") |
| + first, err := SecureUnixListen(sock) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + first.Close() |
| + second, err := SecureUnixListen(sock) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer second.Close() |
| + first.Close() |
| + if _, err := os.Stat(sock); err != nil { |
| + t.Fatalf("the second listener's socket is gone after the first closed again: %v", err) |
| + } |
| +} |
| + |
| +func names(t *testing.T, dir string) []string { |
| + t.Helper() |
| + entries, err := os.ReadDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out []string |
| + for _, e := range entries { |
| + out = append(out, e.Name()) |
| + } |
| + return out |
| +} |
| + |
| +// A socket path at the longest length the kernel accepts still binds: the |
| +// private directory's name is sized so the bind inside it is no longer |
| +// than socketPath, even for a short socket name. |
| +func TestSecureUnixListenBindsAtTheLengthLimit(t *testing.T) { |
| + t.Parallel() |
| + limit := 107 // sun_path is 108 bytes on Linux, including the NUL |
| + if runtime.GOOS != "linux" { |
| + limit = 103 // 104 on the BSDs and darwin |
| + } |
| + dir := t.TempDir() |
| + pad := limit - len(dir) - len("/") - len("/c.sock") |
| + if pad < 1 { |
| + t.Skipf("temp dir %s leaves no room under %d bytes", dir, limit) |
| + } |
| + long := filepath.Join(dir, strings.Repeat("p", pad)) |
| + if err := os.Mkdir(long, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + sock := filepath.Join(long, "c.sock") |
| + if len(sock) != limit { |
| + t.Fatalf("built a %d-byte path, want %d", len(sock), limit) |
| + } |
| + ln, err := SecureUnixListen(sock) |
| + if err != nil { |
| + t.Fatalf("a %d-byte socket path did not bind: %v", len(sock), err) |
| + } |
| + ln.Close() |
| +} |
diff --git a/internal/edge/purge_listen_unix.go b/internal/edge/purge_listen_unix.go| index d4a8cdb3b..857ae5c89 100644 |
| --- a/internal/edge/purge_listen_unix.go |
| +++ b/internal/edge/purge_listen_unix.go |
| @@ -1,4 +1,4 @@ |
| -//go:build !windows |
| +//go:build !windows && !linux |
| package edge |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 217c65123..495304a7c 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -1101,6 +1101,23 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| t.Fatal(err) |
| } |
| seal = "3737ebbd0ee87811839624724e552e0af02f63d70baaa0516a024440d848fdae" |
| + // Census overlay securelisten-337 (amadan #337: edge.SecureUnixListen |
| + // makes its socket owner-only with fchmod before bind instead of setting |
| + // the process umask around it), loaded last, after ci-speed-activator, |
| + // under census-ruling-securelisten-337 (Paul, as census owner, |
| + // 2026-10-21, accepted as proposed). SecureUnixListen in purge_listen_linux.go is new |
| + // accepted legacy at J4 root-input (as pendingDirs.appeared and its |
| + // caller ListenControl): one Lstat of the socket the agent has just |
| + // bound for itself to serve. The internal/edge envelope is re-pinned. |
| + // One frozen declaration is added, so the seal moves; |
| + // ci-speed-activator's is checked first. |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-securelisten-337.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + seal = "33a8ff46dd9fcf85664c42c42eaf01585af6c92806cb7830fa8f30fd0950c430" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/testdata/plan14-root-reads-securelisten-337.tsv b/internal/provision/testdata/plan14-root-reads-securelisten-337.tsv| new file mode 100644 |
| index 000000000..a402400b2 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-securelisten-337.tsv |
| @@ -0,0 +1,6 @@ |
| +# Census overlay securelisten-337 (amadan #337: edge.SecureUnixListen makes its socket owner-only with fchmod before bind instead of setting the process umask to 0177 around the bind), loaded LAST, after ci-speed-activator. RULED as census-ruling-securelisten-337 (Paul, as census owner, 2026-10-21), accepted as proposed. |
| +# SecureUnixListen in internal/edge/purge_listen_linux.go (new file, //go:build linux) is a new unit with one read site: an Lstat of the socket it has just bound, to fail closed if group or other bits are set. It runs in the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos) on the sockets the agent itself is about to serve, and makes no root decision (it also refuses an empty or abstract @ path before bind, and the listener it returns removes its path on Close through SetUnlinkOnClose), so it is accepted legacy at J4 root-input, the disposition census-ruling-moves-offline-fixes gave pendingDirs.appeared (the agent's own Lstat of paths it made) and plan14-root-reads.tsv gives ListenControl, its caller. Its callers (listenLogs, listenCmdStream, ListenControl) are unchanged. The old umask implementation stays in purge_listen_unix.go for other unix systems (build tag now !windows && !linux); the census scans linux only and finds no read there. |
| +# Pinned at branch commit 8fb4ccb1 (Astra review: the listener returned is the *net.UnixListener itself with SetUnlinkOnClose(true); empty and abstract paths refused). One frozen declaration is added, so the seal moves; ci-speed-activator's is checked first. The internal/edge envelope is re-pinned for the new file and the build tag change. |
| +@legacy 670864719cd404f826f3ba1365d8867d0cbb1c0b2eda867676c34968493fd4d7 internal/edge/purge_listen_linux.go SecureUnixListen legacy accepted-legacy accepted-legacy;rev3=census-ruling-securelisten-337;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=root-input (J4, as pendingDirs.appeared under census-ruling-moves-offline-fixes and as ListenControl, its caller); new on Linux (amadan #337), replacing the umask bind, which set the process umask to 0177 and so stripped group and other bits from files other goroutines made meanwhile: one Lstat, which never follows a link, of the unix socket this call has just bound at socketPath for the agent itself to serve (an instance's purge control socket through ListenControl, a role's log socket through listenLogs, a cmd unit's stdout and stderr sockets through listenCmdStream), so the bind fails closed unless the file is a socket with no group or other bits (an empty or abstract @ path is refused before bind, since it has no file and so no permissions); it reads no file content, decides only whether this bind fails, and makes no root decision. startIsolated (claim mode, rolelaunch_root_linux.go) also calls listenLogs, as the agent and outside its l.Work.With step, before it asks root to run the reservation; root never opens or reads the socket file:Lstat@1490 2b037b125506ef5e89c9c2ffedca1474c23ecd30113eac3e7586fc41af6af6b6 |
| +internal/edge/purge_listen_linux.go SecureUnixListen legacy accepted-legacy accepted-legacy;rev3=census-ruling-securelisten-337;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=root-input (J4, as pendingDirs.appeared under census-ruling-moves-offline-fixes and as ListenControl, its caller); new on Linux (amadan #337), replacing the umask bind, which set the process umask to 0177 and so stripped group and other bits from files other goroutines made meanwhile: one Lstat, which never follows a link, of the unix socket this call has just bound at socketPath for the agent itself to serve (an instance's purge control socket through ListenControl, a role's log socket through listenLogs, a cmd unit's stdout and stderr sockets through listenCmdStream), so the bind fails closed unless the file is a socket with no group or other bits (an empty or abstract @ path is refused before bind, since it has no file and so no permissions); it reads no file content, decides only whether this bind fails, and makes no root decision. startIsolated (claim mode, rolelaunch_root_linux.go) also calls listenLogs, as the agent and outside its l.Work.With step, before it asks root to run the reservation; root never opens or reads the socket file:Lstat@1490 2b037b125506ef5e89c9c2ffedca1474c23ecd30113eac3e7586fc41af6af6b6 |
| +@coverage internal/edge e8f21100bb0132adc26fb49e12143780e9fbcfecd33ea8e91b6ad7db89198021 |