56ca01d6
refactor: quic.zig follow-through — client callbacks dedup, typed pathFrom, honest topology prose
a73x 2026-08-12 18:24
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -191,10 +191,11 @@ pub fn build(b: *std.Build) void { | |||
| 191 | // daemon itself when nobody handed it a --key. | 191 | // daemon itself when nobody handed it a --key. |
| 192 | server_mod.addImport("xdg", xdg_mod); | 192 | server_mod.addImport("xdg", xdg_mod); |
| 193 | 193 | ||
| 194 | // The client's QUIC transport. Imports the listener's module for the | 194 | // The client's QUIC transport. Imports the vocabulary module — NOT the |
| 195 | // pieces both ends must agree on (the key, the egress ring's lifetime | 195 | // listener — for the pieces both ends must agree on (the key, the egress |
| 196 | // discipline, the PSK identity and ALPN) — duplicating those would make | 196 | // ring's lifetime discipline, the PSK identity and ALPN); duplicating |
| 197 | // a handshake failure the first sign they had drifted. | 197 | // those would make a handshake failure the first sign they had drifted. |
| 198 | // It has no business knowing a listener exists, and now it cannot. | ||
| 198 | const quic_client_mod = b.createModule(.{ | 199 | const quic_client_mod = b.createModule(.{ |
| 199 | .root_source_file = b.path("src/quic_client.zig"), | 200 | .root_source_file = b.path("src/quic_client.zig"), |
| 200 | .target = target, | 201 | .target = target, |
src/quic.zig
| Old | New | ||
|---|---|---|---|
| @@ -5,6 +5,13 @@ | |||
| 5 | //! owns the listener, quic_client.zig owns the client transport, and both | 5 | //! owns the listener, quic_client.zig owns the client transport, and both |
| 6 | //! import this — which is what makes "the two ends agree" a fact about one | 6 | //! import this — which is what makes "the two ends agree" a fact about one |
| 7 | //! file rather than a convention two files are trusted to keep. | 7 | //! file rather than a convention two files are trusted to keep. |
| 8 | //! | ||
| 9 | //! THE OWNERSHIP RULE, because it is load-bearing rather than tidy: this | ||
| 10 | //! file holds the one and only @cImport of the QUIC stack. Two blocks over | ||
| 11 | //! the same headers produce two *distinct* Zig types, so an `ngtcp2_vec` | ||
| 12 | //! minted under one would not be an `ngtcp2_vec` to the other — and the | ||
| 13 | //! egress ring hands exactly those across the seam between listener and | ||
| 14 | //! client. A new QUIC file therefore imports this one; it never cImports. | ||
| 8 | const std = @import("std"); | 15 | const std = @import("std"); |
| 9 | 16 | ||
| 10 | /// The C view of the QUIC stack. Exported because the client transport | 17 | /// The C view of the QUIC stack. Exported because the client transport |
| @@ -19,7 +26,9 @@ pub const c = @cImport({ | |||
| 19 | @cInclude("wolfssl/ssl.h"); | 26 | @cInclude("wolfssl/ssl.h"); |
| 20 | }); | 27 | }); |
| 21 | 28 | ||
| 22 | pub const key_len = 32; | 29 | // --------------------------------------------------------------------------- |
| 30 | // Wire constants: the numbers and strings both ends must spell alike | ||
| 31 | // --------------------------------------------------------------------------- | ||
| 23 | 32 | ||
| 24 | /// mux's conventional QUIC port. Both parsers reach for it when the user | 33 | /// mux's conventional QUIC port. Both parsers reach for it when the user |
| 25 | /// names no port; it lives here because this module is the one thing both | 34 | /// names no port; it lives here because this module is the one thing both |
| @@ -38,6 +47,24 @@ pub const default_port: u16 = 4433; | |||
| 38 | /// binaries default to are two numbers, and they drift in silence. | 47 | /// binaries default to are two numbers, and they drift in silence. |
| 39 | pub const default_idle_ms: u32 = 15_000; | 48 | pub const default_idle_ms: u32 = 15_000; |
| 40 | 49 | ||
| 50 | pub const max_udp = 1452; // conservative IPv4 datagram that avoids fragmenting | ||
| 51 | |||
| 52 | pub const psk_identity: [*:0]const u8 = "mux"; | ||
| 53 | pub const psk_ciphersuite: [*:0]const u8 = "TLS13-AES128-GCM-SHA256"; | ||
| 54 | pub const alpn = "\x03mux"; | ||
| 55 | |||
| 56 | /// How many outbound bytes one connection may hold. Sized to the stream | ||
| 57 | /// window the peer advertises, because holding much more than the peer will | ||
| 58 | /// let us send buys nothing: past this the daemon's own `pending_cap` is the | ||
| 59 | /// right place for the backlog to sit and be judged. | ||
| 60 | pub const egress_cap = 256 * 1024; | ||
| 61 | |||
| 62 | // --------------------------------------------------------------------------- | ||
| 63 | // The pre-shared key | ||
| 64 | // --------------------------------------------------------------------------- | ||
| 65 | |||
| 66 | pub const key_len = 32; | ||
| 67 | |||
| 41 | /// The pre-shared key, and the rules for getting one off disk. | 68 | /// The pre-shared key, and the rules for getting one off disk. |
| 42 | /// | 69 | /// |
| 43 | /// A key file is exactly as sensitive as an ssh private key, so it is held | 70 | /// A key file is exactly as sensitive as an ssh private key, so it is held |
| @@ -208,17 +235,9 @@ test "Key.load: refuses a permissive mode, a missing file, and a bad length" { | |||
| 208 | ); | 235 | ); |
| 209 | } | 236 | } |
| 210 | 237 | ||
| 211 | pub const max_udp = 1452; // conservative IPv4 datagram that avoids fragmenting | 238 | // --------------------------------------------------------------------------- |
| 212 | 239 | // Egress: the ring, and what one writev_stream return means for it | |
| 213 | pub const psk_identity: [*:0]const u8 = "mux"; | 240 | // --------------------------------------------------------------------------- |
| 214 | pub const psk_ciphersuite: [*:0]const u8 = "TLS13-AES128-GCM-SHA256"; | ||
| 215 | pub const alpn = "\x03mux"; | ||
| 216 | |||
| 217 | /// How many outbound bytes one connection may hold. Sized to the stream | ||
| 218 | /// window the peer advertises, because holding much more than the peer will | ||
| 219 | /// let us send buys nothing: past this the daemon's own `pending_cap` is the | ||
| 220 | /// right place for the backlog to sit and be judged. | ||
| 221 | pub const egress_cap = 256 * 1024; | ||
| 222 | 241 | ||
| 223 | /// Outbound stream bytes, in a ring that never moves a byte once written. | 242 | /// Outbound stream bytes, in a ring that never moves a byte once written. |
| 224 | /// | 243 | /// |
| @@ -422,6 +441,10 @@ test "Egress: an ack against a torn-down ring is ignored, not a division by zero | |||
| 422 | try std.testing.expectEqual(@as(usize, 0), e.held); | 441 | try std.testing.expectEqual(@as(usize, 0), e.held); |
| 423 | } | 442 | } |
| 424 | 443 | ||
| 444 | // --------------------------------------------------------------------------- | ||
| 445 | // Time, and the keepalive derived from it | ||
| 446 | // --------------------------------------------------------------------------- | ||
| 447 | |||
| 425 | pub fn timestampNs() u64 { | 448 | pub fn timestampNs() u64 { |
| 426 | const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch return 0; | 449 | const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch return 0; |
| 427 | return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec)); | 450 | return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec)); |
| @@ -444,6 +467,10 @@ test "keepAlive: a third of the idle timeout, and never disabled" { | |||
| 444 | try std.testing.expectEqual(@as(u64, 1_000_000), keepAliveNs(2)); | 467 | try std.testing.expectEqual(@as(u64, 1_000_000), keepAliveNs(2)); |
| 445 | } | 468 | } |
| 446 | 469 | ||
| 470 | // --------------------------------------------------------------------------- | ||
| 471 | // ngtcp2 callbacks with no per-endpoint variation | ||
| 472 | // --------------------------------------------------------------------------- | ||
| 473 | |||
| 447 | pub fn randCb(dest: [*c]u8, destlen: usize, _: [*c]const c.ngtcp2_rand_ctx) callconv(.c) void { | 474 | pub fn randCb(dest: [*c]u8, destlen: usize, _: [*c]const c.ngtcp2_rand_ctx) callconv(.c) void { |
| 448 | std.crypto.random.bytes(dest[0..destlen]); | 475 | std.crypto.random.bytes(dest[0..destlen]); |
| 449 | } | 476 | } |
| @@ -461,9 +488,18 @@ pub fn getNewCidCb( | |||
| 461 | return 0; | 488 | return 0; |
| 462 | } | 489 | } |
| 463 | 490 | ||
| 491 | // --------------------------------------------------------------------------- | ||
| 492 | // Paths | ||
| 493 | // --------------------------------------------------------------------------- | ||
| 494 | |||
| 464 | /// The ngtcp2_path literal, spelled once. Six call sites built this | 495 | /// The ngtcp2_path literal, spelled once. Six call sites built this |
| 465 | /// by hand and the seventh would have drifted. | 496 | /// by hand and the seventh would have drifted. |
| 466 | pub fn pathFrom(local: anytype, local_len: c.socklen_t, remote: anytype, remote_len: c.socklen_t) c.ngtcp2_path { | 497 | pub fn pathFrom( |
| 498 | local: *std.posix.sockaddr.storage, | ||
| 499 | local_len: std.posix.socklen_t, | ||
| 500 | remote: *std.posix.sockaddr.storage, | ||
| 501 | remote_len: std.posix.socklen_t, | ||
| 502 | ) c.ngtcp2_path { | ||
| 467 | return .{ | 503 | return .{ |
| 468 | .local = .{ .addr = @ptrCast(local), .addrlen = local_len }, | 504 | .local = .{ .addr = @ptrCast(local), .addrlen = local_len }, |
| 469 | .remote = .{ .addr = @ptrCast(remote), .addrlen = remote_len }, | 505 | .remote = .{ .addr = @ptrCast(remote), .addrlen = remote_len }, |
src/quic_client.zig
| Old | New | ||
|---|---|---|---|
| @@ -7,8 +7,13 @@ | |||
| 7 | //! keeps "the transport is a swap" a checkable claim rather than a slogan, | 7 | //! keeps "the transport is a swap" a checkable claim rather than a slogan, |
| 8 | //! and it is why there is no `proto` import here. | 8 | //! and it is why there is no `proto` import here. |
| 9 | //! | 9 | //! |
| 10 | //! Three things in here are carried over from the daemon's side rather than | 10 | //! The pieces both ends must agree on are IMPORTED from quic.zig, not |
| 11 | //! rediscovered, because they were paid for once already: | 11 | //! reimplemented here: the key, the wire constants, the `Egress` ring, the |
| 12 | //! write accounting, the clock. That is why a drift between the two sides | ||
| 13 | //! is not a thing that can happen quietly — there is only one copy to drift. | ||
| 14 | //! | ||
| 15 | //! Three of those imports carry hard-won reasons with them, restated here | ||
| 16 | //! because the cost of relearning them is measured in freezes: | ||
| 12 | //! | 17 | //! |
| 13 | //! 1. **ngtcp2 does not copy stream payload.** It keeps the vector it is | 18 | //! 1. **ngtcp2 does not copy stream payload.** It keeps the vector it is |
| 14 | //! handed and re-reads those bytes to retransmit, so outbound bytes | 19 | //! handed and re-reads those bytes to retransmit, so outbound bytes |
| @@ -24,9 +29,9 @@ | |||
| 24 | const std = @import("std"); | 29 | const std = @import("std"); |
| 25 | const quic = @import("quic"); | 30 | const quic = @import("quic"); |
| 26 | 31 | ||
| 27 | /// Shared with the listener rather than imported again: two @cImport blocks | 32 | /// quic.zig's cImport, and deliberately not one of our own: two @cImport |
| 28 | /// over the same headers are two distinct type universes, and the egress | 33 | /// blocks over the same headers are two distinct type universes, and the |
| 29 | /// ring hands `ngtcp2_vec`s across this boundary. | 34 | /// egress ring hands `ngtcp2_vec`s across this boundary. |
| 30 | const c = quic.c; | 35 | const c = quic.c; |
| 31 | 36 | ||
| 32 | pub const Key = quic.Key; | 37 | pub const Key = quic.Key; |
| @@ -61,23 +66,6 @@ fn getConnCb(ref: [*c]c.ngtcp2_crypto_conn_ref) callconv(.c) ?*c.ngtcp2_conn { | |||
| 61 | return self.conn; | 66 | return self.conn; |
| 62 | } | 67 | } |
| 63 | 68 | ||
| 64 | fn randCb(dest: [*c]u8, destlen: usize, _: [*c]const c.ngtcp2_rand_ctx) callconv(.c) void { | ||
| 65 | std.crypto.random.bytes(dest[0..destlen]); | ||
| 66 | } | ||
| 67 | |||
| 68 | fn getNewCidCb( | ||
| 69 | _: ?*c.ngtcp2_conn, | ||
| 70 | cid: [*c]c.ngtcp2_cid, | ||
| 71 | token: [*c]c.ngtcp2_stateless_reset_token, | ||
| 72 | cidlen: usize, | ||
| 73 | _: ?*anyopaque, | ||
| 74 | ) callconv(.c) c_int { | ||
| 75 | std.crypto.random.bytes(cid.*.data[0..cidlen]); | ||
| 76 | cid.*.datalen = cidlen; | ||
| 77 | std.crypto.random.bytes(&token.*.data); | ||
| 78 | return 0; | ||
| 79 | } | ||
| 80 | |||
| 81 | fn handshakeCompletedCb(_: ?*c.ngtcp2_conn, ud: ?*anyopaque) callconv(.c) c_int { | 69 | fn handshakeCompletedCb(_: ?*c.ngtcp2_conn, ud: ?*anyopaque) callconv(.c) c_int { |
| 82 | const self: *Client = @ptrCast(@alignCast(ud.?)); | 70 | const self: *Client = @ptrCast(@alignCast(ud.?)); |
| 83 | self.handshake_done = true; | 71 | self.handshake_done = true; |
| @@ -252,8 +240,8 @@ pub const Client = struct { | |||
| 252 | cbs.delete_crypto_cipher_ctx = c.ngtcp2_crypto_delete_crypto_cipher_ctx_cb; | 240 | cbs.delete_crypto_cipher_ctx = c.ngtcp2_crypto_delete_crypto_cipher_ctx_cb; |
| 253 | cbs.get_path_challenge_data = c.ngtcp2_crypto_get_path_challenge_data_cb; | 241 | cbs.get_path_challenge_data = c.ngtcp2_crypto_get_path_challenge_data_cb; |
| 254 | cbs.version_negotiation = c.ngtcp2_crypto_version_negotiation_cb; | 242 | cbs.version_negotiation = c.ngtcp2_crypto_version_negotiation_cb; |
| 255 | cbs.rand = randCb; | 243 | cbs.rand = quic.randCb; |
| 256 | cbs.get_new_connection_id2 = getNewCidCb; | 244 | cbs.get_new_connection_id2 = quic.getNewCidCb; |
| 257 | cbs.handshake_completed = handshakeCompletedCb; | 245 | cbs.handshake_completed = handshakeCompletedCb; |
| 258 | cbs.extend_max_local_streams_bidi = extendStreamsCb; | 246 | cbs.extend_max_local_streams_bidi = extendStreamsCb; |
| 259 | cbs.recv_stream_data = recvStreamDataCb; | 247 | cbs.recv_stream_data = recvStreamDataCb; |
src/quic_server.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,12 @@ | |||
| 1 | //! muxd's QUIC listener, and only the listener: one UDP socket, N | 1 | //! muxd's QUIC listener: one UDP socket, N authenticated connections, each |
| 2 | //! authenticated connections, each carrying exactly one bidirectional stream | 2 | //! carrying exactly one bidirectional stream of opaque bytes. The vocabulary |
| 3 | //! of opaque bytes. The vocabulary both ends share — the C import, the key, | 3 | //! both ends share — the C import, the key, the egress ring — is quic.zig's, |
| 4 | //! the egress ring — is quic.zig's, and this file imports it. | 4 | //! and this file imports it. |
| 5 | //! | ||
| 6 | //! Two things live here, not one. Below the test banner further down sits | ||
| 7 | //! `TestClient`, a real QUIC peer the daemon's own tests drive; it is | ||
| 8 | //! exported for server.zig's benefit and is scaffolding, not a transport. | ||
| 9 | //! The shipping client is quic_client.zig. | ||
| 5 | //! | 10 | //! |
| 6 | //! This file follows proxy.zig's discipline and for the same reason: it | 11 | //! This file follows proxy.zig's discipline and for the same reason: it |
| 7 | //! knows NOTHING about the frame protocol it carries. It moves bytes | 12 | //! knows NOTHING about the frame protocol it carries. It moves bytes |
| @@ -942,9 +947,10 @@ pub const Listener = struct { | |||
| 942 | // --------------------------------------------------------------------------- | 947 | // --------------------------------------------------------------------------- |
| 943 | // Tests: a real handshake against a real client, in one process. | 948 | // Tests: a real handshake against a real client, in one process. |
| 944 | // | 949 | // |
| 945 | // The client below is test scaffolding, not a preview of Task 3's transport. | 950 | // The client below is test scaffolding, and not a stand-in for the real |
| 946 | // It exists because the listener cannot be tested by inspection: a QUIC | 951 | // one: quic_client.zig is the shipping transport. It exists because the |
| 947 | // handshake either completes against a real peer or it does not. | 952 | // listener cannot be tested by inspection — a QUIC handshake either |
| 953 | // completes against a real peer or it does not. | ||
| 948 | // --------------------------------------------------------------------------- | 954 | // --------------------------------------------------------------------------- |
| 949 | 955 | ||
| 950 | var g_client_key: quic.Key = undefined; | 956 | var g_client_key: quic.Key = undefined; |
| @@ -968,8 +974,8 @@ fn pskClientCb( | |||
| 968 | } | 974 | } |
| 969 | 975 | ||
| 970 | /// Test scaffolding, exported so the daemon's own tests can drive a real | 976 | /// Test scaffolding, exported so the daemon's own tests can drive a real |
| 971 | /// QUIC peer. Not a preview of Task 3's client transport: no reconnect, no | 977 | /// QUIC peer. Not the client transport — that is quic_client.zig — and |
| 972 | /// resumption, and a fixed receive buffer. | 978 | /// deliberately less: no reconnect, no resumption, a fixed receive buffer. |
| 973 | pub const TestClient = struct { | 979 | pub const TestClient = struct { |
| 974 | fd: std.posix.fd_t, | 980 | fd: std.posix.fd_t, |
| 975 | ssl_ctx: ?*c.WOLFSSL_CTX, | 981 | ssl_ctx: ?*c.WOLFSSL_CTX, |