1e3ab018
feat: mux HOST attaches over ssh — the VM hop is one word
a73x 2026-08-08 14:08
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -90,7 +90,9 @@ pub fn build(b: *std.Build) void { | |||
| 90 | b.installArtifact(mux_exe); | 90 | b.installArtifact(mux_exe); |
| 91 | 91 | ||
| 92 | const test_step = b.step("test", "Run unit tests"); | 92 | const test_step = b.step("test", "Run unit tests"); |
| 93 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod }) |mod| { | 93 | // mux_mod is an executable root, but it carries the argument parser, and |
| 94 | // a test that is never built is not a test. | ||
| 95 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod }) |mod| { | ||
| 94 | const t = b.addTest(.{ .root_module = mod }); | 96 | const t = b.addTest(.{ .root_module = mod }); |
| 95 | t.use_llvm = true; | 97 | t.use_llvm = true; |
| 96 | t.use_lld = true; | 98 | t.use_lld = true; |
src/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,10 +1,67 @@ | |||
| 1 | //! mux — client binary. `mux [--sock PATH]` attaches to the local muxd; | 1 | //! mux — client binary. `mux [--sock PATH]` attaches to the local muxd; |
| 2 | //! `mux --via CMD` attaches over CMD's stdio instead (any command that | 2 | //! `mux --via CMD` attaches over CMD's stdio instead (any command that |
| 3 | //! exposes a session socket as a byte pipe, e.g. `ssh host muxd proxy`). | 3 | //! exposes a session socket as a byte pipe, e.g. `ssh host muxd proxy`); |
| 4 | //! `mux HOST` is sugar for exactly that ssh recipe. | ||
| 4 | const std = @import("std"); | 5 | const std = @import("std"); |
| 5 | const client = @import("client"); | 6 | const client = @import("client"); |
| 6 | 7 | ||
| 7 | const usage = "usage: mux [--sock PATH | --via CMD]\n"; | 8 | const usage = |
| 9 | \\usage: mux [HOST | --sock PATH | --via CMD] | ||
| 10 | \\ HOST attaches over "ssh HOST muxd proxy" (muxd must be on HOST's PATH) | ||
| 11 | \\ | ||
| 12 | ; | ||
| 13 | |||
| 14 | /// What the command line asked for. A tagged union rather than a struct of | ||
| 15 | /// optionals so the two failure modes are results in their own right, and so | ||
| 16 | /// the parse can be tested without a process to exit from. | ||
| 17 | const ParseResult = union(enum) { | ||
| 18 | /// At most one of these is set; both null means the default local socket. | ||
| 19 | attach: struct { sock: ?[]const u8 = null, via: ?[]const u8 = null }, | ||
| 20 | /// A bare hostname: the ssh recipe is built from it in main, where there | ||
| 21 | /// is an allocator to build it with. | ||
| 22 | host: []const u8, | ||
| 23 | /// More than one transport named — a request that cannot be honoured | ||
| 24 | /// rather than one to reconcile. | ||
| 25 | conflict, | ||
| 26 | usage_error, | ||
| 27 | }; | ||
| 28 | |||
| 29 | fn parseArgs(args: []const [:0]const u8) ParseResult { | ||
| 30 | var sock: ?[]const u8 = null; | ||
| 31 | var via: ?[]const u8 = null; | ||
| 32 | var host: ?[]const u8 = null; | ||
| 33 | |||
| 34 | var i: usize = 1; | ||
| 35 | while (i < args.len) : (i += 1) { | ||
| 36 | const a = args[i]; | ||
| 37 | if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { | ||
| 38 | i += 1; | ||
| 39 | if (sock != null) return .conflict; | ||
| 40 | sock = args[i]; | ||
| 41 | } else if (std.mem.eql(u8, a, "--via") and i + 1 < args.len) { | ||
| 42 | i += 1; | ||
| 43 | if (via != null) return .conflict; | ||
| 44 | via = args[i]; | ||
| 45 | } else if (a.len > 0 and a[0] != '-') { | ||
| 46 | // A bare word is a host to hop to. Two of them is as ambiguous | ||
| 47 | // as naming two transports, so it lands in the same place. | ||
| 48 | if (host != null) return .conflict; | ||
| 49 | host = a; | ||
| 50 | } else { | ||
| 51 | // Includes `--sock`/`--via` with no value left to take: a flag | ||
| 52 | // whose argument is missing is a usage mistake, not a transport. | ||
| 53 | return .usage_error; | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | // Every pairing of the three is two transports for one session. | ||
| 58 | const named: u8 = @as(u8, @intFromBool(sock != null)) + | ||
| 59 | @intFromBool(via != null) + @intFromBool(host != null); | ||
| 60 | if (named > 1) return .conflict; | ||
| 61 | |||
| 62 | if (host) |h| return .{ .host = h }; | ||
| 63 | return .{ .attach = .{ .sock = sock, .via = via } }; | ||
| 64 | } | ||
| 8 | 65 | ||
| 9 | pub fn main() !u8 { | 66 | pub fn main() !u8 { |
| 10 | var gpa: std.heap.DebugAllocator(.{}) = .init; | 67 | var gpa: std.heap.DebugAllocator(.{}) = .init; |
| @@ -14,37 +71,89 @@ pub fn main() !u8 { | |||
| 14 | const args = try std.process.argsAlloc(alloc); | 71 | const args = try std.process.argsAlloc(alloc); |
| 15 | defer std.process.argsFree(alloc, args); | 72 | defer std.process.argsFree(alloc, args); |
| 16 | 73 | ||
| 17 | var sock_arg: ?[]const u8 = null; | 74 | const parsed = parseArgs(args); |
| 18 | var via_arg: ?[]const u8 = null; | 75 | switch (parsed) { |
| 19 | var i: usize = 1; | 76 | .usage_error => { |
| 20 | while (i < args.len) : (i += 1) { | ||
| 21 | if (std.mem.eql(u8, args[i], "--sock") and i + 1 < args.len) { | ||
| 22 | i += 1; | ||
| 23 | sock_arg = args[i]; | ||
| 24 | } else if (std.mem.eql(u8, args[i], "--via") and i + 1 < args.len) { | ||
| 25 | i += 1; | ||
| 26 | via_arg = args[i]; | ||
| 27 | } else { | ||
| 28 | std.debug.print("{s}", .{usage}); | 77 | std.debug.print("{s}", .{usage}); |
| 29 | return 2; | 78 | return 2; |
| 30 | } | 79 | }, |
| 80 | .conflict => { | ||
| 81 | std.debug.print("mux: name one transport: HOST, --sock or --via\n{s}", .{usage}); | ||
| 82 | return 2; | ||
| 83 | }, | ||
| 84 | .host => |h| { | ||
| 85 | // muxd on the far side exposes the session over its stdio; ssh | ||
| 86 | // carries the bytes and nothing here knows the difference. | ||
| 87 | const cmd = try std.fmt.allocPrint(alloc, "ssh {s} muxd proxy", .{h}); | ||
| 88 | defer alloc.free(cmd); | ||
| 89 | return client.attach(alloc, null, cmd); | ||
| 90 | }, | ||
| 91 | .attach => |t| { | ||
| 92 | if (t.via) |cmd| return client.attach(alloc, null, cmd); | ||
| 93 | const sock_path = if (t.sock) |s| | ||
| 94 | try alloc.dupe(u8, s) | ||
| 95 | else if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| | ||
| 96 | try std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir}) | ||
| 97 | else | ||
| 98 | try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()}); | ||
| 99 | defer alloc.free(sock_path); | ||
| 100 | return client.attach(alloc, sock_path, null); | ||
| 101 | }, | ||
| 31 | } | 102 | } |
| 103 | } | ||
| 32 | 104 | ||
| 33 | // Two transports, one session: naming both is a request we cannot honour | 105 | /// Test helper: parseArgs takes what argsAlloc produces, so the tests have to |
| 34 | // rather than one to reconcile. | 106 | /// speak the same type — a slice of sentinel-terminated strings. |
| 35 | if (sock_arg != null and via_arg != null) { | 107 | fn parse(comptime argv: []const [:0]const u8) ParseResult { |
| 36 | std.debug.print("mux: --sock and --via are mutually exclusive\n{s}", .{usage}); | 108 | return parseArgs(argv); |
| 37 | return 2; | 109 | } |
| 38 | } | 110 | |
| 39 | if (via_arg) |cmd| return client.attach(alloc, null, cmd); | 111 | test "parseArgs: no arguments means the default local socket" { |
| 112 | const r = parse(&.{"mux"}); | ||
| 113 | try std.testing.expect(r == .attach); | ||
| 114 | try std.testing.expect(r.attach.sock == null); | ||
| 115 | try std.testing.expect(r.attach.via == null); | ||
| 116 | } | ||
| 117 | |||
| 118 | test "parseArgs: --sock and --via each name their transport" { | ||
| 119 | const s = parse(&.{ "mux", "--sock", "/tmp/x.sock" }); | ||
| 120 | try std.testing.expect(s == .attach); | ||
| 121 | try std.testing.expectEqualStrings("/tmp/x.sock", s.attach.sock.?); | ||
| 122 | try std.testing.expect(s.attach.via == null); | ||
| 123 | |||
| 124 | const v = parse(&.{ "mux", "--via", "ssh box muxd proxy" }); | ||
| 125 | try std.testing.expect(v == .attach); | ||
| 126 | try std.testing.expectEqualStrings("ssh box muxd proxy", v.attach.via.?); | ||
| 127 | try std.testing.expect(v.attach.sock == null); | ||
| 128 | } | ||
| 40 | 129 | ||
| 41 | const sock_path = if (sock_arg) |s| | 130 | test "parseArgs: a bare word is a host to hop to" { |
| 42 | try alloc.dupe(u8, s) | 131 | const h = parse(&.{ "mux", "vm1" }); |
| 43 | else if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| | 132 | try std.testing.expect(h == .host); |
| 44 | try std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir}) | 133 | try std.testing.expectEqualStrings("vm1", h.host); |
| 45 | else | 134 | |
| 46 | try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()}); | 135 | // The user@host form is just as bare a word; nothing parses inside it, |
| 47 | defer alloc.free(sock_path); | 136 | // which is what lets ssh's own config (aliases, ports, ProxyJump) keep |
| 137 | // working untouched. | ||
| 138 | const u = parse(&.{ "mux", "ubuntu@sandbox-9b70e9" }); | ||
| 139 | try std.testing.expect(u == .host); | ||
| 140 | try std.testing.expectEqualStrings("ubuntu@sandbox-9b70e9", u.host); | ||
| 141 | } | ||
| 142 | |||
| 143 | test "parseArgs: naming two transports is a conflict, however it is spelled" { | ||
| 144 | try std.testing.expect(parse(&.{ "mux", "vm1", "--sock", "/tmp/x.sock" }) == .conflict); | ||
| 145 | try std.testing.expect(parse(&.{ "mux", "--sock", "/tmp/x.sock", "vm1" }) == .conflict); | ||
| 146 | try std.testing.expect(parse(&.{ "mux", "vm1", "--via", "ssh box muxd proxy" }) == .conflict); | ||
| 147 | try std.testing.expect(parse(&.{ "mux", "--sock", "/a", "--via", "c" }) == .conflict); | ||
| 148 | // Two of the same kind is the same ambiguity as two different kinds. | ||
| 149 | try std.testing.expect(parse(&.{ "mux", "vm1", "vm2" }) == .conflict); | ||
| 150 | try std.testing.expect(parse(&.{ "mux", "--sock", "/a", "--sock", "/b" }) == .conflict); | ||
| 151 | } | ||
| 48 | 152 | ||
| 49 | return client.attach(alloc, sock_path, null); | 153 | test "parseArgs: unknown flags and valueless flags are usage errors" { |
| 154 | try std.testing.expect(parse(&.{ "mux", "--wat" }) == .usage_error); | ||
| 155 | try std.testing.expect(parse(&.{ "mux", "-x" }) == .usage_error); | ||
| 156 | // A flag whose value is missing must not be mistaken for a bare host. | ||
| 157 | try std.testing.expect(parse(&.{ "mux", "--sock" }) == .usage_error); | ||
| 158 | try std.testing.expect(parse(&.{ "mux", "--via" }) == .usage_error); | ||
| 50 | } | 159 | } |