01a27648
feat: port 4433 is the default on both ends of quic://
a73x 2026-08-09 12:29
Commit message
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" |