src/cli/webhub_main.zig
Ref: Size: 15.7 KiB History
//! `mux web` — the hub mode: serves the wall page on 127.0.0.1 and pumps one
//! WebSocket per tile.
//!
//! The wall is the LAYOUT file, the same one the terminal wall reads and
//! writes: `/tiles` is that file's panes in tree order, and the poll grades
//! them. Hosts supplied on the command line are recorded in the hosts file
//! before it is loaded, because a pane may only name a listed daemon.
//! Session suffixes are rejected there because a host line names a machine;
//! a pane is authored on a wall, not on this command line.
const std = @import("std");
const client = @import("client");
const client_os = @import("client_os");
const webhub = @import("webhub");
const hosts = @import("client").hosts;
const build_options = @import("build_options");
const xdg = @import("xdg");
const cliflags = @import("cliflags");
const usage =
\\usage: mux web [HOST ...] [--port N]
\\ each HOST is a daemon: HOST | --sock PATH | quic://HOST[:PORT]
\\ `--sock PATH` may be two arguments or one quoted '--sock PATH', the
\\ spelling the hosts file holds; `mux hosts add` takes both too
\\ a HOST on the line is added to the hosts file (deduped); the WALL is
\\ the layout file, and its tiles are that file's panes
\\ no #SESSION: a host line names a daemon, and a pane is authored on a
\\ wall — `mux hosts rm` is how a daemon leaves the file
\\ quic:// hosts use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key
\\ [--quic-idle-ms N] tunes how fast a dead link is noticed
\\ --port N serves on 127.0.0.1:N (default 7681); localhost only,
\\ remote viewing is `ssh -L`
\\ --version prints the version, --help this page
\\
;
/// Parsed hub flags and host arguments. Field names and types define the flag
/// syntax; `parseArgs` applies the semantic checks afterward.
const HubArguments = struct {
port: u16 = webhub.default_port,
key: ?[]const u8 = null,
quic_idle_ms: client.IdleMs = .{},
_argv: hosts.Argv,
pub fn positional(self: *HubArguments, w: []const u8) bool {
return self._argv.positional(w);
}
pub fn extra(self: *HubArguments, rest: []const [:0]const u8) usize {
return self._argv.extra(rest);
}
fn deinit(self: *HubArguments) void {
self._argv.deinit();
}
};
comptime {
cliflags.assertDocumented(HubArguments, usage, &.{});
}
/// Parsing can fail through the shared CLI errors or while allocating the host
/// list. Keeping failures in an error union lets one `errdefer` release that
/// list on every unsuccessful path.
const ParseError = cliflags.ParseError || std.mem.Allocator.Error;
fn parseArgs(
alloc: std.mem.Allocator,
args: []const [:0]const u8,
env_key: ?[]const u8,
) ParseError!HubArguments {
var p = HubArguments{ ._argv = .{ .alloc = alloc } };
errdefer p.deinit();
const outcome = cliflags.parseStrict(HubArguments, &p, args[1..]);
// The host parser records a specific reason before returning false to the
// generic flag parser. Report that reason first so an invalid command with
// several hosts identifies the offending target.
if (p._argv.err) |e| {
if (e.err == error.OutOfMemory) return error.OutOfMemory;
std.debug.print("mux web: host {s}: {s}\n", .{ e.word, hosts.reason(e.err) });
return error.Usage;
}
try outcome;
// Port zero would make the kernel choose an ephemeral port, but the hub
// reports the configured value. Reject it rather than printing an unusable
// address.
if (p.port == 0) return error.Usage;
// An empty host list is valid because `main` will load existing hosts from
// the state file.
p.key = xdg.pickKey(p.key, env_key);
return p;
}
/// `mux web`. argv is the dispatcher's, minus the program name.
pub fn main(args: []const [:0]const u8) !u8 {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer if (gpa.deinit() == .leak)
std.debug.print("mux web: LEAK: allocations outlived deinit\n", .{});
const alloc = gpa.allocator();
var parsed = parseArgs(alloc, args, std.posix.getenv(xdg.key_env)) catch |err| switch (err) {
error.OutOfMemory => return err,
else => |e| return cliflags.exitFor(e, usage, "mux", build_options.version),
};
defer parsed.deinit();
// All resolved host data lives until the non-returning accept loop ends, so
// one arena owns it for the lifetime of the hub.
var arena_state = std.heap.ArenaAllocator.init(alloc);
defer arena_state.deinit();
const arena = arena_state.allocator();
const state_path = try hosts.statePath(arena);
// Command-line hosts are persisted before loading the wall, making
// `mux web box` equivalent to adding `box` and then starting the hub.
for (parsed._argv.list.items) |spelling| {
_ = hosts.record(alloc, state_path, spelling) catch |err|
return refuseFile(arena, state_path, err);
}
var h = hosts.load(arena, state_path) catch |err| return refuseFile(arena, state_path, err);
defer h.deinit(arena);
if (h.lines.items.len == 0) {
// Unlike the interactive client, the web hub does not start a local
// daemon when the hosts file is empty. Print guidance once instead.
std.debug.print("mux web: no hosts (mux hosts add HOST, or mux HOST)\n", .{});
}
// Use the same resolver as the CLI wall so host syntax, key resolution,
// and background polling behavior remain consistent between interfaces.
const specs = try arena.alloc(client.HostSpec, h.lines.items.len);
for (specs, h.lines.items) |*spec, line| {
spec.* = client.resolveHost(arena, line, parsed.key, parsed.quic_idle_ms.ms) catch |err| switch (err) {
error.MissingKey => {
std.debug.print(
"mux web: no key for a quic:// host: pass --key, set MUX_KEY_FILE, or run `mux d keygen`\n",
.{},
);
return 2;
},
else => return err,
};
}
// The layout is the wall. `readLeaves` has already printed the line to
// fix when it refuses one, and an empty wall is what the browser gets:
// a page that served half a layout would be a wall the user cannot see
// is short, and one that refused to start would take the whole hub down
// over a file the terminal wall can repair.
const layout_path = try hosts.layoutPath(arena);
const leaves: []const webhub.Leaf = webhub.readLeaves(arena, layout_path, specs) catch |err| switch (err) {
error.BadLayout => &.{},
else => return err,
};
// Only worth saying when there is somewhere for a pane to live: an
// empty hosts file already printed its own guidance above.
if (leaves.len == 0 and h.lines.items.len != 0) {
std.debug.print("mux web: no panes ({s}); add one from a terminal wall\n", .{layout_path});
}
var hub = try webhub.Hub.init(arena, specs, leaves);
defer hub.deinit();
hub.layout_path = layout_path;
var listener = webhub.listenLocal(parsed.port) catch |err| switch (err) {
// `mux d`'s refusal, in the hub's own words: a port another hub owns
// is not this hub's to take, and sharing it strands both — they each
// keep serving a wall and a browser reaches whichever the kernel
// hands the connection to. Same one-liner and same rc as
// `mux d: a daemon is already running on PATH`, and knowingly the
// same wrong-ish message when the port belongs to some other
// program entirely: the advice is right either way, which is the
// trade decisions.md already records for the daemon's own
// AddressInUse.
error.AddressInUse => {
std.debug.print("mux web: a hub is already running on 127.0.0.1:{d} (--port N serves elsewhere)\n", .{parsed.port});
return 1;
},
else => {
std.debug.print("mux web: cannot bind 127.0.0.1:{d}: {s}\n", .{ parsed.port, @errorName(err) });
return 1;
},
};
defer listener.deinit();
// Print the listening address immediately. The tiles were announced by
// `Hub.birth` above, one per pane, as the layout was read.
std.debug.print("mux web: serving http://127.0.0.1:{d} pid={d}\n", .{
parsed.port,
client_os.getpid(),
});
hub.start();
const assets = webhub.Assets{
.index_html = @embedFile("index.html"),
.mux_js = @embedFile("mux.js"),
.core_wasm = @embedFile("mux_core.wasm"),
};
while (true) {
const conn = listener.accept() catch continue;
const th = std.Thread.spawn(.{}, webhub.serveConn, .{
alloc, conn.stream, parsed.port, &hub, assets,
}) catch {
conn.stream.close();
continue;
};
th.detach();
}
}
test "parse: three spellings become three hosts in argv order, port and key bind" {
const alloc = std.testing.allocator;
const args = [_][:0]const u8{
"web", "box1", "--sock", "/tmp/a.sock", "quic://h:4433", "--key", "/k", "--port", "8000",
};
var r = try parseArgs(alloc, &args, null);
defer r.deinit();
try std.testing.expectEqual(@as(usize, 3), r._argv.list.items.len);
try std.testing.expectEqualStrings("box1", r._argv.list.items[0]);
// From this point, `--sock PATH` is stored as one string used by the hosts
// file, page label, and resolver.
try std.testing.expectEqualStrings("--sock /tmp/a.sock", r._argv.list.items[1]);
try std.testing.expectEqualStrings("quic://h:4433", r._argv.list.items[2]);
try std.testing.expectEqual(@as(u16, 8000), r.port);
try std.testing.expectEqualStrings("/k", r.key.?);
}
test "parse: a quoted '--sock PATH' is the same host as the two-argument form" {
const alloc = std.testing.allocator;
// The hosts file's own spelling, pasted straight onto the command line.
var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "--sock /tmp/a.sock" }, null);
defer r.deinit();
try std.testing.expectEqual(@as(usize, 1), r._argv.list.items.len);
try std.testing.expectEqualStrings("--sock /tmp/a.sock", r._argv.list.items[0]);
}
test "parse: zero hosts, bad flags, and flag-beats-env" {
const alloc = std.testing.allocator;
// No command-line hosts is valid; `main` is responsible for loading the
// hosts file.
{
var r = try parseArgs(alloc, &[_][:0]const u8{"web"}, null);
defer r.deinit();
try std.testing.expectEqual(@as(usize, 0), r._argv.list.items.len);
}
// Syntax failures return `error.Usage`. The testing allocator also verifies
// that each failure releases any hosts already appended to the list.
try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "--sock" }, null));
try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--port", "x" }, null));
// Exercise failures after a host has already been allocated.
try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--wat" }, null));
try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "quic://" }, null));
try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--quic-idle-ms", "0" }, null));
// Port zero would make the announced address differ from the bound port.
try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--port", "0" }, null));
// A nonzero value confirms that the flag itself is accepted.
{
var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--port", "1" }, null);
defer r.deinit();
try std.testing.expectEqual(@as(u16, 1), r.port);
}
// Env fills in when --key is absent; --key wins when both are set.
{
var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "h" }, "/env-key");
defer r.deinit();
try std.testing.expectEqualStrings("/env-key", r.key.?);
}
{
var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--key", "/flag-key" }, "/env-key");
defer r.deinit();
try std.testing.expectEqualStrings("/flag-key", r.key.?);
}
// Empty either way means unset.
{
var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "h" }, "");
defer r.deinit();
try std.testing.expectEqual(@as(?[]const u8, null), r.key);
}
}
test "hosts: the spelling reaches the file verbatim" {
const alloc = std.testing.allocator;
const args = [_][:0]const u8{ "web", "user@box.example.com", "quic://h:1", "--sock", "/tmp/x", "plainhost" };
var r = try parseArgs(alloc, &args, null);
defer r.deinit();
try std.testing.expectEqual(@as(usize, 4), r._argv.list.items.len);
// Preserve the exact hostname for both the state-file entry and tile label.
try std.testing.expectEqualStrings("user@box.example.com", r._argv.list.items[0]);
try std.testing.expectEqualStrings("quic://h:1", r._argv.list.items[1]);
// The flag and its value become one spelling.
try std.testing.expectEqualStrings("--sock /tmp/x", r._argv.list.items[2]);
try std.testing.expectEqualStrings("plainhost", r._argv.list.items[3]);
}
test "hosts: a '#SESSION' is rejected during parsing in every spelling" {
const alloc = std.testing.allocator;
// Reject `#NAME` while parsing argv because wall entries identify daemons,
// not sessions. The emitted `mux web:` diagnostics identify each bad host.
for ([_][]const u8{ "host#b", "host#has space", "host#", "a#b#c", "quic://h:1#b" }) |bad| {
var argv = [_][:0]const u8{ "web", undefined };
var buf: [64]u8 = undefined;
@memcpy(buf[0..bad.len], bad);
buf[bad.len] = 0;
argv[1] = buf[0..bad.len :0];
try std.testing.expectError(error.Usage, parseArgs(alloc, &argv, null));
}
// Same rule through --sock's value.
try std.testing.expectError(
error.Usage,
parseArgs(alloc, &[_][:0]const u8{ "web", "--sock", "/tmp/x#b" }, null),
);
// Also reject punctuation that would make a hostname unsafe as an SSH
// argument.
try std.testing.expectError(
error.Usage,
parseArgs(alloc, &[_][:0]const u8{ "web", "box; touch /tmp/pwned" }, null),
);
}
test "help is requested output, and -- separates hosts from flags" {
const alloc = std.testing.allocator;
try std.testing.expectError(error.Help, parseArgs(alloc, &[_][:0]const u8{ "web", "--help" }, null));
try std.testing.expectError(error.Help, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "-h" }, null));
// After `--`, a flag-shaped word is treated as a host.
var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "--", "host" }, null);
defer r.deinit();
try std.testing.expectEqual(@as(usize, 1), r._argv.list.items.len);
try std.testing.expectEqualStrings("host", r._argv.list.items[0]);
}
test "version short-circuits everything else on the line" {
const alloc = std.testing.allocator;
try std.testing.expectError(error.Version, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--version", "--bogus" }, null));
}
// Ensure every public declaration is semantically analyzed during tests;
// `std.meta.declarations` does not include private declarations.
test {
std.testing.refAllDeclsRecursive(@This());
}
/// Report a hosts-file error using this command's prefix. For parse errors,
/// print each invalid hand-edited line so the user can repair the file.
fn refuseFile(arena: std.mem.Allocator, path: []const u8, err: anyerror) u8 {
std.debug.print("mux web: {s}: {s}\n", .{ path, hosts.reason(err) });
if (!hosts.isParse(err)) return 1;
const lines = hosts.loadLines(arena, path) catch return 2;
for (lines.items) |l| _ = hosts.parse(l) catch std.debug.print(" {s}\n", .{l});
return 2;
}