a52200c3
feat: webhub — origin gate, route table, ws endpoint naming
a73x 2026-08-13 10:29
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -205,6 +205,15 @@ pub fn build(b: *std.Build) void { | |||
| 205 | .optimize = optimize, | 205 | .optimize = optimize, |
| 206 | }); | 206 | }); |
| 207 | 207 | ||
| 208 | // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route | ||
| 209 | // table, WS endpoint naming. Assets are injected (the exe root | ||
| 210 | // @embedFiles them), so its tests build no artifacts. | ||
| 211 | const webhub_mod = b.createModule(.{ | ||
| 212 | .root_source_file = b.path("src/webhub.zig"), | ||
| 213 | .target = target, | ||
| 214 | .optimize = optimize, | ||
| 215 | }); | ||
| 216 | |||
| 208 | // The socket path's identity and the right to bind it: the stale-socket | 217 | // The socket path's identity and the right to bind it: the stale-socket |
| 209 | // claim and the dev+ino record teardown compares against. A leaf — it | 218 | // claim and the dev+ino record teardown compares against. A leaf — it |
| 210 | // takes a path and nothing else, and knows no Server exists. | 219 | // takes a path and nothing else, and knows no Server exists. |
| @@ -472,7 +481,7 @@ pub fn build(b: *std.Build) void { | |||
| 472 | // absence here was a live hazard recorded in decisions.md — muxd's | 481 | // absence here was a live hazard recorded in decisions.md — muxd's |
| 473 | // entrypoint could grow tests that silently never ran, exactly as | 482 | // entrypoint could grow tests that silently never ran, exactly as |
| 474 | // mux_main.zig's five did before it was added. | 483 | // mux_main.zig's five did before it was added. |
| 475 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, keymap_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| { | 484 | 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| { |
| 476 | const t = b.addTest(.{ .root_module = mod }); | 485 | const t = b.addTest(.{ .root_module = mod }); |
| 477 | t.use_llvm = true; | 486 | t.use_llvm = true; |
| 478 | t.use_lld = true; | 487 | t.use_lld = true; |
src/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,136 @@ | |||
| 1 | //! The muxweb hub's HTTP/WebSocket layer (M-web Task 5): route table, | ||
| 2 | //! Origin gate, and the WS endpoint naming — the decisions std.http does | ||
| 3 | //! NOT make for us. The connection loop and per-tile pump build on this | ||
| 4 | //! (Tasks 6-7); the assets are @embedFile'd by webhub_main.zig (the exe | ||
| 5 | //! root) and injected here, which keeps this module testable without | ||
| 6 | //! building the wasm artifact. | ||
| 7 | //! | ||
| 8 | //! Localhost only, by construction: the hub binds 127.0.0.1 and there is | ||
| 9 | //! no flag to change that in v1 — remote viewing is `ssh -L`, | ||
| 10 | //! authenticated by ssh like everything else in this project. | ||
| 11 | |||
| 12 | const std = @import("std"); | ||
| 13 | |||
| 14 | pub const default_port: u16 = 7681; | ||
| 15 | |||
| 16 | /// ONE number bounds two things, a property of std.http.Server: the | ||
| 17 | /// buffer handed to the connection's Reader is both the max HTTP header | ||
| 18 | /// size AND the max inbound WebSocket message (readSmallMessage rejects | ||
| 19 | /// fragmented messages outright and caps at the buffer length). 64 KiB: | ||
| 20 | /// headers never approach it, keystrokes are bytes, and the browser side | ||
| 21 | /// chunks pastes at 32 KiB so no message approaches it either. Hub→ | ||
| 22 | /// browser has no such bound (u64 lengths) — snapshots are safe. | ||
| 23 | pub const ws_buffer_len = 64 * 1024; | ||
| 24 | |||
| 25 | /// The WebSocket Origin check, non-negotiable (spec): any webpage open | ||
| 26 | /// in the browser may attempt ws://127.0.0.1:PORT — localhost binding | ||
| 27 | /// does not stop cross-origin WebSocket dials, and this socket carries | ||
| 28 | /// shell input to every device. Exactly our own two spellings pass; | ||
| 29 | /// no Origin header refuses. std's upgradeRequested does NOT check this; | ||
| 30 | /// it is entirely ours. | ||
| 31 | pub fn originAllowed(origin: ?[]const u8, port: u16) bool { | ||
| 32 | const o = origin orelse return false; | ||
| 33 | var buf: [40]u8 = undefined; | ||
| 34 | inline for (.{ "127.0.0.1", "localhost" }) |host| { | ||
| 35 | const want = std.fmt.bufPrint(&buf, "http://" ++ host ++ ":{d}", .{port}) catch | ||
| 36 | unreachable; | ||
| 37 | if (std.mem.eql(u8, o, want)) return true; | ||
| 38 | } | ||
| 39 | return false; | ||
| 40 | } | ||
| 41 | |||
| 42 | /// `/ws/<idx>` → the tile index, or null for anything else (including an | ||
| 43 | /// index that fails to parse or overruns the tile list — the caller sees | ||
| 44 | /// null and 404s rather than indexing air). | ||
| 45 | pub fn wsTileIndex(path: []const u8, tile_count: usize) ?usize { | ||
| 46 | const prefix = "/ws/"; | ||
| 47 | if (!std.mem.startsWith(u8, path, prefix)) return null; | ||
| 48 | const idx = std.fmt.parseInt(usize, path[prefix.len..], 10) catch return null; | ||
| 49 | if (idx >= tile_count) return null; | ||
| 50 | return idx; | ||
| 51 | } | ||
| 52 | |||
| 53 | /// The embedded page, injected by the exe root (webhub_main @embedFiles | ||
| 54 | /// them; tests inject fakes). | ||
| 55 | pub const Assets = struct { | ||
| 56 | index_html: []const u8, | ||
| 57 | mux_js: []const u8, | ||
| 58 | core_wasm: []const u8, | ||
| 59 | }; | ||
| 60 | |||
| 61 | pub const Asset = struct { | ||
| 62 | body: []const u8, | ||
| 63 | content_type: []const u8, | ||
| 64 | }; | ||
| 65 | |||
| 66 | /// The whole static route table. Anything else is a 404 — there are no | ||
| 67 | /// other files, and inventing a directory to traverse would be the only | ||
| 68 | /// way to get one. | ||
| 69 | pub fn route(assets: Assets, path: []const u8) ?Asset { | ||
| 70 | if (std.mem.eql(u8, path, "/") or std.mem.eql(u8, path, "/index.html")) | ||
| 71 | return .{ .body = assets.index_html, .content_type = "text/html; charset=utf-8" }; | ||
| 72 | if (std.mem.eql(u8, path, "/mux.js")) | ||
| 73 | return .{ .body = assets.mux_js, .content_type = "application/javascript" }; | ||
| 74 | if (std.mem.eql(u8, path, "/mux_core.wasm")) | ||
| 75 | return .{ .body = assets.core_wasm, .content_type = "application/wasm" }; | ||
| 76 | return null; | ||
| 77 | } | ||
| 78 | |||
| 79 | // --------------------------------------------------------------------------- | ||
| 80 | |||
| 81 | test "origin: exactly our two spellings pass, everything else refuses" { | ||
| 82 | const cases = [_]struct { origin: ?[]const u8, port: u16, want: bool }{ | ||
| 83 | .{ .origin = "http://127.0.0.1:7681", .port = 7681, .want = true }, | ||
| 84 | .{ .origin = "http://localhost:7681", .port = 7681, .want = true }, | ||
| 85 | .{ .origin = "http://127.0.0.1:41234", .port = 41234, .want = true }, | ||
| 86 | // The port is part of the identity. | ||
| 87 | .{ .origin = "http://127.0.0.1:7682", .port = 7681, .want = false }, | ||
| 88 | // https is a different origin even on the right host+port. | ||
| 89 | .{ .origin = "https://127.0.0.1:7681", .port = 7681, .want = false }, | ||
| 90 | // Any other page, including one that merely CONTAINS ours. | ||
| 91 | .{ .origin = "http://evil.example", .port = 7681, .want = false }, | ||
| 92 | .{ .origin = "http://127.0.0.1:7681.evil.example", .port = 7681, .want = false }, | ||
| 93 | .{ .origin = "http://[::1]:7681", .port = 7681, .want = false }, | ||
| 94 | // A missing Origin header is a refusal, not a shrug: browsers | ||
| 95 | // always send it on cross-origin WebSocket dials, so its absence | ||
| 96 | // means a non-browser client that can speak to the daemon | ||
| 97 | // directly anyway — and "absent means yes" is how localhost | ||
| 98 | // servers get owned. | ||
| 99 | .{ .origin = null, .port = 7681, .want = false }, | ||
| 100 | .{ .origin = "", .port = 7681, .want = false }, | ||
| 101 | }; | ||
| 102 | for (cases) |c| { | ||
| 103 | try std.testing.expectEqual(c.want, originAllowed(c.origin, c.port)); | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | test "ws path: /ws/<idx> in range, null for everything else" { | ||
| 108 | try std.testing.expectEqual(@as(?usize, 0), wsTileIndex("/ws/0", 3)); | ||
| 109 | try std.testing.expectEqual(@as(?usize, 2), wsTileIndex("/ws/2", 3)); | ||
| 110 | try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/ws/3", 3)); // over | ||
| 111 | try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/ws/", 3)); | ||
| 112 | try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/ws/x", 3)); | ||
| 113 | try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/ws/-1", 3)); | ||
| 114 | try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/wsx/0", 3)); | ||
| 115 | try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/", 3)); | ||
| 116 | } | ||
| 117 | |||
| 118 | test "routes: the three assets with their content types, 404 for the rest" { | ||
| 119 | const assets = Assets{ | ||
| 120 | .index_html = "<html>", | ||
| 121 | .mux_js = "js", | ||
| 122 | .core_wasm = "\x00asm", | ||
| 123 | }; | ||
| 124 | const idx = route(assets, "/").?; | ||
| 125 | try std.testing.expectEqualStrings("<html>", idx.body); | ||
| 126 | try std.testing.expectEqualStrings("text/html; charset=utf-8", idx.content_type); | ||
| 127 | try std.testing.expectEqualStrings("<html>", route(assets, "/index.html").?.body); | ||
| 128 | const js = route(assets, "/mux.js").?; | ||
| 129 | try std.testing.expectEqualStrings("application/javascript", js.content_type); | ||
| 130 | const wasm = route(assets, "/mux_core.wasm").?; | ||
| 131 | try std.testing.expectEqualStrings("application/wasm", wasm.content_type); | ||
| 132 | try std.testing.expectEqualStrings("\x00asm", wasm.body); | ||
| 133 | try std.testing.expectEqual(@as(?Asset, null), route(assets, "/etc/passwd")); | ||
| 134 | try std.testing.expectEqual(@as(?Asset, null), route(assets, "/ws/0")); | ||
| 135 | try std.testing.expectEqual(@as(?Asset, null), route(assets, "/../src/main.zig")); | ||
| 136 | } | ||
web/index.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,17 @@ | |||
| 1 | <!doctype html> | ||
| 2 | <!-- muxweb: the wall of devices. Skeleton (M-web Task 5); the shell | ||
| 3 | proper lands in Task 8. --> | ||
| 4 | <html lang="en"> | ||
| 5 | <head> | ||
| 6 | <meta charset="utf-8"> | ||
| 7 | <title>mux</title> | ||
| 8 | <style> | ||
| 9 | body { margin: 0; background: #111; color: #ccc; font: 14px monospace; } | ||
| 10 | #wall { display: grid; gap: 8px; padding: 8px; } | ||
| 11 | </style> | ||
| 12 | </head> | ||
| 13 | <body> | ||
| 14 | <div id="wall"></div> | ||
| 15 | <script src="/mux.js"></script> | ||
| 16 | </body> | ||
| 17 | </html> | ||
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,7 @@ | |||
| 1 | // muxweb glue skeleton (M-web Task 5). The renderer, input, and tile | ||
| 2 | // logic land in Task 8. Discipline stated once, here, for everything | ||
| 3 | // that follows: NEVER cache a view of wasm memory — every call can grow | ||
| 4 | // linear memory and growth detaches every ArrayBuffer view. Re-read | ||
| 5 | // exports.memory.buffer after each call. | ||
| 6 | 'use strict'; | ||
| 7 | console.log('muxweb: shell skeleton'); | ||