a73x

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;
}