a73x

8657666e

refactor: peer credentials and the parent walk go through the os rows

a73x   2026-09-03 15:10

Commit message
refactor: peer credentials and the parent walk go through the os rows

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW

build.zig
Old New
@@ -229,7 +229,7 @@ const mod_table = [_]ModSpec{
229 // sits here rather than in either front so the CLI wall and the browser 229 // sits here rather than in either front so the CLI wall and the browser
230 // hub resolve a host line the same way. Nothing here WRITES that file — 230 // hub resolve a host line the same way. Nothing here WRITES that file —
231 // `wall_host.recordHost` and `webhub_main` do. 231 // `wall_host.recordHost` and `webhub_main` do.
232 .{ .name = "client", .path = "src/client/client.zig", .link_libc = true, .imports = &.{ "term", "quic", "xdg", "sockpath", "serve", "dial", "link" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 232 .{ .name = "client", .path = "src/client/client.zig", .link_libc = true, .imports = &.{ "term", "quic", "xdg", "sockpath", "serve", "dial", "link", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
233 // ---- the two fronts ---- 233 // ---- the two fronts ----
234 // The browser hub's HTTP/WebSocket decisions: Origin gate, route table, 234 // The browser hub's HTTP/WebSocket decisions: Origin gate, route table,
235 // WS endpoint naming. Assets are injected (the exe root @embedFiles 235 // WS endpoint naming. Assets are injected (the exe root @embedFiles
src/cli/main.zig
Old New
@@ -692,13 +692,11 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
692 return 1; 692 return 1;
693 } 693 }
694 694
695 /// Read the peer pid from the kernel. Return null when the kernel cannot expose 695 /// The peer's pid, or null when the kernel cannot expose it; callers then
696 /// it, such as across a pid namespace; callers then rely on socket shutdown. 696 /// rely on socket shutdown.
697 fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t { 697 fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t {
698 const Ucred = extern struct { pid: std.posix.pid_t, uid: std.posix.uid_t, gid: std.posix.gid_t }; 698 const cred = server_os.peerCred(fd) orelse return null;
699 var cred: Ucred = undefined; 699 return cred.pid;
700 std.posix.getsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, std.mem.asBytes(&cred)) catch return null;
701 return if (cred.pid > 0) cred.pid else null;
702 } 700 }
703 701
704 /// Wait for the peer process after its socket disappears. Socket unlink occurs 702 /// Wait for the peer process after its socket disappears. Socket unlink occurs
@@ -2007,10 +2005,10 @@ test "peerPid: the kernel names the peer" {
2007 // Both socketpair endpoints belong to this process, so kernel credentials 2005 // Both socketpair endpoints belong to this process, so kernel credentials
2008 // must report the current pid. 2006 // must report the current pid.
2009 var sp: [2]i32 = undefined; 2007 var sp: [2]i32 = undefined;
2010 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp)); 2008 try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp));
2011 defer std.posix.close(sp[0]); 2009 defer std.posix.close(sp[0]);
2012 defer std.posix.close(sp[1]); 2010 defer std.posix.close(sp[1]);
2013 try std.testing.expectEqual(std.os.linux.getpid(), peerPid(sp[0]).?); 2011 try std.testing.expectEqual(server_os.getpid(), peerPid(sp[0]).?);
2014 } 2012 }
2015 2013
2016 test "waitPidGone: returns only once the OS has no such process" { 2014 test "waitPidGone: returns only once the OS has no such process" {
src/client/askpass.zig
Old New
@@ -12,6 +12,7 @@ const std = @import("std");
12 // inside the struct the bare name is ambiguous. 12 // inside the struct the bare name is ambiguous.
13 const serve_mod = @import("serve"); 13 const serve_mod = @import("serve");
14 const xdg = @import("xdg"); 14 const xdg = @import("xdg");
15 const client_os = @import("client_os");
15 16
16 /// Env var naming the socket. The mode word for the helper, too: ssh execs 17 /// Env var naming the socket. The mode word for the helper, too: ssh execs
17 /// its helper with the prompt as argv[1] and nothing else, so there is no 18 /// its helper with the prompt as argv[1] and nothing else, so there is no
@@ -152,7 +153,7 @@ pub const Listener = struct {
152 const path = try std.fmt.allocPrint( 153 const path = try std.fmt.allocPrint(
153 alloc, 154 alloc,
154 "{s}/mux-ask-{d}.sock", 155 "{s}/mux-ask-{d}.sock",
155 .{ runtime_dir, std.os.linux.getpid() }, 156 .{ runtime_dir, client_os.getpid() },
156 ); 157 );
157 errdefer alloc.free(path); 158 errdefer alloc.free(path);
158 // Every wall that died by signal — a closed terminal window, a kill 159 // Every wall that died by signal — a closed terminal window, a kill
@@ -296,12 +297,10 @@ pub const Listener = struct {
296 } 297 }
297 298
298 fn serve(self: *Listener, c: std.posix.socket_t) void { 299 fn serve(self: *Listener, c: std.posix.socket_t) void {
299 const cred = peerCred(c) orelse return; 300 const cred = client_os.peerCred(c) orelse return;
300 // The 0700 runtime directory is the boundary, and mux takes 301 // Both checks are `client_os.peerCred`'s to explain.
301 // `$XDG_RUNTIME_DIR` as found. Where it is not private, THIS line stops 302 if (cred.uid != client_os.geteuid()) return;
302 // another local user raising a prompt and reading the answer. 303 var p: Prompt = .{ .ssh_pid = dialOwner(cred.pid, client_os.getpid(), client_os.parentOf) };
303 if (cred.uid != std.os.linux.geteuid()) return;
304 var p: Prompt = .{ .ssh_pid = dialOwner(cred.pid, std.os.linux.getpid(), parentOf) };
305 var raw: [prompt_max + 1]u8 = undefined; 304 var raw: [prompt_max + 1]u8 = undefined;
306 // Bounded, because an accept thread parked in `read` is every later 305 // Bounded, because an accept thread parked in `read` is every later
307 // prompt of this wall parked behind it — and `stop`'s join with it. 306 // prompt of this wall parked behind it — and `stop`'s join with it.
@@ -467,23 +466,10 @@ fn writeAll(fd: std.posix.fd_t, bytes: []const u8) bool {
467 return true; 466 return true;
468 } 467 }
469 468
470 const Ucred = extern struct {
471 pid: std.posix.pid_t,
472 uid: std.posix.uid_t,
473 gid: std.posix.gid_t,
474 };
475
476 /// How far up the tree the walk goes. ssh execs its helper directly, so 469 /// How far up the tree the walk goes. ssh execs its helper directly, so
477 /// production is one step; the slack is for a shell in between. 470 /// production is one step; the slack is for a shell in between.
478 const ancestor_max = 8; 471 const ancestor_max = 8;
479 472
480 /// Who is on the other end, or null when the kernel will not say.
481 fn peerCred(c: std.posix.socket_t) ?Ucred {
482 var buf: [@sizeOf(Ucred)]u8 = undefined;
483 std.posix.getsockopt(c, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, &buf) catch return null;
484 return std.mem.bytesToValue(Ucred, &buf);
485 }
486
487 /// The ssh THIS process spawned that is behind `peer`: the ancestor whose 473 /// The ssh THIS process spawned that is behind `peer`: the ancestor whose
488 /// parent is us. 474 /// parent is us.
489 fn dialOwner( 475 fn dialOwner(
@@ -506,26 +492,6 @@ fn dialOwner(
506 return 0; 492 return 0;
507 } 493 }
508 494
509 /// `/proc/<pid>/stat` field 4. Parsed from the LAST ')' rather than by
510 /// counting spaces: field 2 is the executable's name, unquoted, and a
511 /// program free to call itself `a b) c` is a program free to move every
512 /// field after it.
513 fn parentOf(pid: std.posix.pid_t) std.posix.pid_t {
514 if (pid <= 0) return 0;
515 var path_buf: [64]u8 = undefined;
516 const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/stat", .{pid}) catch return 0;
517 var stat_buf: [512]u8 = undefined;
518 const f = std.fs.cwd().openFile(path, .{}) catch return 0;
519 defer f.close();
520 const n = f.read(&stat_buf) catch return 0;
521 const text = stat_buf[0..n];
522 const close = std.mem.lastIndexOfScalar(u8, text, ')') orelse return 0;
523 var it = std.mem.tokenizeScalar(u8, text[close + 1 ..], ' ');
524 _ = it.next() orelse return 0; // the run state
525 const ppid = it.next() orelse return 0;
526 return std.fmt.parseInt(std.posix.pid_t, ppid, 10) catch 0;
527 }
528
529 // ---- tests ---- 495 // ---- tests ----
530 496
531 const testtmp = @import("testtmp"); 497 const testtmp = @import("testtmp");
@@ -727,14 +693,6 @@ test "askpass: a helper two shells below the ssh we spawned is still that ssh's"
727 try std.testing.expectEqual(@as(std.posix.pid_t, 0), dialOwner(1, 7, FakeTree.ring)); 693 try std.testing.expectEqual(@as(std.posix.pid_t, 0), dialOwner(1, 7, FakeTree.ring));
728 } 694 }
729 695
730 test "askpass.parentOf: the field it reads is the one the OS calls ppid" {
731 // The reader, against the OS itself rather than against a fixture —
732 // `/proc/<pid>/stat` field 4 is positional, and a comment claiming
733 // which field that is cannot fail.
734 try std.testing.expectEqual(std.os.linux.getppid(), parentOf(std.os.linux.getpid()));
735 try std.testing.expectEqual(@as(std.posix.pid_t, 0), parentOf(0));
736 }
737
738 test "askpass.Listener: a helper that is not a child of ours is attributed to nothing" { 696 test "askpass.Listener: a helper that is not a child of ours is attributed to nothing" {
739 const alloc = std.testing.allocator; 697 const alloc = std.testing.allocator;
740 var tmp = try testtmp.TmpDir.make(); 698 var tmp = try testtmp.TmpDir.make();
src/os/client_os.zig
Old New
@@ -16,10 +16,49 @@ pub fn getpid() std.posix.pid_t {
16 return impl.getpid(); 16 return impl.getpid();
17 } 17 }
18 18
19 /// Who is on the other end of the askpass socket. The 0700 runtime
20 /// directory is the boundary and mux takes it as found; where it is not
21 /// private, the uid here is what stops another local user raising a prompt
22 /// and reading the answer, and the pid is what attributes a prompt to the
23 /// ssh THIS wall spawned.
24 pub const PeerCred = struct { uid: std.posix.uid_t, pid: std.posix.pid_t };
25 pub fn peerCred(fd: std.posix.socket_t) ?PeerCred {
26 return impl.peerCred(fd);
27 }
28
29 /// The parent of `pid`, or 0 when the OS will not say or `pid` is not
30 /// positive. One step of the walk from an askpass helper up to the ssh a
31 /// dial spawned.
32 pub fn parentOf(pid: std.posix.pid_t) std.posix.pid_t {
33 if (pid <= 0) return 0;
34 return impl.parentOf(pid);
35 }
36
37 /// The effective uid, for the askpass caller check above.
38 pub fn geteuid() std.posix.uid_t {
39 return impl.geteuid();
40 }
41
19 test "client_os: the arm compiles and answers for the process it is in" { 42 test "client_os: the arm compiles and answers for the process it is in" {
20 try std.testing.expect(getpid() > 0); 43 try std.testing.expect(getpid() > 0);
21 } 44 }
22 45
46 test "client_os.peerCred and parentOf: asked of the OS, not a fixture" {
47 // Both socketpair ends belong to this process, so the kernel must name
48 // it; and `parentOf` is graded against the ppid the OS itself reports,
49 // because the field it reads is positional and a comment naming that
50 // field cannot fail.
51 var sp: [2]std.posix.fd_t = undefined;
52 try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp));
53 defer std.posix.close(sp[0]);
54 defer std.posix.close(sp[1]);
55 const cred = peerCred(sp[0]) orelse return error.NoCred;
56 try std.testing.expectEqual(getpid(), cred.pid);
57 try std.testing.expectEqual(geteuid(), cred.uid);
58 try std.testing.expectEqual(std.c.getppid(), parentOf(getpid()));
59 try std.testing.expectEqual(@as(std.posix.pid_t, 0), parentOf(0));
60 }
61
23 test { 62 test {
24 std.testing.refAllDeclsRecursive(@This()); 63 std.testing.refAllDeclsRecursive(@This());
25 } 64 }
src/os/client_os_linux.zig
Old New
@@ -1,6 +1,37 @@
1 //! Linux arm of `client_os`. Spellings only; the contract is in the root. 1 //! Linux arm of `client_os`. Spellings only; the contract is in the root.
2 const std = @import("std"); 2 const std = @import("std");
3 const root = @import("client_os.zig");
3 4
4 pub fn getpid() std.posix.pid_t { 5 pub fn getpid() std.posix.pid_t {
5 return std.os.linux.getpid(); 6 return std.os.linux.getpid();
6 } 7 }
8
9 pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred {
10 const Ucred = extern struct { pid: std.posix.pid_t, uid: std.posix.uid_t, gid: std.posix.gid_t };
11 var cred: Ucred = undefined;
12 std.posix.getsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, std.mem.asBytes(&cred)) catch return null;
13 return .{ .uid = cred.uid, .pid = cred.pid };
14 }
15
16 /// `/proc/<pid>/stat` field 4. Parsed from the LAST ')' rather than by
17 /// counting spaces: field 2 is the executable's name, unquoted, and a
18 /// program free to call itself `a b) c` is a program free to move every
19 /// field after it.
20 pub fn parentOf(pid: std.posix.pid_t) std.posix.pid_t {
21 var path_buf: [64]u8 = undefined;
22 const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/stat", .{pid}) catch return 0;
23 var stat_buf: [512]u8 = undefined;
24 const f = std.fs.cwd().openFile(path, .{}) catch return 0;
25 defer f.close();
26 const n = f.read(&stat_buf) catch return 0;
27 const text = stat_buf[0..n];
28 const close = std.mem.lastIndexOfScalar(u8, text, ')') orelse return 0;
29 var it = std.mem.tokenizeScalar(u8, text[close + 1 ..], ' ');
30 _ = it.next() orelse return 0; // the run state
31 const ppid = it.next() orelse return 0;
32 return std.fmt.parseInt(std.posix.pid_t, ppid, 10) catch 0;
33 }
34
35 pub fn geteuid() std.posix.uid_t {
36 return std.os.linux.geteuid();
37 }
src/os/server_os.zig
Old New
@@ -21,6 +21,19 @@ pub fn getpid() std.posix.pid_t {
21 return impl.getpid(); 21 return impl.getpid();
22 } 22 }
23 23
24 /// Who is on the other end of a unix socket, or null when the kernel will
25 /// not say (across a pid namespace, for one); callers then rely on socket
26 /// shutdown. The daemon uses the pid to wait for a client that vanished.
27 /// "Will not say" is THIS root's rule and not an arm's: a kernel that
28 /// answers at all still reports a pid of 0 for a peer it cannot name, so
29 /// the non-positive pid is rejected here and an arm returns what it read.
30 pub const PeerCred = struct { uid: std.posix.uid_t, pid: std.posix.pid_t };
31 pub fn peerCred(fd: std.posix.socket_t) ?PeerCred {
32 const cred = impl.peerCred(fd) orelse return null;
33 if (cred.pid <= 0) return null;
34 return cred;
35 }
36
24 pub const Winsize = std.posix.winsize; 37 pub const Winsize = std.posix.winsize;
25 pub const ForkedPty = struct { pid: std.posix.pid_t, master: std.posix.fd_t }; 38 pub const ForkedPty = struct { pid: std.posix.pid_t, master: std.posix.fd_t };
26 39
@@ -87,6 +100,16 @@ test "server_os: the arm compiles and answers for the process it is in" {
87 try std.testing.expect(getpid() > 0); 100 try std.testing.expect(getpid() > 0);
88 } 101 }
89 102
103 test "server_os.peerCred: the kernel names the peer of a socketpair as this process" {
104 var sp: [2]std.posix.fd_t = undefined;
105 try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp));
106 defer std.posix.close(sp[0]);
107 defer std.posix.close(sp[1]);
108 const cred = peerCred(sp[0]) orelse return error.NoCred;
109 try std.testing.expectEqual(getpid(), cred.pid);
110 try std.testing.expectEqual(std.c.geteuid(), cred.uid);
111 }
112
90 test "server_os.closeFrom: a fd below the floor survives and one above does not" { 113 test "server_os.closeFrom: a fd below the floor survives and one above does not" {
91 // pipe(2) sets no CLOEXEC, so a child that did not close would still 114 // pipe(2) sets no CLOEXEC, so a child that did not close would still
92 // hold pipe[1]. Asked through /dev/fd, which both OSes have. 115 // hold pipe[1]. Asked through /dev/fd, which both OSes have.
src/os/server_os_linux.zig
Old New
@@ -10,6 +10,13 @@ pub fn getpid() std.posix.pid_t {
10 return std.os.linux.getpid(); 10 return std.os.linux.getpid();
11 } 11 }
12 12
13 pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred {
14 const Ucred = extern struct { pid: std.posix.pid_t, uid: std.posix.uid_t, gid: std.posix.gid_t };
15 var cred: Ucred = undefined;
16 std.posix.getsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, std.mem.asBytes(&cred)) catch return null;
17 return .{ .uid = cred.uid, .pid = cred.pid };
18 }
19
13 pub fn forkPty(ws: root.Winsize) error{ForkPtyFailed}!root.ForkedPty { 20 pub fn forkPty(ws: root.Winsize) error{ForkPtyFailed}!root.ForkedPty {
14 var master: c_int = undefined; 21 var master: c_int = undefined;
15 var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 }; 22 var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 };