a73x

1e3ab018

feat: mux HOST attaches over ssh — the VM hop is one word

a73x   2026-08-08 14:08

Commit message
feat: mux HOST attaches over ssh — the VM hop is one word

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 }