8657666e
refactor: peer credentials and the parent walk go through the os rows
a73x 2026-09-03 15:10
Commit message
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 }; |