a73x

b7241c32

feat: muxweb — the hub binary; tiles are argv, transports are mux's own

a73x   2026-08-13 10:37

Commit message
feat: muxweb — the hub binary; tiles are argv, transports are mux's own

M-web Task 7. muxweb TARGET... [--port N]: parse records spellings
(bare HOST / --sock / quic://, --key with mux's flag-beats-env rule,
--version short-circuiting) and main resolves them into client.Targets
— the ssh-recipe and key-resolution arms mirror mux_main's, noted as a
dedup candidate. serveConn (webhub.zig) runs one accepted connection:
keep-alive statics off the route table, and /ws/<idx> checks Origin
BEFORE upgrade — a hostile page gets a 403 and never a socket — then
consumes the connection into pumpTile on its own thread.

Assets ride as anonymous imports (@embedFile), the wasm one being the
artifact itself, which sequences the wasm build under the hub's. Hand
smoke: / 200, /mux_core.wasm 200 application/wasm, 404, wrong-Origin
ws 403, clean teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

build.zig
Old New
@@ -474,6 +474,29 @@ pub fn build(b: *std.Build) void {
474 wasm_exe.rdynamic = true; 474 wasm_exe.rdynamic = true;
475 b.installArtifact(wasm_exe); 475 b.installArtifact(wasm_exe);
476 476
477 // ---- muxweb, the hub binary (M-web Task 7) ----
478 // The page's three assets arrive as anonymous imports so @embedFile
479 // can name them; the wasm one is the artifact itself, which also
480 // sequences the wasm build before the hub's.
481 const webhub_main_mod = b.createModule(.{
482 .root_source_file = b.path("src/webhub_main.zig"),
483 .target = target,
484 .optimize = optimize,
485 .link_libc = true,
486 });
487 webhub_main_mod.addImport("client", client_mod);
488 webhub_main_mod.addImport("webhub", webhub_mod);
489 webhub_main_mod.addImport("xdg", xdg_mod);
490 webhub_main_mod.addImport("build_options", version_opts.createModule());
491 webhub_main_mod.addAnonymousImport("index.html", .{ .root_source_file = b.path("web/index.html") });
492 webhub_main_mod.addAnonymousImport("mux.js", .{ .root_source_file = b.path("web/mux.js") });
493 webhub_main_mod.addAnonymousImport("mux_core.wasm", .{ .root_source_file = wasm_exe.getEmittedBin() });
494 const webhub_exe = b.addExecutable(.{ .name = "muxweb", .root_module = webhub_main_mod });
495 webhub_exe.use_llvm = true;
496 webhub_exe.use_lld = true;
497 linkQuic(b, webhub_exe, quic);
498 b.installArtifact(webhub_exe);
499
477 const test_step = b.step("test", "Run unit tests"); 500 const test_step = b.step("test", "Run unit tests");
478 // delta_mod and sockpath_mod sit BEFORE server_mod, deliberately: their 501 // delta_mod and sockpath_mod sit BEFORE server_mod, deliberately: their
479 // tests are seconds-long and socket-free, while a regression in either 502 // tests are seconds-long and socket-free, while a regression in either
@@ -486,7 +509,7 @@ pub fn build(b: *std.Build) void {
486 // absence here was a live hazard recorded in decisions.md — muxd's 509 // absence here was a live hazard recorded in decisions.md — muxd's
487 // entrypoint could grow tests that silently never ran, exactly as 510 // entrypoint could grow tests that silently never ran, exactly as
488 // mux_main.zig's five did before it was added. 511 // mux_main.zig's five did before it was added.
489 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod }) |mod| { 512 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod, webhub_main_mod}) |mod| {
490 const t = b.addTest(.{ .root_module = mod }); 513 const t = b.addTest(.{ .root_module = mod });
491 t.use_llvm = true; 514 t.use_llvm = true;
492 t.use_lld = true; 515 t.use_lld = true;
@@ -498,7 +521,8 @@ pub fn build(b: *std.Build) void {
498 // found no libraries and no explanation. 521 // found no libraries and no explanation.
499 if (mod == server_mod or mod == quic_mod or mod == quic_server_mod or 522 if (mod == server_mod or mod == quic_mod or mod == quic_server_mod or
500 mod == exe_mod or mod == client_mod or mod == mux_mod or 523 mod == exe_mod or mod == client_mod or mod == mux_mod or
501 mod == quic_client_mod or mod == webhub_mod) linkQuic(b, t, quic); 524 mod == quic_client_mod or mod == webhub_mod or
525 mod == webhub_main_mod) linkQuic(b, t, quic);
502 test_step.dependOn(&b.addRunArtifact(t).step); 526 test_step.dependOn(&b.addRunArtifact(t).step);
503 } 527 }
504 528
src/webhub.zig
Old New
@@ -246,6 +246,72 @@ fn dialLoop(
246 } 246 }
247 } 247 }
248 248
249 /// One accepted connection, start to finish (M-web Task 7). Static
250 /// requests loop for keep-alive; a WebSocket upgrade consumes the
251 /// connection into a tile pump and never returns to HTTP.
252 pub fn serveConn(
253 alloc: std.mem.Allocator,
254 stream: std.net.Stream,
255 port: u16,
256 targets: []const client.Target,
257 assets: Assets,
258 ) void {
259 defer stream.close();
260 // The ONE buffer: max HTTP header and max inbound WS message alike.
261 var in_buf: [ws_buffer_len]u8 = undefined;
262 var out_buf: [8 * 1024]u8 = undefined;
263 var conn_reader = stream.reader(&in_buf);
264 var conn_writer = stream.writer(&out_buf);
265 var server = std.http.Server.init(conn_reader.interface(), &conn_writer.interface);
266
267 while (true) {
268 var req = server.receiveHead() catch return;
269 const path = req.head.target;
270
271 if (wsTileIndex(path, targets.len)) |idx| {
272 // Origin BEFORE upgrade, always: the refusal must happen while
273 // this is still HTTP, so a hostile page gets a 403 and never a
274 // socket. std's upgradeRequested does not look at Origin.
275 var origin: ?[]const u8 = null;
276 var it = req.iterateHeaders();
277 while (it.next()) |h| {
278 if (std.ascii.eqlIgnoreCase(h.name, "origin")) origin = h.value;
279 }
280 if (!originAllowed(origin, port)) {
281 req.respond("forbidden\n", .{ .status = .forbidden }) catch {};
282 return;
283 }
284 const key = switch (req.upgradeRequested()) {
285 .websocket => |k| k orelse {
286 req.respond("bad upgrade\n", .{ .status = .bad_request }) catch {};
287 return;
288 },
289 else => {
290 req.respond("websocket only\n", .{ .status = .bad_request }) catch {};
291 return;
292 },
293 };
294 var ws = req.respondWebSocket(.{ .key = key }) catch return;
295 ws.flush() catch return;
296 pumpTile(alloc, &ws, stream.handle, targets[idx]);
297 return;
298 }
299
300 if (route(assets, path)) |asset| {
301 req.respond(asset.body, .{
302 .extra_headers = &.{
303 .{ .name = "content-type", .value = asset.content_type },
304 // The page and its wasm never change under a running
305 // hub; the browser may cache for the session.
306 .{ .name = "cache-control", .value = "no-cache" },
307 },
308 }) catch return;
309 } else {
310 req.respond("not found\n", .{ .status = .not_found }) catch return;
311 }
312 }
313 }
314
249 test "origin: exactly our two spellings pass, everything else refuses" { 315 test "origin: exactly our two spellings pass, everything else refuses" {
250 const cases = [_]struct { origin: ?[]const u8, port: u16, want: bool }{ 316 const cases = [_]struct { origin: ?[]const u8, port: u16, want: bool }{
251 .{ .origin = "http://127.0.0.1:7681", .port = 7681, .want = true }, 317 .{ .origin = "http://127.0.0.1:7681", .port = 7681, .want = true },
src/webhub_main.zig
Old New
@@ -0,0 +1,289 @@
1 //! muxweb — the hub binary (M-web Task 7). `muxweb TARGET [TARGET ...]
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.
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
6 //! mux). The TARGET string is the tile's label; the tile list is argv —
7 //! no config file, per the standing non-goal.
8
9 const std = @import("std");
10 const client = @import("client");
11 const webhub = @import("webhub");
12 const build_options = @import("build_options");
13 const xdg = @import("xdg");
14
15 const usage =
16 \\usage: muxweb TARGET [TARGET ...] [--port N]
17 \\ each TARGET is a tile: HOST | --sock PATH | quic://HOST[:PORT]
18 \\ quic:// tiles use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key
19 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed
20 \\ --port N serves on 127.0.0.1:N (default 7681); localhost only,
21 \\ remote viewing is `ssh -L`
22 \\ --version prints the version
23 \\
24 ;
25
26 /// One tile as the command line spelled it. Building the full
27 /// client.Target needs an allocator and the environment, so the parse
28 /// records spellings and main resolves them — the same split mux_main
29 /// uses, for the same testability reason.
30 const TileSpec = union(enum) {
31 sock: []const u8,
32 host: []const u8,
33 quic: []const u8,
34 };
35
36 const Parsed = struct {
37 tiles: std.ArrayList(TileSpec),
38 port: u16 = webhub.default_port,
39 key: ?[]const u8 = null,
40 idle_ms: u32 = client.quic_idle_ms_default,
41
42 fn deinit(self: *Parsed, alloc: std.mem.Allocator) void {
43 self.tiles.deinit(alloc);
44 }
45 };
46
47 const ParseResult = union(enum) {
48 serve: Parsed,
49 version,
50 usage_error,
51 };
52
53 fn parseArgs(
54 alloc: std.mem.Allocator,
55 args: []const [:0]const u8,
56 env_key: ?[]const u8,
57 ) !ParseResult {
58 var p = Parsed{ .tiles = .empty };
59 errdefer p.tiles.deinit(alloc);
60 var key: ?[]const u8 = null;
61
62 var i: usize = 1;
63 while (i < args.len) : (i += 1) {
64 const a = args[i];
65 if (std.mem.eql(u8, a, "--version")) {
66 p.tiles.deinit(alloc);
67 return .version;
68 } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) {
69 i += 1;
70 try p.tiles.append(alloc, .{ .sock = args[i] });
71 } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) {
72 i += 1;
73 p.port = std.fmt.parseInt(u16, args[i], 10) catch {
74 p.tiles.deinit(alloc);
75 return .usage_error;
76 };
77 } else if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) {
78 i += 1;
79 key = args[i];
80 } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) {
81 i += 1;
82 const n = std.fmt.parseInt(u32, args[i], 10) catch {
83 p.tiles.deinit(alloc);
84 return .usage_error;
85 };
86 if (n == 0) {
87 p.tiles.deinit(alloc);
88 return .usage_error;
89 }
90 p.idle_ms = n;
91 } else if (std.mem.startsWith(u8, a, "quic://")) {
92 const hp = a["quic://".len..];
93 if (hp.len == 0) {
94 p.tiles.deinit(alloc);
95 return .usage_error;
96 }
97 try p.tiles.append(alloc, .{ .quic = hp });
98 } else if (a.len > 0 and a[0] != '-') {
99 try p.tiles.append(alloc, .{ .host = a });
100 } else {
101 p.tiles.deinit(alloc);
102 return .usage_error;
103 }
104 }
105
106 if (p.tiles.items.len == 0) {
107 p.tiles.deinit(alloc);
108 return .usage_error;
109 }
110 // --key wins over the environment, exactly mux_main's rule; empty
111 // means unset either way.
112 var k = key orelse env_key;
113 if (k) |kk| {
114 if (kk.len == 0) k = null;
115 }
116 p.key = k;
117 return .{ .serve = p };
118 }
119
120 pub fn main() !u8 {
121 var gpa: std.heap.DebugAllocator(.{}) = .init;
122 defer _ = gpa.deinit();
123 const alloc = gpa.allocator();
124
125 const args = try std.process.argsAlloc(alloc);
126 defer std.process.argsFree(alloc, args);
127
128 var parsed = switch (try parseArgs(alloc, args, std.posix.getenv(client_key_env))) {
129 .version => {
130 var vbuf: [64]u8 = undefined;
131 const s = std.fmt.bufPrint(&vbuf, "muxweb {s}\n", .{build_options.version}) catch unreachable;
132 _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {};
133 return 0;
134 },
135 .usage_error => {
136 std.debug.print("{s}", .{usage});
137 return 2;
138 },
139 .serve => |p| p,
140 };
141 defer parsed.deinit(alloc);
142
143 // Resolve spellings into dialable Targets. The recipes mirror
144 // mux_main's .host/.quic arms (a cross-file duplication, noted as a
145 // dedup candidate); labels are the argv spellings verbatim.
146 var targets: std.ArrayList(client.Target) = .empty;
147 defer targets.deinit(alloc);
148 var labels: std.ArrayList([]const u8) = .empty;
149 defer labels.deinit(alloc);
150 var owned: std.ArrayList([]const u8) = .empty;
151 defer {
152 for (owned.items) |s| alloc.free(s);
153 owned.deinit(alloc);
154 }
155
156 for (parsed.tiles.items) |spec| switch (spec) {
157 .sock => |path| {
158 if (path.len > 107) {
159 std.debug.print("muxweb: socket path too long ({d} bytes, max 107): {s}\n", .{ path.len, path });
160 return 2;
161 }
162 try targets.append(alloc, .{ .sock = path });
163 try labels.append(alloc, path);
164 },
165 .host => |h| {
166 const cmd = try std.fmt.allocPrint(alloc, "ssh {s} muxd endpoint", .{h});
167 try owned.append(alloc, cmd);
168 const cache: ?[]const u8 = xdg.hostCachePath(alloc, h) catch null;
169 if (cache) |c| try owned.append(alloc, c);
170 try targets.append(alloc, .{ .hand = .{
171 .host = h,
172 .ssh_cmd = cmd,
173 .cache_path = cache,
174 .idle_ms = parsed.idle_ms,
175 } });
176 try labels.append(alloc, h);
177 },
178 .quic => |hp| {
179 const key_path = parsed.key orelse blk: {
180 const p = try xdg.keyPath(alloc);
181 try owned.append(alloc, p);
182 std.fs.cwd().access(p, .{}) catch {
183 std.debug.print(
184 "muxweb: no key for quic://{s}: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n",
185 .{ hp, p },
186 );
187 return 2;
188 };
189 break :blk p;
190 };
191 try targets.append(alloc, .{ .quic = .{
192 .host_port = hp,
193 .key_path = key_path,
194 .idle_ms = parsed.idle_ms,
195 } });
196 const label = try std.fmt.allocPrint(alloc, "quic://{s}", .{hp});
197 try owned.append(alloc, label);
198 try labels.append(alloc, label);
199 },
200 };
201
202 const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable;
203 var listener = addr.listen(.{ .reuse_address = true }) catch |err| {
204 std.debug.print("muxweb: cannot bind 127.0.0.1:{d}: {s}\n", .{ parsed.port, @errorName(err) });
205 return 1;
206 };
207 defer listener.deinit();
208
209 // The tile list, one line each, then the door: everything a script
210 // (or a user) needs to know the hub is up and what it serves.
211 for (labels.items, 0..) |label, i| {
212 std.debug.print("muxweb: tile {d}: {s}\n", .{ i, label });
213 }
214 std.debug.print("muxweb: serving http://127.0.0.1:{d} pid={d}\n", .{
215 parsed.port,
216 std.os.linux.getpid(),
217 });
218
219 const assets = webhub.Assets{
220 .index_html = @embedFile("index.html"),
221 .mux_js = @embedFile("mux.js"),
222 .core_wasm = @embedFile("mux_core.wasm"),
223 };
224
225 while (true) {
226 const conn = listener.accept() catch continue;
227 const th = std.Thread.spawn(.{}, webhub.serveConn, .{
228 alloc, conn.stream, parsed.port, targets.items, assets,
229 }) catch {
230 conn.stream.close();
231 continue;
232 };
233 th.detach();
234 }
235 }
236
237 /// mux_main.key_env's value, spelled again rather than imported: pulling
238 /// an exe root into the module graph for one string constant is not
239 /// worth the tangle. mux_main.zig:49 is the origin; a drift between the
240 /// two spellings would make the two binaries read different variables,
241 /// so change both or neither.
242 const client_key_env = "MUX_KEY_FILE";
243
244 test "parse: three spellings become three tiles in argv order, port and key bind" {
245 const alloc = std.testing.allocator;
246 const args = [_][:0]const u8{
247 "muxweb", "box1", "--sock", "/tmp/a.sock", "quic://h:4433", "--key", "/k", "--port", "8000",
248 };
249 var r = (try parseArgs(alloc, &args, null)).serve;
250 defer r.deinit(alloc);
251 try std.testing.expectEqual(@as(usize, 3), r.tiles.items.len);
252 try std.testing.expectEqualStrings("box1", r.tiles.items[0].host);
253 try std.testing.expectEqualStrings("/tmp/a.sock", r.tiles.items[1].sock);
254 try std.testing.expectEqualStrings("h:4433", r.tiles.items[2].quic);
255 try std.testing.expectEqual(@as(u16, 8000), r.port);
256 try std.testing.expectEqualStrings("/k", r.key.?);
257 }
258
259 test "parse: zero targets, bad flags, and flag-beats-env" {
260 const alloc = std.testing.allocator;
261 // No targets is a usage error, not an empty wall.
262 try std.testing.expect((try parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null)) == .usage_error);
263 // A flag with no value is a usage mistake, not a transport.
264 try std.testing.expect((try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock" }, null)) == .usage_error);
265 try std.testing.expect((try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "x" }, null)) == .usage_error);
266 // Env fills in when --key is absent; --key wins when both are set.
267 {
268 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "/env-key")).serve;
269 defer r.deinit(alloc);
270 try std.testing.expectEqualStrings("/env-key", r.key.?);
271 }
272 {
273 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--key", "/flag-key" }, "/env-key")).serve;
274 defer r.deinit(alloc);
275 try std.testing.expectEqualStrings("/flag-key", r.key.?);
276 }
277 // Empty either way means unset.
278 {
279 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "")).serve;
280 defer r.deinit(alloc);
281 try std.testing.expectEqual(@as(?[]const u8, null), r.key);
282 }
283 }
284
285 test "version short-circuits everything else on the line" {
286 const alloc = std.testing.allocator;
287 const r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--version", "--bogus" }, null);
288 try std.testing.expect(r == .version);
289 }