a73x

01a27648

feat: port 4433 is the default on both ends of quic://

a73x   2026-08-09 12:29

Commit message
feat: port 4433 is the default on both ends of quic://

docs/superpowers/plans/2026-08-09-m10-quic-ergonomics.md
Old New
@@ -528,7 +528,7 @@ git commit -m "feat: muxd keygen — one command replaces the /dev/urandom incan
528 - Modify: `src/client.zig:329-351` (`parseQuicAddr`) + new test 528 - Modify: `src/client.zig:329-351` (`parseQuicAddr`) + new test
529 - Modify: `src/mux_main.zig` usage; `test/e2e.sh:384` 529 - Modify: `src/mux_main.zig` usage; `test/e2e.sh:384`
530 530
531 - [ ] **Step 1: Write the failing tests** 531 - [x] **Step 1: Write the failing tests**
532 532
533 `src/main.zig`, extend the existing `splitHostPort` test — **replace** the two lines currently expecting errors for portless forms (~460 and ~464): 533 `src/main.zig`, extend the existing `splitHostPort` test — **replace** the two lines currently expecting errors for portless forms (~460 and ~464):
534 534
@@ -568,12 +568,12 @@ test "parseQuicAddr: no port means 4433, explicit port wins" {
568 } 568 }
569 ``` 569 ```
570 570
571 - [ ] **Step 2: Run to verify both fail** 571 - [x] **Step 2: Run to verify both fail**
572 572
573 Run: `make test` 573 Run: `make test`
574 Expected: compile error (`default_port` not found) — the failing state. 574 Expected: compile error (`default_port` not found) — the failing state.
575 575
576 - [ ] **Step 3: Implement** 576 - [x] **Step 3: Implement**
577 577
578 `src/quic_server.zig`, near the other top-level pub decls: 578 `src/quic_server.zig`, near the other top-level pub decls:
579 579
@@ -648,7 +648,7 @@ with the existing with-port path ending in `return resolveHost(host, port);` aft
648 648
649 `src/mux_main.zig` usage line: `quic://HOST:PORT` → `quic://HOST[:PORT] (PORT defaults to 4433)`. Same in `src/main.zig` usage: `--quic HOST[:PORT]`. 649 `src/mux_main.zig` usage line: `quic://HOST:PORT` → `quic://HOST[:PORT] (PORT defaults to 4433)`. Same in `src/main.zig` usage: `--quic HOST[:PORT]`.
650 650
651 - [ ] **Step 4: Fix the e2e refusal that just became valid** 651 - [x] **Step 4: Fix the e2e refusal that just became valid**
652 652
653 `test/e2e.sh:384` currently expects a portless `--quic` to be refused: 653 `test/e2e.sh:384` currently expects a portless `--quic` to be refused:
654 654
@@ -662,16 +662,16 @@ A portless address is now valid (defaults 4433) — but this invocation still ex
662 refuse 1 --quic "127.0.0.1:" --key "$QKEY" 662 refuse 1 --quic "127.0.0.1:" --key "$QKEY"
663 ``` 663 ```
664 664
665 - [ ] **Step 5: Run tests, expect pass** 665 - [x] **Step 5: Run tests, expect pass**
666 666
667 Run: `make test && make build && make e2e` 667 Run: `make test && make build && make e2e`
668 Expected: pass. 668 Expected: pass.
669 669
670 - [ ] **Step 6: Mutation check (port default)** 670 - [x] **Step 6: Mutation check (port default)**
671 671
672 Change `default_port` to `4434` in quic_server.zig. Run `make test`: BOTH new tests (main.zig and client.zig) must fail — if only one fails, the other is asserting through a stale constant; find out why before restoring. Restore to 4433. 672 Change `default_port` to `4434` in quic_server.zig. Run `make test`: BOTH new tests (main.zig and client.zig) must fail — if only one fails, the other is asserting through a stale constant; find out why before restoring. Restore to 4433.
673 673
674 - [ ] **Step 7: Commit** 674 - [x] **Step 7: Commit**
675 675
676 ```bash 676 ```bash
677 git add src/quic_server.zig src/quic_client.zig src/main.zig src/client.zig src/mux_main.zig test/e2e.sh 677 git add src/quic_server.zig src/quic_client.zig src/main.zig src/client.zig src/mux_main.zig test/e2e.sh
src/client.zig
Old New
@@ -327,8 +327,12 @@ fn waitReady(
327 /// side because a bind address that resolves to several is a question; here 327 /// side because a bind address that resolves to several is a question; here
328 /// a NAME is exactly what a user types, so this one does resolve. 328 /// a NAME is exactly what a user types, so this one does resolve.
329 fn parseQuicAddr(host_port: []const u8) !std.net.Address { 329 fn parseQuicAddr(host_port: []const u8) !std.net.Address {
330 // `[::1]` — bracketed, portless: the brackets say where the address
331 // stops, so the port can default.
332 if (host_port.len >= 2 and host_port[0] == '[' and host_port[host_port.len - 1] == ']')
333 return resolveHost(host_port[1 .. host_port.len - 1], quic_client.default_port);
330 const colon = std.mem.lastIndexOfScalar(u8, host_port, ':') orelse 334 const colon = std.mem.lastIndexOfScalar(u8, host_port, ':') orelse
331 return error.MalformedAddress; 335 return resolveHost(host_port, quic_client.default_port);
332 var host = host_port[0..colon]; 336 var host = host_port[0..colon];
333 const port_s = host_port[colon + 1 ..]; 337 const port_s = host_port[colon + 1 ..];
334 // `[::1]:4433` — brackets are how an IPv6 literal says where it stops. 338 // `[::1]:4433` — brackets are how an IPv6 literal says where it stops.
@@ -340,8 +344,14 @@ fn parseQuicAddr(host_port: []const u8) !std.net.Address {
340 // port. Refused rather than guessed at. 344 // port. Refused rather than guessed at.
341 return error.MalformedAddress; 345 return error.MalformedAddress;
342 } 346 }
343 if (host.len == 0) return error.MalformedAddress;
344 const port = std.fmt.parseInt(u16, port_s, 10) catch return error.MalformedAddress; 347 const port = std.fmt.parseInt(u16, port_s, 10) catch return error.MalformedAddress;
348 return resolveHost(host, port);
349 }
350
351 /// A host that is already known to be unambiguous, plus the port it goes
352 /// with: literal if it parses as one, resolved if it does not.
353 fn resolveHost(host: []const u8, port: u16) !std.net.Address {
354 if (host.len == 0) return error.MalformedAddress;
345 if (std.net.Address.parseIp(host, port)) |addr| return addr else |_| {} 355 if (std.net.Address.parseIp(host, port)) |addr| return addr else |_| {}
346 // Not a literal: resolve it. A remote host is normally a name. 356 // Not a literal: resolve it. A remote host is normally a name.
347 const list = try std.net.getAddressList(std.heap.page_allocator, host, port); 357 const list = try std.net.getAddressList(std.heap.page_allocator, host, port);
@@ -1808,3 +1818,18 @@ test "paintDeltaClipped skips rows beyond the tty and clamps the cursor" {
1808 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "does-not-fit") == null); 1818 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "does-not-fit") == null);
1809 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[24;80H") != null); // clamped 1819 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[24;80H") != null); // clamped
1810 } 1820 }
1821
1822 test "parseQuicAddr: no port means 4433, explicit port wins" {
1823 // 4433 spelled out, not `quic_client.default_port`: asserting against
1824 // the constant the code under test reads would hold for any value, so
1825 // it could never catch the number changing — and this is precisely the
1826 // number the daemon must agree with.
1827 const d = try parseQuicAddr("127.0.0.1");
1828 try std.testing.expectEqual(@as(u16, 4433), d.getPort());
1829 const e = try parseQuicAddr("127.0.0.1:9");
1830 try std.testing.expectEqual(@as(u16, 9), e.getPort());
1831 const b = try parseQuicAddr("[::1]");
1832 try std.testing.expectEqual(@as(u16, 4433), b.getPort());
1833 // Unbracketed IPv6 stays ambiguous and refused, with or without ports.
1834 try std.testing.expectError(error.MalformedAddress, parseQuicAddr("fe80::1:4433"));
1835 }
src/main.zig
Old New
@@ -12,7 +12,7 @@ const xdg = @import("xdg");
12 const usage = 12 const usage =
13 \\usage: 13 \\usage:
14 \\ muxd run [--sock PATH] [--shell PATH] [--cols N] [--rows N] 14 \\ muxd run [--sock PATH] [--shell PATH] [--cols N] [--rows N]
15 \\ [--quic HOST:PORT --key FILE] [--quic-idle-ms N] 15 \\ [--quic HOST[:PORT] --key FILE] [--quic-idle-ms N]
16 \\ muxd dump [--vt] [--sock PATH] 16 \\ muxd dump [--vt] [--sock PATH]
17 \\ muxd stats [--sock PATH] 17 \\ muxd stats [--sock PATH]
18 \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket) 18 \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket)
@@ -159,16 +159,20 @@ fn usageExit(u: Usage) u8 {
159 return 2; 159 return 2;
160 } 160 }
161 161
162 /// `HOST:PORT` where HOST is a literal address — `127.0.0.1:4433`, 162 /// `HOST[:PORT]` where HOST is a literal address — `127.0.0.1:4433`,
163 /// `0.0.0.0:4433`, `[::]:4433`. Deliberately no DNS: this is the address to 163 /// `0.0.0.0:4433`, `[::]:4433`. An omitted port means `quic.default_port`;
164 /// bind, and a name resolving to several is a question, not an answer. 164 /// an empty one (`127.0.0.1:`) is still a mistake and still refused.
165 /// Deliberately no DNS: this is the address to bind, and a name resolving
166 /// to several is a question, not an answer.
165 fn splitHostPort(s: []const u8) !struct { host: []const u8, port: u16 } { 167 fn splitHostPort(s: []const u8) !struct { host: []const u8, port: u16 } {
166 if (s.len > 0 and s[0] == '[') { 168 if (s.len > 0 and s[0] == '[') {
167 const close = std.mem.indexOfScalar(u8, s, ']') orelse return error.MalformedAddress; 169 const close = std.mem.indexOfScalar(u8, s, ']') orelse return error.MalformedAddress;
168 if (close + 1 >= s.len or s[close + 1] != ':') return error.MalformedAddress; 170 if (close + 1 == s.len) return .{ .host = s[1..close], .port = quic.default_port };
171 if (s[close + 1] != ':') return error.MalformedAddress;
169 return .{ .host = s[1..close], .port = try parsePort(s[close + 2 ..]) }; 172 return .{ .host = s[1..close], .port = try parsePort(s[close + 2 ..]) };
170 } 173 }
171 const colon = std.mem.lastIndexOfScalar(u8, s, ':') orelse return error.MalformedAddress; 174 const colon = std.mem.lastIndexOfScalar(u8, s, ':') orelse
175 return .{ .host = s, .port = quic.default_port };
172 // An unbracketed IPv6 literal carries colons of its own, and splitting 176 // An unbracketed IPv6 literal carries colons of its own, and splitting
173 // on the last one would quietly take its final group as a port: 177 // on the last one would quietly take its final group as a port:
174 // `fe80::1:4433` reads equally well as host `fe80::1` port 4433 and as 178 // `fe80::1:4433` reads equally well as host `fe80::1` port 4433 and as
@@ -499,11 +503,25 @@ test "splitHostPort: literal addresses, bracketed and not" {
499 try std.testing.expectEqualStrings("::", any6.host); 503 try std.testing.expectEqualStrings("::", any6.host);
500 try std.testing.expectEqual(@as(u16, 1), any6.port); 504 try std.testing.expectEqual(@as(u16, 1), any6.port);
501 505
502 try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1")); 506 // No port names the default. 4433 is mux's convention; an explicit
507 // port always wins.
508 //
509 // The number is spelled out rather than written `quic.default_port`:
510 // comparing the parse's answer against the same constant the parse
511 // reads holds for ANY value, so it would pin the wiring and say
512 // nothing about the port — and 4433 is the half both ends of a
513 // connection have to agree on.
514 const dflt = try splitHostPort("127.0.0.1");
515 try std.testing.expectEqualStrings("127.0.0.1", dflt.host);
516 try std.testing.expectEqual(@as(u16, 4433), dflt.port);
517
518 const dflt6 = try splitHostPort("[::1]");
519 try std.testing.expectEqualStrings("::1", dflt6.host);
520 try std.testing.expectEqual(@as(u16, 4433), dflt6.port);
521
503 try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:")); 522 try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:"));
504 try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:99999")); 523 try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:99999"));
505 try std.testing.expectError(error.MalformedAddress, splitHostPort("[::1]4433")); 524 try std.testing.expectError(error.MalformedAddress, splitHostPort("[::1]4433"));
506 try std.testing.expectError(error.MalformedAddress, splitHostPort("[::1]"));
507 525
508 // An IPv6 literal without brackets is ambiguous about where the address 526 // An IPv6 literal without brackets is ambiguous about where the address
509 // stops, so it is refused instead of being read either way. 527 // stops, so it is refused instead of being read either way.
src/mux_main.zig
Old New
@@ -7,10 +7,10 @@ const client = @import("client");
7 const build_options = @import("build_options"); 7 const build_options = @import("build_options");
8 8
9 const usage = 9 const usage =
10 \\usage: mux [HOST | --sock PATH | --via CMD | quic://HOST:PORT] 10 \\usage: mux [HOST | --sock PATH | --via CMD | quic://HOST[:PORT]]
11 \\ HOST attaches over "ssh HOST muxd proxy" (muxd must be on HOST's PATH) 11 \\ HOST attaches over "ssh HOST muxd proxy" (muxd must be on HOST's PATH)
12 \\ quic://HOST:PORT needs --key FILE (or MUX_KEY_FILE); muxd must be 12 \\ quic://HOST[:PORT] (PORT defaults to 4433) needs --key FILE (or
13 \\ running with a matching --quic and --key 13 \\ MUX_KEY_FILE); muxd must be running with a matching --quic and --key
14 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed 14 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed
15 \\ --version prints the version 15 \\ --version prints the version
16 \\ 16 \\
src/quic_client.zig
Old New
@@ -31,6 +31,7 @@ const c = quic.c;
31 31
32 pub const Key = quic.Key; 32 pub const Key = quic.Key;
33 pub const key_len = quic.key_len; 33 pub const key_len = quic.key_len;
34 pub const default_port = quic.default_port;
34 35
35 /// wolfSSL's PSK callback carries no user pointer, so the key has to be 36 /// wolfSSL's PSK callback carries no user pointer, so the key has to be
36 /// reachable without one. A client process runs one connection at a time, 37 /// reachable without one. A client process runs one connection at a time,
src/quic_server.zig
Old New
@@ -27,6 +27,11 @@ pub const c = @cImport({
27 27
28 pub const key_len = 32; 28 pub const key_len = 32;
29 29
30 /// mux's conventional QUIC port. Both parsers reach for it when the user
31 /// names no port; it lives here because this module is the one thing both
32 /// binaries already import.
33 pub const default_port: u16 = 4433;
34
30 /// The pre-shared key, and the rules for getting one off disk. 35 /// The pre-shared key, and the rules for getting one off disk.
31 /// 36 ///
32 /// A key file is exactly as sensitive as an ssh private key, so it is held 37 /// A key file is exactly as sensitive as an ssh private key, so it is held
test/e2e.sh
Old New
@@ -424,7 +424,7 @@ refuse 2 --key "$QKEY"
424 refuse 2 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 0 424 refuse 2 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 0
425 refuse 2 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms soon 425 refuse 2 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms soon
426 # Refusals that are about the world rather than the spelling exit 1. 426 # Refusals that are about the world rather than the spelling exit 1.
427 refuse 1 --quic "127.0.0.1" --key "$QKEY" 427 refuse 1 --quic "127.0.0.1:" --key "$QKEY"
428 refuse 1 --quic "localhost:$QPORT" --key "$QKEY" 428 refuse 1 --quic "localhost:$QPORT" --key "$QKEY"
429 refuse 1 --quic "127.0.0.1:$QPORT" --key "$QKEY.bad" 429 refuse 1 --quic "127.0.0.1:$QPORT" --key "$QKEY.bad"
430 refuse 1 --quic "127.0.0.1:$QPORT" --key "$QKEY.missing" 430 refuse 1 --quic "127.0.0.1:$QPORT" --key "$QKEY.missing"