83c02b92
feat: muxweb runtime wall — POST/DELETE/PUT /tiles, argv seeds or file restores
a73x 2026-08-18 18:00
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -239,7 +239,9 @@ const mod_table = [_]ModSpec{ | |||
| 239 | // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route | 239 | // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route |
| 240 | // table, WS endpoint naming. Assets are injected (the exe root | 240 | // table, WS endpoint naming. Assets are injected (the exe root |
| 241 | // @embedFiles them), so its tests build no artifacts. | 241 | // @embedFiles them), so its tests build no artifacts. |
| 242 | .{ .name = "webhub", .path = "src/webhub.zig", .layer = 3, .imports = &.{ "protocol", "client", "wall", "handoff", "xdg" }, .quic_tests = true }, | 242 | // sockpath is the sun_path bound a `--sock` tile is refused against — |
| 243 | // the check argv used to make before the Hub owned resolution. | ||
| 244 | .{ .name = "webhub", .path = "src/webhub.zig", .layer = 3, .imports = &.{ "protocol", "client", "wall", "handoff", "xdg", "sockpath" }, .quic_tests = true }, | ||
| 243 | // sockpath is the sun_path bound only; the client binds no socket itself. | 245 | // sockpath is the sun_path bound only; the client binds no socket itself. |
| 244 | // protocol is the session-name validator alone (validSessionName): a bad | 246 | // protocol is the session-name validator alone (validSessionName): a bad |
| 245 | // --session has to be a usage error here, at parse, not bytes some | 247 | // --session has to be a usage error here, at parse, not bytes some |
| @@ -253,12 +255,12 @@ const mod_table = [_]ModSpec{ | |||
| 253 | // daemon itself never touches. | 255 | // daemon itself never touches. |
| 254 | .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 256 | .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 255 | // ---- layer 4 ---- | 257 | // ---- layer 4 ---- |
| 256 | // The HOST tile's recipe comes from the same owner mux_main uses, and | 258 | // wall owns the spelling grammar and the state file, so argv is parsed |
| 257 | // sockpath is the sun_path bound its --sock tiles are refused against. | 259 | // by the SAME rules the page's POST /tiles and the restored file are — |
| 258 | // protocol is here for one function — validSessionName — so a TARGET#NAME | 260 | // one grammar, not three. Resolution itself now lives in the Hub, so |
| 259 | // tile is refused by the SAME rule `mux --session` and `muxa --session` | 261 | // handoff/protocol left with it; sockpath stays for the one startup |
| 260 | // use, rather than by a second spelling of "printable, no space". | 262 | // message that names the sun_path bound. |
| 261 | .{ .name = "webhub_main", .path = "src/webhub_main.zig", .layer = 4, .link_libc = true, .imports = &.{ "client", "webhub", "protocol", "xdg", "handoff", "sockpath" }, .quic_tests = true }, | 263 | .{ .name = "webhub_main", .path = "src/webhub_main.zig", .layer = 4, .link_libc = true, .imports = &.{ "client", "webhub", "wall", "xdg", "sockpath" }, .quic_tests = true }, |
| 262 | }; | 264 | }; |
| 263 | 265 | ||
| 264 | /// Comptime row lookup. Every hand-written module name in this file goes | 266 | /// Comptime row lookup. Every hand-written module name in this file goes |
src/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -15,6 +15,7 @@ const client = @import("client"); | |||
| 15 | const wall = @import("wall"); | 15 | const wall = @import("wall"); |
| 16 | const handoff = @import("handoff"); | 16 | const handoff = @import("handoff"); |
| 17 | const xdg = @import("xdg"); | 17 | const xdg = @import("xdg"); |
| 18 | const sockpath = @import("sockpath"); | ||
| 18 | 19 | ||
| 19 | pub const default_port: u16 = 7681; | 20 | pub const default_port: u16 = 7681; |
| 20 | 21 | ||
| @@ -71,9 +72,13 @@ const HubTile = struct { | |||
| 71 | }; | 72 | }; |
| 72 | 73 | ||
| 73 | /// What a spelling can fail to become. `MissingKey` is a `quic://` entry | 74 | /// What a spelling can fail to become. `MissingKey` is a `quic://` entry |
| 74 | /// with no key to prove itself with; `PersistFailed` is a mutation the | 75 | /// with no key to prove itself with; `SockPathTooLong` is a path sun_path |
| 75 | /// wall file did not accept. | 76 | /// cannot hold; `PersistFailed` is a mutation the wall file did not accept. |
| 76 | pub const AddError = wall.ParseError || error{ MissingKey, OutOfMemory, PersistFailed }; | 77 | pub const AddError = wall.ParseError || ResolveError || error{PersistFailed}; |
| 78 | |||
| 79 | /// The half of AddError a spelling can fail at resolution time — shared | ||
| 80 | /// with `Hub.init`, which resolves the whole wall before serving. | ||
| 81 | pub const ResolveError = error{ MissingKey, SockPathTooLong, OutOfMemory }; | ||
| 77 | 82 | ||
| 78 | /// Spelling → client.Target. The same resolution argv gets at startup, | 83 | /// Spelling → client.Target. The same resolution argv gets at startup, |
| 79 | /// so a tile added at runtime means exactly what one typed on the command | 84 | /// so a tile added at runtime means exactly what one typed on the command |
| @@ -83,7 +88,7 @@ fn resolveTile( | |||
| 83 | spelling: []const u8, | 88 | spelling: []const u8, |
| 84 | key: ?[]const u8, | 89 | key: ?[]const u8, |
| 85 | idle_ms: u32, | 90 | idle_ms: u32, |
| 86 | ) (wall.ParseError || error{ MissingKey, OutOfMemory })!struct { | 91 | ) (wall.ParseError || ResolveError)!struct { |
| 87 | target: client.Target, | 92 | target: client.Target, |
| 88 | label: []const u8, | 93 | label: []const u8, |
| 89 | session: []const u8, | 94 | session: []const u8, |
| @@ -94,7 +99,15 @@ fn resolveTile( | |||
| 94 | const label = try arena.dupe(u8, spelling); | 99 | const label = try arena.dupe(u8, spelling); |
| 95 | const session = try arena.dupe(u8, p.session); | 100 | const session = try arena.dupe(u8, p.session); |
| 96 | const target: client.Target = switch (p.spec) { | 101 | const target: client.Target = switch (p.spec) { |
| 97 | .sock => |path| .{ .sock = try arena.dupe(u8, path) }, | 102 | // sun_path is a fixed array in the kernel's struct: a longer path |
| 103 | // cannot be dialed at all, so it is refused here rather than at a | ||
| 104 | // connect that fails with a truncated name nobody typed. muxweb's | ||
| 105 | // argv path made this check before the Hub existed; a POSTed | ||
| 106 | // spelling gets the same answer. | ||
| 107 | .sock => |path| if (path.len > sockpath.max_sun_path) | ||
| 108 | return error.SockPathTooLong | ||
| 109 | else | ||
| 110 | .{ .sock = try arena.dupe(u8, path) }, | ||
| 98 | .host => |h| blk: { | 111 | .host => |h| blk: { |
| 99 | const hd = try arena.dupe(u8, h); | 112 | const hd = try arena.dupe(u8, h); |
| 100 | const r = try handoff.recipeFor(arena, hd); | 113 | const r = try handoff.recipeFor(arena, hd); |
| @@ -266,13 +279,16 @@ pub const Hub = struct { | |||
| 266 | return tile.id; | 279 | return tile.id; |
| 267 | } | 280 | } |
| 268 | 281 | ||
| 269 | /// False for an id that is already gone — a second device removing | 282 | /// `UnknownId` for an id that is already gone — a second device |
| 270 | /// the same tile is a race, not an error. | 283 | /// removing the same tile is a race the caller may ignore (404), not a |
| 271 | pub fn removeTile(self: *Hub, id: u32) bool { | 284 | /// failure. `PersistFailed` says the tile IS gone from this hub and the |
| 285 | /// wall file disagrees, which the user must be told: silence there | ||
| 286 | /// means the tile reappears at the next start with no explanation. | ||
| 287 | pub fn removeTile(self: *Hub, id: u32) error{ UnknownId, PersistFailed }!void { | ||
| 272 | self.mutex.lock(); | 288 | self.mutex.lock(); |
| 273 | defer self.mutex.unlock(); | 289 | defer self.mutex.unlock(); |
| 274 | 290 | ||
| 275 | const idx = self.indexOf(id) orelse return false; | 291 | const idx = self.indexOf(id) orelse return error.UnknownId; |
| 276 | var tile = self.tiles.orderedRemove(idx); | 292 | var tile = self.tiles.orderedRemove(idx); |
| 277 | // Wake the pump before freeing anything it might still be reading | 293 | // Wake the pump before freeing anything it might still be reading |
| 278 | // for: shutdown unblocks its recv, and it unregisters the fd | 294 | // for: shutdown unblocks its recv, and it unregisters the fd |
| @@ -286,14 +302,10 @@ pub const Hub = struct { | |||
| 286 | tile.arena.deinit(); | 302 | tile.arena.deinit(); |
| 287 | // Same index, by construction: tiles is parallel to the wall. | 303 | // Same index, by construction: tiles is parallel to the wall. |
| 288 | self.wall_state.remove(self.alloc, idx); | 304 | self.wall_state.remove(self.alloc, idx); |
| 289 | // A bool cannot carry PersistFailed. Say it on stderr rather than | 305 | // No rollback, unlike addTile: the tile's arena is already freed and |
| 290 | // swallow it — the tile IS gone from this hub either way, and a | 306 | // its pump already woken, so there is nothing left to put back. The |
| 291 | // wall file that disagrees is exactly what the user needs told. | 307 | // removal stands and the caller is told the file did not take it. |
| 292 | self.persist() catch std.debug.print( | 308 | return self.persist(); |
| 293 | "muxweb: wall not saved after removing tile {d}\n", | ||
| 294 | .{id}, | ||
| 295 | ); | ||
| 296 | return true; | ||
| 297 | } | 309 | } |
| 298 | 310 | ||
| 299 | /// `ids` must name every live tile exactly once — a browser working | 311 | /// `ids` must name every live tile exactly once — a browser working |
| @@ -335,21 +347,24 @@ pub const Hub = struct { | |||
| 335 | } | 347 | } |
| 336 | 348 | ||
| 337 | /// The pump's copy of the target, in the pump's own arena, plus the | 349 | /// The pump's copy of the target, in the pump's own arena, plus the |
| 338 | /// fd registration that lets removeTile reach it. Null when the id is | 350 | /// fd registration that lets removeTile reach it. UnknownId when the |
| 339 | /// gone — a browser can always dial a tile another device just removed. | 351 | /// id is gone — a browser can always dial a tile another device just |
| 352 | /// removed — kept distinct from OutOfMemory so the HTTP layer can | ||
| 353 | /// answer 404 for one and 500 for the other instead of folding a | ||
| 354 | /// memory failure into "no such tile". | ||
| 340 | pub fn checkoutTarget( | 355 | pub fn checkoutTarget( |
| 341 | self: *Hub, | 356 | self: *Hub, |
| 342 | id: u32, | 357 | id: u32, |
| 343 | ws_fd: std.posix.fd_t, | 358 | ws_fd: std.posix.fd_t, |
| 344 | arena: std.mem.Allocator, | 359 | arena: std.mem.Allocator, |
| 345 | ) ?client.Target { | 360 | ) error{ UnknownId, OutOfMemory }!client.Target { |
| 346 | self.mutex.lock(); | 361 | self.mutex.lock(); |
| 347 | defer self.mutex.unlock(); | 362 | defer self.mutex.unlock(); |
| 348 | 363 | ||
| 349 | const idx = self.indexOf(id) orelse return null; | 364 | const idx = self.indexOf(id) orelse return error.UnknownId; |
| 350 | // Copy first: a failed copy must not leave an fd registered for a | 365 | // Copy first: a failed copy must not leave an fd registered for a |
| 351 | // pump that never starts. | 366 | // pump that never starts. |
| 352 | const copy = copyTarget(arena, self.tiles.items[idx].target) catch return null; | 367 | const copy = try copyTarget(arena, self.tiles.items[idx].target); |
| 353 | // FIRST registration wins: two browsers on one wall run two pumps | 368 | // FIRST registration wins: two browsers on one wall run two pumps |
| 354 | // per tile, and overwriting would leave removeTile able to wake | 369 | // per tile, and overwriting would leave removeTile able to wake |
| 355 | // only the last one — worse, the untracked pump's release would | 370 | // only the last one — worse, the untracked pump's release would |
| @@ -901,13 +916,26 @@ pub fn tilesJson( | |||
| 901 | return out.toOwnedSlice(alloc); | 916 | return out.toOwnedSlice(alloc); |
| 902 | } | 917 | } |
| 903 | 918 | ||
| 919 | /// `3,0,2` → ids. Empty, junk, or trailing garbage refuse: the body is | ||
| 920 | /// machine-written by our own page, so anything malformed is a bug | ||
| 921 | /// worth surfacing, not input to repair. | ||
| 922 | pub fn parseIdList(alloc: std.mem.Allocator, body: []const u8) error{ Bad, OutOfMemory }![]u32 { | ||
| 923 | var out: std.ArrayList(u32) = .empty; | ||
| 924 | errdefer out.deinit(alloc); | ||
| 925 | var it = std.mem.splitScalar(u8, std.mem.trim(u8, body, " \t\r\n"), ','); | ||
| 926 | while (it.next()) |part| { | ||
| 927 | const id = std.fmt.parseInt(u32, part, 10) catch return error.Bad; | ||
| 928 | try out.append(alloc, id); | ||
| 929 | } | ||
| 930 | if (out.items.len == 0) return error.Bad; | ||
| 931 | return out.toOwnedSlice(alloc); | ||
| 932 | } | ||
| 933 | |||
| 904 | pub fn serveConn( | 934 | pub fn serveConn( |
| 905 | alloc: std.mem.Allocator, | 935 | alloc: std.mem.Allocator, |
| 906 | stream: std.net.Stream, | 936 | stream: std.net.Stream, |
| 907 | port: u16, | 937 | port: u16, |
| 908 | targets: []const client.Target, | 938 | hub: *Hub, |
| 909 | labels: []const []const u8, | ||
| 910 | sessions: []const []const u8, | ||
| 911 | assets: Assets, | 939 | assets: Assets, |
| 912 | ) void { | 940 | ) void { |
| 913 | defer stream.close(); | 941 | defer stream.close(); |
| @@ -920,24 +948,23 @@ pub fn serveConn( | |||
| 920 | 948 | ||
| 921 | while (true) { | 949 | while (true) { |
| 922 | var req = server.receiveHead() catch return; | 950 | var req = server.receiveHead() catch return; |
| 951 | // `path` BORROWS the head buffer — it is a slice, not a copy — so | ||
| 952 | // every use of it below must happen before a body reader touches | ||
| 953 | // that buffer (readerExpectContinue → readerExpectNone reuses it). | ||
| 954 | // The mutating verbs read a body; they must read `path` first. | ||
| 923 | const path = req.head.target; | 955 | const path = req.head.target; |
| 956 | const method = req.head.method; | ||
| 924 | 957 | ||
| 925 | // The argv-driven flow still names tiles by index, and here id == | 958 | var origin: ?[]const u8 = null; |
| 926 | // index because nothing removes a tile; the range check that used | 959 | var it = req.iterateHeaders(); |
| 927 | // to live in the path parser lives here until the Hub owns this. | 960 | while (it.next()) |h| { |
| 928 | const wanted: ?usize = if (wsTileId(path)) |id| | 961 | if (std.ascii.eqlIgnoreCase(h.name, "origin")) origin = h.value; |
| 929 | (if (id < targets.len) @as(usize, id) else null) | 962 | } |
| 930 | else | 963 | |
| 931 | null; | 964 | if (wsTileId(path)) |id| { |
| 932 | if (wanted) |idx| { | ||
| 933 | // Origin BEFORE upgrade, always: the refusal must happen while | 965 | // Origin BEFORE upgrade, always: the refusal must happen while |
| 934 | // this is still HTTP, so a hostile page gets a 403 and never a | 966 | // this is still HTTP, so a hostile page gets a 403 and never a |
| 935 | // socket. std's upgradeRequested does not look at Origin. | 967 | // socket. std's upgradeRequested does not look at Origin. |
| 936 | var origin: ?[]const u8 = null; | ||
| 937 | var it = req.iterateHeaders(); | ||
| 938 | while (it.next()) |h| { | ||
| 939 | if (std.ascii.eqlIgnoreCase(h.name, "origin")) origin = h.value; | ||
| 940 | } | ||
| 941 | if (!originAllowed(origin, port)) { | 968 | if (!originAllowed(origin, port)) { |
| 942 | req.respond("forbidden\n", .{ .status = .forbidden }) catch {}; | 969 | req.respond("forbidden\n", .{ .status = .forbidden }) catch {}; |
| 943 | return; | 970 | return; |
| @@ -952,14 +979,44 @@ pub fn serveConn( | |||
| 952 | return; | 979 | return; |
| 953 | }, | 980 | }, |
| 954 | }; | 981 | }; |
| 982 | // The pump owns a COPY of the target: the tile (and its arena) | ||
| 983 | // may be removed mid-pump, and the shutdown() that kicks us out | ||
| 984 | // must never race a free of our own strings. | ||
| 985 | var pump_arena = std.heap.ArenaAllocator.init(alloc); | ||
| 986 | defer pump_arena.deinit(); | ||
| 987 | // Checkout BEFORE the upgrade, so an id that is not there is | ||
| 988 | // answered in HTTP — a 404 the page can read, rather than a 101 | ||
| 989 | // followed by a silent close it can only guess at. The fd is | ||
| 990 | // the same number either way: the upgrade rides this socket. | ||
| 991 | // One name for the fd we register with, so the release below | ||
| 992 | // provably names the same number it checked out under. | ||
| 993 | const ws_fd = stream.handle; | ||
| 994 | const target = hub.checkoutTarget(id, ws_fd, pump_arena.allocator()) catch |err| switch (err) { | ||
| 995 | // Removed between the page's GET and this dial: the browser | ||
| 996 | // refetches /tiles and stops asking for it. | ||
| 997 | error.UnknownId => { | ||
| 998 | req.respond("no such tile\n", .{ .status = .not_found }) catch {}; | ||
| 999 | return; | ||
| 1000 | }, | ||
| 1001 | error.OutOfMemory => { | ||
| 1002 | req.respond("out of memory\n", .{ .status = .internal_server_error }) catch {}; | ||
| 1003 | return; | ||
| 1004 | }, | ||
| 1005 | }; | ||
| 1006 | // Deferred, not called after pumpTile: an upgrade that fails | ||
| 1007 | // must unregister too. Registered AFTER `defer stream.close()`, | ||
| 1008 | // so it runs BEFORE it — the ordering that keeps a concurrent | ||
| 1009 | // removeTile from shutting down an fd the kernel has already | ||
| 1010 | // handed to somebody else. | ||
| 1011 | defer hub.releaseTile(id, ws_fd); | ||
| 955 | var ws = req.respondWebSocket(.{ .key = key }) catch return; | 1012 | var ws = req.respondWebSocket(.{ .key = key }) catch return; |
| 956 | ws.flush() catch return; | 1013 | ws.flush() catch return; |
| 957 | pumpTile(alloc, &ws, stream.handle, targets[idx]); | 1014 | pumpTile(alloc, &ws, ws_fd, target); |
| 958 | return; | 1015 | return; |
| 959 | } | 1016 | } |
| 960 | 1017 | ||
| 961 | if (std.mem.eql(u8, path, "/tiles")) { | 1018 | if (std.mem.eql(u8, path, "/tiles") and method == .GET) { |
| 962 | const json = tilesJson(alloc, labels, sessions) catch return; | 1019 | const json = hub.json(alloc) catch return; |
| 963 | defer alloc.free(json); | 1020 | defer alloc.free(json); |
| 964 | req.respond(json, .{ | 1021 | req.respond(json, .{ |
| 965 | .extra_headers = &.{ | 1022 | .extra_headers = &.{ |
| @@ -970,6 +1027,122 @@ pub fn serveConn( | |||
| 970 | continue; | 1027 | continue; |
| 971 | } | 1028 | } |
| 972 | 1029 | ||
| 1030 | // Mutations: Origin-gated, always — a text/plain POST is a CSRF | ||
| 1031 | // "simple request" any web page can fire at localhost without a | ||
| 1032 | // preflight; the gate is what keeps this page's power this page's. | ||
| 1033 | // GET above stays ungated: we send no CORS headers, so a hostile | ||
| 1034 | // page can make the request but never read the answer. | ||
| 1035 | // | ||
| 1036 | // Exact match only: a prefix match would route `/tilesgarbage` | ||
| 1037 | // into a mutation and `/tiles/` or `/tiles?x=1` into a confusing | ||
| 1038 | // 403/405 here instead of the asset router's plain 404. The API | ||
| 1039 | // defines exactly "/tiles" (POST/PUT) and "/tiles/<id>" (DELETE); | ||
| 1040 | // reject everything else by not matching it at all. | ||
| 1041 | const tiles_root = std.mem.eql(u8, path, "/tiles"); | ||
| 1042 | const tile_id_suffix = if (std.mem.startsWith(u8, path, "/tiles/") and path.len > "/tiles/".len) | ||
| 1043 | path["/tiles/".len..] | ||
| 1044 | else | ||
| 1045 | null; | ||
| 1046 | if (tiles_root or tile_id_suffix != null) { | ||
| 1047 | if (!originAllowed(origin, port)) { | ||
| 1048 | req.respond("forbidden\n", .{ .status = .forbidden }) catch {}; | ||
| 1049 | return; | ||
| 1050 | } | ||
| 1051 | switch (method) { | ||
| 1052 | .POST => { // body = one spelling | ||
| 1053 | if (!tiles_root) { | ||
| 1054 | req.respond("not found\n", .{ .status = .not_found }) catch return; | ||
| 1055 | continue; | ||
| 1056 | } | ||
| 1057 | var body_buf: [512]u8 = undefined; | ||
| 1058 | const rdr = req.readerExpectContinue(&body_buf) catch return; | ||
| 1059 | const body_raw = rdr.allocRemaining(alloc, .limited(4096)) catch return; | ||
| 1060 | defer alloc.free(body_raw); | ||
| 1061 | const spelling = std.mem.trim(u8, body_raw, " \t\r\n"); | ||
| 1062 | const id = hub.addTile(spelling) catch |err| { | ||
| 1063 | const status: std.http.Status, const msg: []const u8 = switch (err) { | ||
| 1064 | error.BadSession => .{ .bad_request, "bad session name after '#'\n" }, | ||
| 1065 | error.EmptySpec => .{ .bad_request, "empty target\n" }, | ||
| 1066 | error.BadByte => .{ .bad_request, "control byte in target\n" }, | ||
| 1067 | error.MissingKey => .{ .bad_request, "no key for quic:// target (muxd keygen, or MUX_KEY_FILE)\n" }, | ||
| 1068 | error.SockPathTooLong => .{ .bad_request, "socket path too long\n" }, | ||
| 1069 | // The tile is NOT live: addTile rolls back on a | ||
| 1070 | // failed save, so 500 is the whole truth here. | ||
| 1071 | error.PersistFailed => .{ .internal_server_error, "wall not saved\n" }, | ||
| 1072 | error.OutOfMemory => return, | ||
| 1073 | }; | ||
| 1074 | req.respond(msg, .{ .status = status }) catch {}; | ||
| 1075 | continue; | ||
| 1076 | }; | ||
| 1077 | var buf: [32]u8 = undefined; | ||
| 1078 | const resp = std.fmt.bufPrint(&buf, "{{\"id\":{d}}}", .{id}) catch unreachable; | ||
| 1079 | req.respond(resp, .{ .extra_headers = &.{ | ||
| 1080 | .{ .name = "content-type", .value = "application/json" }, | ||
| 1081 | } }) catch return; | ||
| 1082 | }, | ||
| 1083 | .DELETE => { // path = /tiles/<id> | ||
| 1084 | const id_str = tile_id_suffix orelse { | ||
| 1085 | req.respond("not found\n", .{ .status = .not_found }) catch return; | ||
| 1086 | continue; | ||
| 1087 | }; | ||
| 1088 | const id = std.fmt.parseInt(u32, id_str, 10) catch { | ||
| 1089 | req.respond("not found\n", .{ .status = .not_found }) catch return; | ||
| 1090 | continue; | ||
| 1091 | }; | ||
| 1092 | hub.removeTile(id) catch |err| { | ||
| 1093 | const status: std.http.Status, const msg: []const u8 = switch (err) { | ||
| 1094 | error.UnknownId => .{ .not_found, "not found\n" }, | ||
| 1095 | // The tile IS gone from the running hub; only the | ||
| 1096 | // file disagrees. 500 so the user learns the wall | ||
| 1097 | // will come back on the next start. | ||
| 1098 | error.PersistFailed => .{ .internal_server_error, "wall not saved\n" }, | ||
| 1099 | }; | ||
| 1100 | req.respond(msg, .{ .status = status }) catch return; | ||
| 1101 | continue; | ||
| 1102 | }; | ||
| 1103 | req.respond("", .{ .status = .no_content }) catch return; | ||
| 1104 | }, | ||
| 1105 | .PUT => { // body = CSV of ids, the FULL new order | ||
| 1106 | if (!tiles_root) { | ||
| 1107 | req.respond("not found\n", .{ .status = .not_found }) catch return; | ||
| 1108 | continue; | ||
| 1109 | } | ||
| 1110 | var body_buf: [512]u8 = undefined; | ||
| 1111 | const rdr = req.readerExpectContinue(&body_buf) catch return; | ||
| 1112 | const body_raw = rdr.allocRemaining(alloc, .limited(4096)) catch return; | ||
| 1113 | defer alloc.free(body_raw); | ||
| 1114 | const ids = parseIdList(alloc, body_raw) catch { | ||
| 1115 | req.respond("bad id list\n", .{ .status = .bad_request }) catch return; | ||
| 1116 | continue; | ||
| 1117 | }; | ||
| 1118 | defer alloc.free(ids); | ||
| 1119 | hub.reorderTiles(ids) catch |err| switch (err) { | ||
| 1120 | error.Stale => { | ||
| 1121 | // A stale view: hand back the truth so the page | ||
| 1122 | // can reconcile and retry. | ||
| 1123 | const json = hub.json(alloc) catch return; | ||
| 1124 | defer alloc.free(json); | ||
| 1125 | req.respond(json, .{ .status = .conflict, .extra_headers = &.{ | ||
| 1126 | .{ .name = "content-type", .value = "application/json" }, | ||
| 1127 | } }) catch return; | ||
| 1128 | continue; | ||
| 1129 | }, | ||
| 1130 | // The new order IS live (reorderTiles does not roll | ||
| 1131 | // back) — only the file lags, which the next | ||
| 1132 | // successful mutation rewrites whole. | ||
| 1133 | error.PersistFailed => { | ||
| 1134 | req.respond("wall not saved\n", .{ .status = .internal_server_error }) catch return; | ||
| 1135 | continue; | ||
| 1136 | }, | ||
| 1137 | error.OutOfMemory => return, | ||
| 1138 | }; | ||
| 1139 | req.respond("", .{ .status = .no_content }) catch return; | ||
| 1140 | }, | ||
| 1141 | else => req.respond("bad method\n", .{ .status = .method_not_allowed }) catch return, | ||
| 1142 | } | ||
| 1143 | continue; | ||
| 1144 | } | ||
| 1145 | |||
| 973 | if (route(assets, path)) |asset| { | 1146 | if (route(assets, path)) |asset| { |
| 974 | req.respond(asset.body, .{ | 1147 | req.respond(asset.body, .{ |
| 975 | .extra_headers = &.{ | 1148 | .extra_headers = &.{ |
| @@ -1038,8 +1211,8 @@ test "hub: ids are stable across remove and reorder; json is wall order" { | |||
| 1038 | 1211 | ||
| 1039 | const id2 = try hub.addTile("--sock /tmp/c"); | 1212 | const id2 = try hub.addTile("--sock /tmp/c"); |
| 1040 | try std.testing.expectEqual(@as(u32, 2), id2); | 1213 | try std.testing.expectEqual(@as(u32, 2), id2); |
| 1041 | try std.testing.expect(hub.removeTile(1)); | 1214 | try hub.removeTile(1); |
| 1042 | try std.testing.expect(!hub.removeTile(1)); // already gone | 1215 | try std.testing.expectError(error.UnknownId, hub.removeTile(1)); // already gone |
| 1043 | try hub.reorderTiles(&.{ 2, 0 }); | 1216 | try hub.reorderTiles(&.{ 2, 0 }); |
| 1044 | try std.testing.expectError(error.Stale, hub.reorderTiles(&.{ 0, 1 })); // 1 is gone: stale view | 1217 | try std.testing.expectError(error.Stale, hub.reorderTiles(&.{ 0, 1 })); // 1 is gone: stale view |
| 1045 | 1218 | ||
| @@ -1059,13 +1232,13 @@ test "hub: checkout copies the target into the caller's arena; release unregiste | |||
| 1059 | 1232 | ||
| 1060 | var arena = std.heap.ArenaAllocator.init(alloc); | 1233 | var arena = std.heap.ArenaAllocator.init(alloc); |
| 1061 | defer arena.deinit(); | 1234 | defer arena.deinit(); |
| 1062 | const t = hub.checkoutTarget(0, 7, arena.allocator()).?; | 1235 | const t = try hub.checkoutTarget(0, 7, arena.allocator()); |
| 1063 | // The copy must survive the tile's death: remove frees the tile's own | 1236 | // The copy must survive the tile's death: remove frees the tile's own |
| 1064 | // arena, and the pump's strings must not be in it. | 1237 | // arena, and the pump's strings must not be in it. |
| 1065 | try std.testing.expect(hub.removeTile(0)); | 1238 | try hub.removeTile(0); |
| 1066 | try std.testing.expectEqualStrings("/tmp/a", t.sock); | 1239 | try std.testing.expectEqualStrings("/tmp/a", t.sock); |
| 1067 | hub.releaseTile(0, 7); // gone id: a no-op, not a crash | 1240 | hub.releaseTile(0, 7); // gone id: a no-op, not a crash |
| 1068 | try std.testing.expectEqual(@as(?client.Target, null), hub.checkoutTarget(0, 7, arena.allocator())); | 1241 | try std.testing.expectError(error.UnknownId, hub.checkoutTarget(0, 7, arena.allocator())); |
| 1069 | } | 1242 | } |
| 1070 | 1243 | ||
| 1071 | test "hub: two pumps on one tile — the first fd stays tracked, the second's release spares it" { | 1244 | test "hub: two pumps on one tile — the first fd stays tracked, the second's release spares it" { |
| @@ -1079,8 +1252,8 @@ test "hub: two pumps on one tile — the first fd stays tracked, the second's re | |||
| 1079 | defer arena.deinit(); | 1252 | defer arena.deinit(); |
| 1080 | 1253 | ||
| 1081 | // Two browsers on one wall: both pumps get a target and both serve. | 1254 | // Two browsers on one wall: both pumps get a target and both serve. |
| 1082 | const first = hub.checkoutTarget(0, 7, arena.allocator()).?; | 1255 | const first = try hub.checkoutTarget(0, 7, arena.allocator()); |
| 1083 | const second = hub.checkoutTarget(0, 8, arena.allocator()).?; | 1256 | const second = try hub.checkoutTarget(0, 8, arena.allocator()); |
| 1084 | try std.testing.expectEqualStrings("/tmp/a", first.sock); | 1257 | try std.testing.expectEqualStrings("/tmp/a", first.sock); |
| 1085 | try std.testing.expectEqualStrings("/tmp/a", second.sock); | 1258 | try std.testing.expectEqualStrings("/tmp/a", second.sock); |
| 1086 | 1259 | ||
| @@ -1109,7 +1282,7 @@ test "hub: addTile persists; a reloaded wall matches" { | |||
| 1109 | _ = try hub.addTile("--sock /tmp/a"); | 1282 | _ = try hub.addTile("--sock /tmp/a"); |
| 1110 | _ = try hub.addTile("--sock /tmp/b"); | 1283 | _ = try hub.addTile("--sock /tmp/b"); |
| 1111 | _ = try hub.addTile("--sock /tmp/c"); | 1284 | _ = try hub.addTile("--sock /tmp/c"); |
| 1112 | try std.testing.expect(hub.removeTile(1)); | 1285 | try hub.removeTile(1); |
| 1113 | try hub.reorderTiles(&.{ 2, 0 }); | 1286 | try hub.reorderTiles(&.{ 2, 0 }); |
| 1114 | } | 1287 | } |
| 1115 | var r = try wall.load(alloc, path); | 1288 | var r = try wall.load(alloc, path); |
| @@ -1323,6 +1496,17 @@ test "drain browser: the reader's OWN bytes decide, with or without a readable e | |||
| 1323 | } | 1496 | } |
| 1324 | } | 1497 | } |
| 1325 | 1498 | ||
| 1499 | test "parseIdList: happy path and refusals" { | ||
| 1500 | const alloc = std.testing.allocator; | ||
| 1501 | const ids = try parseIdList(alloc, "3,0,2\n"); | ||
| 1502 | defer alloc.free(ids); | ||
| 1503 | try std.testing.expectEqualSlices(u32, &.{ 3, 0, 2 }, ids); | ||
| 1504 | try std.testing.expectError(error.Bad, parseIdList(alloc, "")); | ||
| 1505 | try std.testing.expectError(error.Bad, parseIdList(alloc, "1,,2")); | ||
| 1506 | try std.testing.expectError(error.Bad, parseIdList(alloc, "1,x")); | ||
| 1507 | try std.testing.expectError(error.Bad, parseIdList(alloc, "-1")); | ||
| 1508 | } | ||
| 1509 | |||
| 1326 | test "tiles json: label/session objects, order preserved" { | 1510 | test "tiles json: label/session objects, order preserved" { |
| 1327 | const alloc = std.testing.allocator; | 1511 | const alloc = std.testing.allocator; |
| 1328 | { | 1512 | { |
src/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,25 +1,31 @@ | |||
| 1 | //! muxweb — the hub binary (M-web Task 7). `muxweb TARGET [TARGET ...] | 1 | //! muxweb — the hub binary (M-web Task 7). `muxweb [TARGET ...] |
| 2 | //! [--port N]`: serves the wall page on 127.0.0.1 and pumps one | 2 | //! [--port N]`: serves the wall page on 127.0.0.1 and pumps one |
| 3 | //! WebSocket per tile, dialing each TARGET the way the mux CLI does. | 3 | //! WebSocket per tile, dialing each TARGET the way the mux CLI does. |
| 4 | //! TARGET spellings are mux's own: bare HOST (ssh→QUIC handoff), | 4 | //! TARGET spellings are mux's own: bare HOST (ssh→QUIC handoff), |
| 5 | //! --sock PATH, quic://HOST[:PORT] (with --key / MUX_KEY_FILE as in | 5 | //! --sock PATH, quic://HOST[:PORT] (with --key / MUX_KEY_FILE as in |
| 6 | //! mux). A `#NAME` suffix on a TARGET names the daemon session that tile | 6 | //! mux). A `#NAME` suffix on a TARGET names the daemon session that tile |
| 7 | //! attaches to, which is how one host becomes two tiles. The TARGET string | 7 | //! attaches to, which is how one host becomes two tiles. The TARGET string |
| 8 | //! is the tile's label, suffix and all; the tile list is argv — no config | 8 | //! is the tile's label, suffix and all. |
| 9 | //! file, per the standing non-goal. | 9 | //! |
| 10 | //! The wall is now a persisted list the page edits at runtime, so argv is | ||
| 11 | //! an OVERRIDE, not the only source: with targets, argv becomes the wall | ||
| 12 | //! and is saved; without, the last run's wall is restored. The standing | ||
| 13 | //! non-goal (no config file) survives — the state file is written by the | ||
| 14 | //! program, never by hand. | ||
| 10 | 15 | ||
| 11 | const std = @import("std"); | 16 | const std = @import("std"); |
| 12 | const client = @import("client"); | 17 | const client = @import("client"); |
| 13 | const webhub = @import("webhub"); | 18 | const webhub = @import("webhub"); |
| 14 | const proto = @import("protocol"); | 19 | const wall = @import("wall"); |
| 15 | const build_options = @import("build_options"); | 20 | const build_options = @import("build_options"); |
| 16 | const xdg = @import("xdg"); | 21 | const xdg = @import("xdg"); |
| 17 | const handoff = @import("handoff"); | ||
| 18 | const sockpath = @import("sockpath"); | 22 | const sockpath = @import("sockpath"); |
| 19 | 23 | ||
| 20 | const usage = | 24 | const usage = |
| 21 | \\usage: muxweb TARGET[#SESSION] [TARGET ...] [--port N] | 25 | \\usage: muxweb [TARGET[#SESSION] ...] [--port N] |
| 22 | \\ each TARGET is a tile: HOST | --sock PATH | quic://HOST[:PORT] | 26 | \\ each TARGET is a tile: HOST | --sock PATH | quic://HOST[:PORT] |
| 27 | \\ with no TARGET the wall from the last run is restored; with TARGETs | ||
| 28 | \\ argv replaces it and becomes the saved wall | ||
| 23 | \\ #SESSION names the daemon session the tile attaches to (default: the | 29 | \\ #SESSION names the daemon session the tile attaches to (default: the |
| 24 | \\ default session) — the same host twice, two sessions, two tiles | 30 | \\ default session) — the same host twice, two sessions, two tiles |
| 25 | \\ quic:// tiles use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key | 31 | \\ quic:// tiles use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key |
| @@ -30,61 +36,48 @@ const usage = | |||
| 30 | \\ | 36 | \\ |
| 31 | ; | 37 | ; |
| 32 | 38 | ||
| 33 | /// One tile as the command line spelled it. Building the full | 39 | /// Validate one wall spelling and take an owned copy of it. |
| 34 | /// client.Target needs an allocator and the environment, so the parse | ||
| 35 | /// records spellings and main resolves them — the same split mux_main | ||
| 36 | /// uses, for the same testability reason. | ||
| 37 | const TileSpec = struct { | ||
| 38 | /// The transport spelling with any `#NAME` suffix already taken off: | ||
| 39 | /// what the dial is built from. | ||
| 40 | spec: Spec, | ||
| 41 | /// The argv string verbatim, `#NAME` and all. The user asked for | ||
| 42 | /// `host#b`, so that is the tile's name on screen — the wall says which | ||
| 43 | /// session each tile is showing without anyone decorating it, and the | ||
| 44 | /// quic tile no longer needs its label rebuilt from the pieces. | ||
| 45 | label: []const u8, | ||
| 46 | /// The session to attach to, "" for the default — the empty-name-means- | ||
| 47 | /// default rule the whole protocol already runs on. Only the browser | ||
| 48 | /// ever sends it: the hub is a byte pump and never attaches. | ||
| 49 | session: []const u8 = "", | ||
| 50 | |||
| 51 | const Spec = union(enum) { | ||
| 52 | sock: []const u8, | ||
| 53 | host: []const u8, | ||
| 54 | quic: []const u8, | ||
| 55 | }; | ||
| 56 | }; | ||
| 57 | |||
| 58 | /// Split `TARGET#NAME`. At the LAST '#', because a session name can never | ||
| 59 | /// hold one (protocol.validSessionName refuses it precisely so this split | ||
| 60 | /// stays decidable) — so any earlier '#' belongs to the target's own | ||
| 61 | /// spelling, a path or a hostname that happens to contain one. | ||
| 62 | /// | 40 | /// |
| 63 | /// A bad name is refused HERE, at usage-error altitude, rather than | 41 | /// A bad spelling is refused HERE, at usage-error altitude, rather than |
| 64 | /// downstream where it would arrive as a rejected attach in one tile with | 42 | /// downstream where it would arrive as a rejected attach in one tile with |
| 65 | /// nothing on the hub's console to explain it. The message names the tile: | 43 | /// nothing on the hub's console to explain it. The message names the tile: |
| 66 | /// with several targets on the line, `usage` alone would not say which. | 44 | /// with several targets on the line, `usage` alone would not say which. |
| 67 | fn splitSession(arg: []const u8) ParseError!struct { spec: []const u8, session: []const u8 } { | 45 | /// |
| 68 | const hash = std.mem.lastIndexOfScalar(u8, arg, '#') orelse | 46 | /// wall.parseSpelling is the ONE grammar — the same one the state file and |
| 69 | return .{ .spec = arg, .session = "" }; | 47 | /// the hub's POST /tiles are read with, so what argv accepts is exactly |
| 70 | const name = arg[hash + 1 ..]; | 48 | /// what the page can add. |
| 71 | if (!proto.validSessionName(name)) { | 49 | fn addSpelling( |
| 72 | std.debug.print( | 50 | alloc: std.mem.Allocator, |
| 73 | "muxweb: tile {s}: bad session name after '#' (printable ASCII, no space, no '/', 1-{d} bytes)\n", | 51 | list: *std.ArrayList([]const u8), |
| 74 | .{ arg, proto.session_name_max }, | 52 | spelling: []const u8, |
| 75 | ); | 53 | ) ParseError!void { |
| 54 | _ = wall.parseSpelling(spelling) catch |err| { | ||
| 55 | std.debug.print("muxweb: tile {s}: {s}\n", .{ spelling, switch (err) { | ||
| 56 | error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", | ||
| 57 | error.EmptySpec => "empty target", | ||
| 58 | error.BadByte => "control byte in target", | ||
| 59 | } }); | ||
| 76 | return error.Usage; | 60 | return error.Usage; |
| 77 | } | 61 | }; |
| 78 | return .{ .spec = arg[0..hash], .session = name }; | 62 | const copy = try alloc.dupe(u8, spelling); |
| 63 | errdefer alloc.free(copy); | ||
| 64 | try list.append(alloc, copy); | ||
| 79 | } | 65 | } |
| 80 | 66 | ||
| 81 | const Parsed = struct { | 67 | const Parsed = struct { |
| 82 | tiles: std.ArrayList(TileSpec), | 68 | /// One wall spelling per tile, in argv order — the same string that |
| 69 | /// reaches the state file, the resolver and the page's label. Owned | ||
| 70 | /// uniformly rather than half-borrowed from argv, because `--sock PATH` | ||
| 71 | /// has to synthesize its `--sock ` prefix and one ownership rule beats | ||
| 72 | /// two. That prefix is now part of a sock tile's label: the label IS | ||
| 73 | /// the spelling. | ||
| 74 | tiles: std.ArrayList([]const u8), | ||
| 83 | port: u16 = webhub.default_port, | 75 | port: u16 = webhub.default_port, |
| 84 | key: ?[]const u8 = null, | 76 | key: ?[]const u8 = null, |
| 85 | idle_ms: u32 = client.quic_idle_ms_default, | 77 | idle_ms: u32 = client.quic_idle_ms_default, |
| 86 | 78 | ||
| 87 | fn deinit(self: *Parsed, alloc: std.mem.Allocator) void { | 79 | fn deinit(self: *Parsed, alloc: std.mem.Allocator) void { |
| 80 | for (self.tiles.items) |t| alloc.free(t); | ||
| 88 | self.tiles.deinit(alloc); | 81 | self.tiles.deinit(alloc); |
| 89 | } | 82 | } |
| 90 | }; | 83 | }; |
| @@ -107,7 +100,7 @@ fn parseArgs( | |||
| 107 | env_key: ?[]const u8, | 100 | env_key: ?[]const u8, |
| 108 | ) ParseError!ParseResult { | 101 | ) ParseError!ParseResult { |
| 109 | var p = Parsed{ .tiles = .empty }; | 102 | var p = Parsed{ .tiles = .empty }; |
| 110 | errdefer p.tiles.deinit(alloc); | 103 | errdefer p.deinit(alloc); |
| 111 | var key: ?[]const u8 = null; | 104 | var key: ?[]const u8 = null; |
| 112 | 105 | ||
| 113 | var i: usize = 1; | 106 | var i: usize = 1; |
| @@ -117,23 +110,16 @@ fn parseArgs( | |||
| 117 | // The one non-error early return, so the one that still frees | 110 | // The one non-error early return, so the one that still frees |
| 118 | // for itself: errdefer does not run on the way out with a | 111 | // for itself: errdefer does not run on the way out with a |
| 119 | // result in hand. | 112 | // result in hand. |
| 120 | p.tiles.deinit(alloc); | 113 | p.deinit(alloc); |
| 121 | return .version; | 114 | return .version; |
| 122 | } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { | 115 | } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { |
| 123 | i += 1; | 116 | i += 1; |
| 124 | // The `#NAME` split is on the TARGET, and --sock's target is its | 117 | // The flag and its value become ONE spelling — `--sock ` is |
| 125 | // VALUE, not the flag. | 118 | // part of the grammar wall.zig reads, not a shape only argv |
| 126 | const s = try splitSession(args[i]); | 119 | // has. Two spellings of the same tile would be two parsers. |
| 127 | // Same refusal the bare-host and quic:// arms make: `--sock '#b'` | 120 | const s = try std.fmt.allocPrint(alloc, "--sock {s}", .{args[i]}); |
| 128 | // splits to an empty path, and an empty path is a usage mistake, | 121 | defer alloc.free(s); |
| 129 | // not something to carry to a connect that fails later and | 122 | try addSpelling(alloc, &p.tiles, s); |
| 130 | // further from the typo. | ||
| 131 | if (s.spec.len == 0) return error.Usage; | ||
| 132 | try p.tiles.append(alloc, .{ | ||
| 133 | .spec = .{ .sock = s.spec }, | ||
| 134 | .label = args[i], | ||
| 135 | .session = s.session, | ||
| 136 | }); | ||
| 137 | } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) { | 123 | } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) { |
| 138 | i += 1; | 124 | i += 1; |
| 139 | p.port = std.fmt.parseInt(u16, args[i], 10) catch return error.Usage; | 125 | p.port = std.fmt.parseInt(u16, args[i], 10) catch return error.Usage; |
| @@ -150,29 +136,17 @@ fn parseArgs( | |||
| 150 | const n = std.fmt.parseInt(u32, args[i], 10) catch return error.Usage; | 136 | const n = std.fmt.parseInt(u32, args[i], 10) catch return error.Usage; |
| 151 | if (n == 0) return error.Usage; | 137 | if (n == 0) return error.Usage; |
| 152 | p.idle_ms = n; | 138 | p.idle_ms = n; |
| 153 | } else if (std.mem.startsWith(u8, a, "quic://")) { | 139 | } else if (std.mem.startsWith(u8, a, "quic://") or (a.len > 0 and a[0] != '-')) { |
| 154 | const s = try splitSession(a); | 140 | // Bare HOST and quic:// are already wall spellings verbatim. |
| 155 | const hp = s.spec["quic://".len..]; | 141 | try addSpelling(alloc, &p.tiles, a); |
| 156 | if (hp.len == 0) return error.Usage; | ||
| 157 | try p.tiles.append(alloc, .{ | ||
| 158 | .spec = .{ .quic = hp }, | ||
| 159 | .label = a, | ||
| 160 | .session = s.session, | ||
| 161 | }); | ||
| 162 | } else if (a.len > 0 and a[0] != '-') { | ||
| 163 | const s = try splitSession(a); | ||
| 164 | if (s.spec.len == 0) return error.Usage; | ||
| 165 | try p.tiles.append(alloc, .{ | ||
| 166 | .spec = .{ .host = s.spec }, | ||
| 167 | .label = a, | ||
| 168 | .session = s.session, | ||
| 169 | }); | ||
| 170 | } else { | 142 | } else { |
| 171 | return error.Usage; | 143 | return error.Usage; |
| 172 | } | 144 | } |
| 173 | } | 145 | } |
| 174 | 146 | ||
| 175 | if (p.tiles.items.len == 0) return error.Usage; | 147 | // No targets is not a usage error any more: it asks for the wall the |
| 148 | // last run persisted. main decides what an empty argv means; the parse | ||
| 149 | // only reports what was on the line. | ||
| 176 | p.key = xdg.pickKey(key, env_key); | 150 | p.key = xdg.pickKey(key, env_key); |
| 177 | return .{ .serve = p }; | 151 | return .{ .serve = p }; |
| 178 | } | 152 | } |
| @@ -204,71 +178,63 @@ pub fn main() !u8 { | |||
| 204 | }; | 178 | }; |
| 205 | defer parsed.deinit(alloc); | 179 | defer parsed.deinit(alloc); |
| 206 | 180 | ||
| 207 | // Resolve spellings into dialable Targets through the SAME owners | ||
| 208 | // mux_main uses — handoff.recipeFor and xdg.resolveKeyPath — so the | ||
| 209 | // two binaries cannot drift on what a bare HOST or a `quic://` means. | ||
| 210 | // Labels are the argv spellings verbatim. | ||
| 211 | // | ||
| 212 | // An arena, because every string built here lives exactly as long as | 181 | // An arena, because every string built here lives exactly as long as |
| 213 | // the hub does: the Targets are handed to every tile thread and the | 182 | // the hub does — the state path, the wall it starts from, and the |
| 214 | // labels to every /tiles response, so nothing is ever freed early and | 183 | // Hub's own allocations — so nothing is ever freed early and the |
| 215 | // the hand-rolled list of pointers-to-free was a lifetime nobody | 184 | // hand-rolled list of pointers-to-free was a lifetime nobody needed to |
| 216 | // needed to track. Which resolutions borrow from argv and which | 185 | // track. The process exits from inside the accept loop, so "as long as |
| 217 | // allocate stops mattering: both end at the same deinit. | 186 | // the hub" is "until exit". |
| 218 | var arena_state = std.heap.ArenaAllocator.init(alloc); | 187 | var arena_state = std.heap.ArenaAllocator.init(alloc); |
| 219 | defer arena_state.deinit(); | 188 | defer arena_state.deinit(); |
| 220 | const arena = arena_state.allocator(); | 189 | const arena = arena_state.allocator(); |
| 221 | 190 | ||
| 222 | var targets: std.ArrayList(client.Target) = .empty; | 191 | const state_path = try wall.statePath(arena); |
| 223 | var labels: std.ArrayList([]const u8) = .empty; | 192 | var w: wall.Wall = undefined; |
| 224 | // Parallel to labels and targets, index for index: /tiles hands the | 193 | if (parsed.tiles.items.len == 0) { |
| 225 | // browser both, and the browser is the one that attaches. | 194 | // No argv: the wall is whatever the last run persisted. |
| 226 | var sessions: std.ArrayList([]const u8) = .empty; | 195 | w = wall.load(arena, state_path) catch |err| { |
| 227 | 196 | // The file may have been hand-edited into a line that no longer | |
| 228 | for (parsed.tiles.items) |tile| { | 197 | // parses. Naming it beats a stack trace: the fix is in the file. |
| 229 | // The label is the argv spelling verbatim, `#NAME` included, for | 198 | std.debug.print("muxweb: cannot read wall {s}: {s}\n", .{ state_path, @errorName(err) }); |
| 230 | // every spelling — including quic://, which used to rebuild its own. | 199 | return 2; |
| 231 | try labels.append(arena, tile.label); | 200 | }; |
| 232 | try sessions.append(arena, tile.session); | 201 | } else { |
| 233 | switch (tile.spec) { | 202 | // Argv present: the explicit override. It becomes the persisted wall. |
| 234 | .sock => |path| { | 203 | w = wall.Wall{}; |
| 235 | if (path.len > sockpath.max_sun_path) { | 204 | for (parsed.tiles.items) |s| _ = try w.add(arena, s); |
| 236 | std.debug.print("muxweb: socket path too long ({d} bytes, max {d}): {s}\n", .{ | ||
| 237 | path.len, sockpath.max_sun_path, path, | ||
| 238 | }); | ||
| 239 | return 2; | ||
| 240 | } | ||
| 241 | try targets.append(arena, .{ .sock = path }); | ||
| 242 | }, | ||
| 243 | .host => |h| { | ||
| 244 | const r = try handoff.recipeFor(arena, h); | ||
| 245 | try targets.append(arena, .{ .hand = .{ | ||
| 246 | .host = h, | ||
| 247 | .ssh_cmd = r.ssh_cmd, | ||
| 248 | .cache_path = r.cache_path, | ||
| 249 | .idle_ms = parsed.idle_ms, | ||
| 250 | } }); | ||
| 251 | }, | ||
| 252 | .quic => |hp| { | ||
| 253 | const key_path = switch (try xdg.resolveKeyPath(arena, parsed.key)) { | ||
| 254 | .given, .default => |p| p, | ||
| 255 | .missing => |p| { | ||
| 256 | std.debug.print( | ||
| 257 | "muxweb: no key for quic://{s}: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n", | ||
| 258 | .{ hp, p }, | ||
| 259 | ); | ||
| 260 | return 2; | ||
| 261 | }, | ||
| 262 | }; | ||
| 263 | try targets.append(arena, .{ .quic = .{ | ||
| 264 | .host_port = hp, | ||
| 265 | .key_path = key_path, | ||
| 266 | .idle_ms = parsed.idle_ms, | ||
| 267 | } }); | ||
| 268 | }, | ||
| 269 | } | ||
| 270 | } | 205 | } |
| 271 | 206 | ||
| 207 | // The Hub resolves every spelling into a dialable Target through the | ||
| 208 | // SAME owners mux_main uses — handoff.recipeFor and xdg.resolveKeyPath — | ||
| 209 | // so the two binaries cannot drift on what a bare HOST or a `quic://` | ||
| 210 | // means, and a tile POSTed by the page means what one typed on the | ||
| 211 | // command line. | ||
| 212 | var hub = webhub.Hub.init(arena, w, state_path, parsed.key, parsed.idle_ms) catch |err| switch (err) { | ||
| 213 | error.MissingKey => { | ||
| 214 | std.debug.print( | ||
| 215 | "muxweb: no key for a quic:// tile: pass --key, set MUX_KEY_FILE, or run `muxd keygen`\n", | ||
| 216 | .{}, | ||
| 217 | ); | ||
| 218 | return 2; | ||
| 219 | }, | ||
| 220 | error.SockPathTooLong => { | ||
| 221 | std.debug.print( | ||
| 222 | "muxweb: socket path too long (max {d} bytes)\n", | ||
| 223 | .{sockpath.max_sun_path}, | ||
| 224 | ); | ||
| 225 | return 2; | ||
| 226 | }, | ||
| 227 | else => return err, | ||
| 228 | }; | ||
| 229 | defer hub.deinit(); | ||
| 230 | // Argv is the override, so it is also what the next run restores. Saved | ||
| 231 | // only when there WAS argv: a restore that rewrites what it just read | ||
| 232 | // would turn a read failure into a lost wall. | ||
| 233 | if (parsed.tiles.items.len != 0) wall.save(&hub.wall_state, state_path) catch |err| { | ||
| 234 | std.debug.print("muxweb: cannot save wall {s}: {s}\n", .{ state_path, @errorName(err) }); | ||
| 235 | return 2; | ||
| 236 | }; | ||
| 237 | |||
| 272 | const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable; | 238 | const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable; |
| 273 | var listener = addr.listen(.{ .reuse_address = true }) catch |err| { | 239 | var listener = addr.listen(.{ .reuse_address = true }) catch |err| { |
| 274 | std.debug.print("muxweb: cannot bind 127.0.0.1:{d}: {s}\n", .{ parsed.port, @errorName(err) }); | 240 | std.debug.print("muxweb: cannot bind 127.0.0.1:{d}: {s}\n", .{ parsed.port, @errorName(err) }); |
| @@ -277,9 +243,11 @@ pub fn main() !u8 { | |||
| 277 | defer listener.deinit(); | 243 | defer listener.deinit(); |
| 278 | 244 | ||
| 279 | // The tile list, one line each, then the door: everything a script | 245 | // The tile list, one line each, then the door: everything a script |
| 280 | // (or a user) needs to know the hub is up and what it serves. | 246 | // (or a user) needs to know the hub is up and what it serves. The id |
| 281 | for (labels.items, 0..) |label, i| { | 247 | // is the hub's, not a position — it is what `/ws/<id>` names, and the |
| 282 | std.debug.print("muxweb: tile {d}: {s}\n", .{ i, label }); | 248 | // page can add and remove tiles under it while this line stays true. |
| 249 | for (hub.tiles.items) |t| { | ||
| 250 | std.debug.print("muxweb: tile {d}: {s}\n", .{ t.id, t.label }); | ||
| 283 | } | 251 | } |
| 284 | std.debug.print("muxweb: serving http://127.0.0.1:{d} pid={d}\n", .{ | 252 | std.debug.print("muxweb: serving http://127.0.0.1:{d} pid={d}\n", .{ |
| 285 | parsed.port, | 253 | parsed.port, |
| @@ -295,7 +263,7 @@ pub fn main() !u8 { | |||
| 295 | while (true) { | 263 | while (true) { |
| 296 | const conn = listener.accept() catch continue; | 264 | const conn = listener.accept() catch continue; |
| 297 | const th = std.Thread.spawn(.{}, webhub.serveConn, .{ | 265 | const th = std.Thread.spawn(.{}, webhub.serveConn, .{ |
| 298 | alloc, conn.stream, parsed.port, targets.items, labels.items, sessions.items, assets, | 266 | alloc, conn.stream, parsed.port, &hub, assets, |
| 299 | }) catch { | 267 | }) catch { |
| 300 | conn.stream.close(); | 268 | conn.stream.close(); |
| 301 | continue; | 269 | continue; |
| @@ -319,22 +287,29 @@ test "parse: three spellings become three tiles in argv order, port and key bind | |||
| 319 | var r = (try parseArgs(alloc, &args, null)).serve; | 287 | var r = (try parseArgs(alloc, &args, null)).serve; |
| 320 | defer r.deinit(alloc); | 288 | defer r.deinit(alloc); |
| 321 | try std.testing.expectEqual(@as(usize, 3), r.tiles.items.len); | 289 | try std.testing.expectEqual(@as(usize, 3), r.tiles.items.len); |
| 322 | try std.testing.expectEqualStrings("box1", r.tiles.items[0].spec.host); | 290 | try std.testing.expectEqualStrings("box1", r.tiles.items[0]); |
| 323 | try std.testing.expectEqualStrings("/tmp/a.sock", r.tiles.items[1].spec.sock); | 291 | // `--sock PATH` is ONE spelling from here on, prefix included — that |
| 324 | try std.testing.expectEqualStrings("h:4433", r.tiles.items[2].spec.quic); | 292 | // string is the label, the wall line, and the resolver's input alike. |
| 293 | try std.testing.expectEqualStrings("--sock /tmp/a.sock", r.tiles.items[1]); | ||
| 294 | try std.testing.expectEqualStrings("quic://h:4433", r.tiles.items[2]); | ||
| 325 | try std.testing.expectEqual(@as(u16, 8000), r.port); | 295 | try std.testing.expectEqual(@as(u16, 8000), r.port); |
| 326 | try std.testing.expectEqualStrings("/k", r.key.?); | 296 | try std.testing.expectEqualStrings("/k", r.key.?); |
| 327 | } | 297 | } |
| 328 | 298 | ||
| 329 | test "parse: zero targets, bad flags, and flag-beats-env" { | 299 | test "parse: zero targets, bad flags, and flag-beats-env" { |
| 330 | const alloc = std.testing.allocator; | 300 | const alloc = std.testing.allocator; |
| 331 | // Every refusal arrives as error.Usage — and the testing allocator is | 301 | // No targets is an empty argv wall, not a refusal: restore-from-file |
| 332 | // the other half of this pin: a refusal that leaked the tile list | 302 | // semantics live in main, which is the only place that can read a file. |
| 333 | // would fail the test that provoked it, which is what the single | 303 | { |
| 334 | // errdefer now guarantees on all seven paths. | 304 | var r = (try parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null)).serve; |
| 305 | defer r.deinit(alloc); | ||
| 306 | try std.testing.expectEqual(@as(usize, 0), r.tiles.items.len); | ||
| 307 | } | ||
| 308 | // Every other refusal arrives as error.Usage — and the testing | ||
| 309 | // allocator is the other half of this pin: a refusal that leaked the | ||
| 310 | // tile list (whose strings are now owned) would fail the test that | ||
| 311 | // provoked it, which is what the single errdefer guarantees. | ||
| 335 | // | 312 | // |
| 336 | // No targets is a usage error, not an empty wall. | ||
| 337 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null)); | ||
| 338 | // A flag with no value is a usage mistake, not a transport. | 313 | // A flag with no value is a usage mistake, not a transport. |
| 339 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock" }, null)); | 314 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock" }, null)); |
| 340 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "x" }, null)); | 315 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "x" }, null)); |
| @@ -376,7 +351,7 @@ test "parse: zero targets, bad flags, and flag-beats-env" { | |||
| 376 | } | 351 | } |
| 377 | } | 352 | } |
| 378 | 353 | ||
| 379 | test "tiles: #NAME splits off the session; the label keeps the full spelling" { | 354 | test "tiles: the spelling reaches the wall verbatim, `#NAME` and all" { |
| 380 | const alloc = std.testing.allocator; | 355 | const alloc = std.testing.allocator; |
| 381 | const args = [_][:0]const u8{ | 356 | const args = [_][:0]const u8{ |
| 382 | "muxweb", "host#b", "quic://h:1#b", "--sock", "/tmp/x#b", "plainhost", "a#b#c", | 357 | "muxweb", "host#b", "quic://h:1#b", "--sock", "/tmp/x#b", "plainhost", "a#b#c", |
| @@ -385,41 +360,26 @@ test "tiles: #NAME splits off the session; the label keeps the full spelling" { | |||
| 385 | defer r.deinit(alloc); | 360 | defer r.deinit(alloc); |
| 386 | try std.testing.expectEqual(@as(usize, 5), r.tiles.items.len); | 361 | try std.testing.expectEqual(@as(usize, 5), r.tiles.items.len); |
| 387 | 362 | ||
| 388 | // Every spelling splits the same way, and the label is the argv string | 363 | // The session SPLIT is wall.parseSpelling's, tested there. What is |
| 389 | // verbatim: the user asked for `host#b`, so that is the tile's name on | 364 | // this parse's own is that the argv string arrives intact: the user |
| 390 | // screen — the wall says which session it is showing without anyone | 365 | // asked for `host#b`, so that is the wall line, and therefore the |
| 391 | // having to decorate it. | 366 | // tile's name on screen — nobody decorates it on the way. |
| 392 | try std.testing.expectEqualStrings("host", r.tiles.items[0].spec.host); | 367 | try std.testing.expectEqualStrings("host#b", r.tiles.items[0]); |
| 393 | try std.testing.expectEqualStrings("b", r.tiles.items[0].session); | 368 | try std.testing.expectEqualStrings("quic://h:1#b", r.tiles.items[1]); |
| 394 | try std.testing.expectEqualStrings("host#b", r.tiles.items[0].label); | 369 | // The flag and its value become one spelling; the `#NAME` rides on the |
| 395 | 370 | // VALUE, where the user put it. | |
| 396 | try std.testing.expectEqualStrings("h:1", r.tiles.items[1].spec.quic); | 371 | try std.testing.expectEqualStrings("--sock /tmp/x#b", r.tiles.items[2]); |
| 397 | try std.testing.expectEqualStrings("b", r.tiles.items[1].session); | 372 | try std.testing.expectEqualStrings("plainhost", r.tiles.items[3]); |
| 398 | try std.testing.expectEqualStrings("quic://h:1#b", r.tiles.items[1].label); | 373 | try std.testing.expectEqualStrings("a#b#c", r.tiles.items[4]); |
| 399 | |||
| 400 | // The split is on the TARGET, and --sock's target is its VALUE. | ||
| 401 | try std.testing.expectEqualStrings("/tmp/x", r.tiles.items[2].spec.sock); | ||
| 402 | try std.testing.expectEqualStrings("b", r.tiles.items[2].session); | ||
| 403 | try std.testing.expectEqualStrings("/tmp/x#b", r.tiles.items[2].label); | ||
| 404 | |||
| 405 | // No '#' is the default session — the empty name, which is exactly the | ||
| 406 | // pre-M18 bytes on the wire. | ||
| 407 | try std.testing.expectEqualStrings("plainhost", r.tiles.items[3].spec.host); | ||
| 408 | try std.testing.expectEqualStrings("", r.tiles.items[3].session); | ||
| 409 | try std.testing.expectEqualStrings("plainhost", r.tiles.items[3].label); | ||
| 410 | |||
| 411 | // The LAST '#' wins: a name can never hold one (validSessionName), so | ||
| 412 | // anything earlier belongs to the target's own spelling. | ||
| 413 | try std.testing.expectEqualStrings("a#b", r.tiles.items[4].spec.host); | ||
| 414 | try std.testing.expectEqualStrings("c", r.tiles.items[4].session); | ||
| 415 | try std.testing.expectEqualStrings("a#b#c", r.tiles.items[4].label); | ||
| 416 | } | 374 | } |
| 417 | 375 | ||
| 418 | test "tiles: a bad session name after # is a usage error" { | 376 | test "tiles: a bad session name after # is still a usage error at parse" { |
| 419 | const alloc = std.testing.allocator; | 377 | const alloc = std.testing.allocator; |
| 420 | // These refusals print a line naming the tile before returning, so the | 378 | // The refusal stays HERE, at argv altitude, rather than surfacing later |
| 421 | // muxweb: lines in this test's output are the point, not noise: with | 379 | // as one tile that will not attach. These print a line naming the tile |
| 422 | // several tiles on the line, `usage` alone would not say which one. | 380 | // before returning, so the muxweb: lines in this test's output are the |
| 381 | // point, not noise: with several tiles on the line, `usage` alone would | ||
| 382 | // not say which one. | ||
| 423 | try std.testing.expectError( | 383 | try std.testing.expectError( |
| 424 | error.Usage, | 384 | error.Usage, |
| 425 | parseArgs(alloc, &[_][:0]const u8{ "muxweb", "host#has space" }, null), | 385 | parseArgs(alloc, &[_][:0]const u8{ "muxweb", "host#has space" }, null), |