a73x

1fc273e8

refactor: key refusals have one owner and a pin; dump/stats/logHint dedup

a73x   2026-08-12 19:41

Commit message
refactor: key refusals have one owner and a pin; dump/stats/logHint dedup

The three key-refusal sentences existed as four literal copies — muxd
run, muxd endpoint, the daemon's endpoint_req, and the client's dial —
held in sync by prose comments in each. quic.zig's `keyRefusalBody` now
owns the words; each site owns only its prefix and, for the announce,
its `; staying on ssh`. One literal test in quic.zig pins all four
sentences, so a change to any refusal is a change all four sites see.

The drift the comments did not catch: the catch-all. main.zig's
`reportKeyRefusal` said `cannot read {s}: {s}` and server.zig's
`endpointPortFrom` said `cannot load key {s}: {s}` — the same class of
failure under two verbs. `cannot read` is canonical; server.zig's
endpoint_req line is the one byte-change here, and nothing pinned it.

The three core sentences are byte-identical everywhere, including all
twelve of client.zig's `openFailure` pins, which pass unchanged.

Classification is preserved rather than unified away, because two sites
genuinely differ past the three:

  * `muxd run` propagates an unclassified load error instead of
    printing — it is a foreground start that may fail, while the
    announce paths must stay on ssh and so must speak. It routes only
    the three through the body.
  * the client's `else` covers a failed dial, not a failed read, and
    keeps naming the endpoint. It too routes only the three.

Also in main.zig: `dump` and `stats` were the same round-trip written
twice, now `oneShotQuery` with the verb, request and reply type handed
in (`askEndpointPort` stays its own shape — it polls under a deadline
for a daemon too old to answer at all), with the unit test that
round-trip never had; and the detached-log clause, written twice under
a comment admitting it, is `logHint`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/client.zig
Old New
@@ -765,21 +765,23 @@ fn openFailure(buf: []u8, target: Target, err: anyerror) OpenFailure {
765 // A key the daemon would also have refused, said in the same 765 // A key the daemon would also have refused, said in the same
766 // words, because the user's mistake is the same one. 766 // words, because the user's mistake is the same one.
767 .quic => |q| switch (err) { 767 .quic => |q| switch (err) {
768 error.KeyFileMissing => failedMsg( 768 // Only the three key classes go through the shared body. The
769 buf, 769 // `else` below is NOT its catch-all and must not become it:
770 "mux: no such key file: {s}\n", 770 // down here an unclassified error is far more often a dial that
771 .{q.key_path}, 771 // failed than a file that would not read, so it names the
772 ), 772 // endpoint. The daemon-side callers, whose errors can only have
773 error.KeyFilePermissive => failedMsg( 773 // come from the load, use the body's fourth sentence instead.
774 buf, 774 error.KeyFileMissing,
775 "mux: {s} is readable by group or other; chmod 600 it\n", 775 error.KeyFilePermissive,
776 .{q.key_path}, 776 error.KeyFileMalformed,
777 ), 777 => blk: {
778 error.KeyFileMalformed => failedMsg( 778 var body: [quic_client.key_refusal_len]u8 = undefined;
779 buf, 779 break :blk failedMsg(
780 "mux: {s} is not a key: want 32 raw bytes or 64 hex characters\n", 780 buf,
781 .{q.key_path}, 781 "mux: {s}\n",
782 ), 782 .{quic_client.keyRefusalBody(&body, err, q.key_path)},
783 );
784 },
783 error.MalformedAddress, error.UnknownHostName => failedMsg( 785 error.MalformedAddress, error.UnknownHostName => failedMsg(
784 buf, 786 buf,
785 "mux: cannot resolve quic://{s}\n", 787 "mux: cannot resolve quic://{s}\n",
src/main.zig
Old New
@@ -316,22 +316,21 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
316 return 2; 316 return 2;
317 }; 317 };
318 quic_key = quic.Key.load(key_path) catch |err| switch (err) { 318 quic_key = quic.Key.load(key_path) catch |err| switch (err) {
319 error.KeyFileMissing => { 319 // The three the user can act on, in quic.zig's words — the one
320 std.debug.print("muxd: no such key file: {s}\n", .{key_path}); 320 // owner of them, because `muxd endpoint`, the daemon's
321 return 1; 321 // `endpoint_req` and the client print the same sentences.
322 }, 322 //
323 error.KeyFilePermissive => { 323 // Anything else still propagates rather than being flattened
324 std.debug.print( 324 // into a refusal line: `run` is a foreground start that may
325 "muxd: {s} is readable by group or other; chmod 600 it\n", 325 // fail, so the error goes up. The announce paths cannot do
326 .{key_path}, 326 // that — they must stay on ssh — which is why their catch-all
327 ); 327 // prints the body's fourth sentence and this one does not.
328 return 1; 328 error.KeyFileMissing,
329 }, 329 error.KeyFilePermissive,
330 error.KeyFileMalformed => { 330 error.KeyFileMalformed,
331 std.debug.print( 331 => {
332 "muxd: {s} is not a key: want 32 raw bytes or 64 hex characters\n", 332 var buf: [quic.key_refusal_len]u8 = undefined;
333 .{key_path}, 333 std.debug.print("muxd: {s}\n", .{quic.keyRefusalBody(&buf, err, key_path)});
334 );
335 return 1; 334 return 1;
336 }, 335 },
337 else => return err, 336 else => return err,
@@ -423,20 +422,41 @@ pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
423 return std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()}); 422 return std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()});
424 } 423 }
425 424
426 fn dump(alloc: std.mem.Allocator, sock_path: []const u8, vt_mode: bool) !u8 { 425 /// Ask once, print the first reply of the type asked for, exit. `dump` and
426 /// `stats` are this same round-trip and differed only in the verb they
427 /// name, the frame they send and the frame they wait for.
428 ///
429 /// Frames of other types are skipped rather than refused: the reply is the
430 /// answer to THIS request, and a daemon is free to have said something
431 /// else on the way to it.
432 ///
433 /// `askEndpointPort` below is deliberately not folded in here. It looks
434 /// like the same shape and is not: it waits under a deadline, because the
435 /// daemon it asks may be a binary from before `endpoint_req` existed and
436 /// answer nothing at all. This one has no such case — a daemon that
437 /// understands the socket understands both verbs — so it blocks on the
438 /// read and lets a wedged daemon be seen as a wedged daemon.
439 fn oneShotQuery(
440 alloc: std.mem.Allocator,
441 sock_path: []const u8,
442 verb: []const u8,
443 req: proto.MsgType,
444 req_payload: []const u8,
445 want: proto.MsgType,
446 ) !u8 {
427 const stream = std.net.connectUnixSocket(sock_path) catch { 447 const stream = std.net.connectUnixSocket(sock_path) catch {
428 std.debug.print( 448 std.debug.print(
429 "muxd dump: nothing listening on {s} (`muxd start` starts a daemon)\n", 449 "muxd {s}: nothing listening on {s} (`muxd start` starts a daemon)\n",
430 .{sock_path}, 450 .{ verb, sock_path },
431 ); 451 );
432 return 1; 452 return 1;
433 }; 453 };
434 defer stream.close(); 454 defer stream.close();
435 455
436 try proto.writeFrame(stream.handle, .debug_dump, &.{if (vt_mode) @as(u8, 1) else 0}); 456 try proto.writeFrame(stream.handle, req, req_payload);
437 while (try proto.readFrame(alloc, stream.handle)) |frame| { 457 while (try proto.readFrame(alloc, stream.handle)) |frame| {
438 defer frame.deinit(alloc); 458 defer frame.deinit(alloc);
439 if (frame.type != .dump_reply) continue; 459 if (frame.type != want) continue;
440 try proto.writeAllFd(std.posix.STDOUT_FILENO, frame.payload); 460 try proto.writeAllFd(std.posix.STDOUT_FILENO, frame.payload);
441 try proto.writeAllFd(std.posix.STDOUT_FILENO, "\n"); 461 try proto.writeAllFd(std.posix.STDOUT_FILENO, "\n");
442 return 0; 462 return 0;
@@ -444,25 +464,13 @@ fn dump(alloc: std.mem.Allocator, sock_path: []const u8, vt_mode: bool) !u8 {
444 return 1; 464 return 1;
445 } 465 }
446 466
447 fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { 467 fn dump(alloc: std.mem.Allocator, sock_path: []const u8, vt_mode: bool) !u8 {
448 const stream = std.net.connectUnixSocket(sock_path) catch { 468 const payload = [_]u8{if (vt_mode) 1 else 0};
449 std.debug.print( 469 return oneShotQuery(alloc, sock_path, "dump", .debug_dump, &payload, .dump_reply);
450 "muxd stats: nothing listening on {s} (`muxd start` starts a daemon)\n", 470 }
451 .{sock_path},
452 );
453 return 1;
454 };
455 defer stream.close();
456 471
457 try proto.writeFrame(stream.handle, .stats_req, ""); 472 fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
458 while (try proto.readFrame(alloc, stream.handle)) |frame| { 473 return oneShotQuery(alloc, sock_path, "stats", .stats_req, "", .stats_reply);
459 defer frame.deinit(alloc);
460 if (frame.type != .stats_reply) continue;
461 try proto.writeAllFd(std.posix.STDOUT_FILENO, frame.payload);
462 try proto.writeAllFd(std.posix.STDOUT_FILENO, "\n");
463 return 0;
464 }
465 return 1;
466 } 474 }
467 475
468 /// Ask the daemon on `sock_path` to exit, then wait for the socket to stop 476 /// Ask the daemon on `sock_path` to exit, then wait for the socket to stop
@@ -512,27 +520,42 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
512 ); 520 );
513 return 1; 521 return 1;
514 } 522 }
515 // The log clause appears only when the path resolves: an absent HOME 523 var hint: [log_hint_len]u8 = undefined;
516 // (a container, a systemd unit) must not replace the one finding that 524 std.debug.print(
517 // matters — the daemon did not stop — with an error trace. And the 525 "muxd stop: {s} still answering after {d}s{s}\n",
518 // hedge stays in the words: a foreground `muxd run` logs to its own 526 .{ sock_path, secs, logHint(alloc, &hint) },
519 // stderr, so naming the xdg path unconditionally would guess. 527 );
520 const log: ?[]const u8 = xdg.logPath(alloc) catch null;
521 defer if (log) |l| alloc.free(l);
522 if (log) |l| {
523 std.debug.print(
524 "muxd stop: {s} still answering after {d}s (if it was started detached, its log is {s})\n",
525 .{ sock_path, secs, l },
526 );
527 } else {
528 std.debug.print(
529 "muxd stop: {s} still answering after {d}s\n",
530 .{ sock_path, secs },
531 );
532 }
533 return 1; 528 return 1;
534 } 529 }
535 530
531 const log_hint_len = std.fs.max_path_bytes + 64;
532
533 /// The "where the rest of the story is" clause, or "" when there is no
534 /// path to name.
535 ///
536 /// Two reports end with it — this file's `stopCmd` and `reportNoListener`
537 /// — and both are about a daemon that is not doing what was asked while
538 /// the person reading the line is somewhere else. It was written twice,
539 /// with a comment saying so; this is that comment's other half.
540 ///
541 /// The clause appears only when the path resolves: an absent HOME (a
542 /// container, a systemd unit) must not replace the finding that matters —
543 /// the daemon did not stop, the daemon has no listener — with an error
544 /// trace. And the hedge stays in the words: a foreground `muxd run` logs
545 /// to its own stderr, so naming the xdg path unconditionally would guess.
546 fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 {
547 const log = xdg.logPath(alloc) catch return "";
548 defer alloc.free(log);
549 // A path too long for the buffer is dropped rather than clipped: half
550 // a path is worse than none, and `log_hint_len` clears PATH_MAX, so
551 // the only paths it drops are ones nothing could have opened anyway.
552 return std.fmt.bufPrint(
553 buf,
554 " (if it was started detached, its log is {s})",
555 .{log},
556 ) catch "";
557 }
558
536 /// `muxd proxy` with a one-line preamble: ensure a daemon, ensure a key, 559 /// `muxd proxy` with a one-line preamble: ensure a daemon, ensure a key,
537 /// ask the daemon for its QUIC port, print `endpoint <port> <hex-key>` (or 560 /// ask the daemon for its QUIC port, print `endpoint <port> <hex-key>` (or
538 /// `endpoint none`) as the FIRST bytes on stdout, then become exactly the 561 /// `endpoint none`) as the FIRST bytes on stdout, then become exactly the
@@ -634,24 +657,14 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
634 /// on a box the person reading this line is not sitting at — so the line 657 /// on a box the person reading this line is not sitting at — so the line
635 /// says what happened and where the rest of the story is. 658 /// says what happened and where the rest of the story is.
636 /// 659 ///
637 /// The log clause appears only when the path resolves, and hedges in its 660 /// The log clause is `logHint`'s, conditions and hedge included — it is
638 /// wording: a foreground `muxd run` logs to its own stderr, so naming the 661 /// the same clause `stopCmd` ends with, for the same reasons.
639 /// xdg path unconditionally would guess. Both halves are `stopCmd`'s, for
640 /// the same reasons.
641 fn reportNoListener(alloc: std.mem.Allocator, sock_path: []const u8) void { 662 fn reportNoListener(alloc: std.mem.Allocator, sock_path: []const u8) void {
642 const log: ?[]const u8 = xdg.logPath(alloc) catch null; 663 var hint: [log_hint_len]u8 = undefined;
643 defer if (log) |l| alloc.free(l); 664 std.debug.print(
644 if (log) |l| { 665 "muxd endpoint: the daemon on {s} produced no QUIC listener; staying on ssh{s}\n",
645 std.debug.print( 666 .{ sock_path, logHint(alloc, &hint) },
646 "muxd endpoint: the daemon on {s} produced no QUIC listener; staying on ssh (if it was started detached, its log is {s})\n", 667 );
647 .{ sock_path, l },
648 );
649 } else {
650 std.debug.print(
651 "muxd endpoint: the daemon on {s} produced no QUIC listener; staying on ssh\n",
652 .{sock_path},
653 );
654 }
655 } 668 }
656 669
657 /// The key `muxd endpoint` announces, or null with exactly one stderr line 670 /// The key `muxd endpoint` announces, or null with exactly one stderr line
@@ -738,29 +751,17 @@ fn announceKeyFrom(env: ?[]const u8, dflt: ?[]const u8) KeyResult {
738 /// stderr to someone who is not on that box: "no usable key" alone would 751 /// stderr to someone who is not on that box: "no usable key" alone would
739 /// cost them the trip to go and find out which of the three it was. 752 /// cost them the trip to go and find out which of the three it was.
740 /// 753 ///
741 /// Word for word what `run` prints for the same three refusals, path in 754 /// Word for word what `run` prints for the same refusals, path in the same
742 /// the same position — a key is rejected for the same reasons however the 755 /// position — a key is rejected for the same reasons however the daemon
743 /// daemon was asked, and server.zig's `endpoint_req` refusals honour that 756 /// was asked. That is now a fact rather than a convention: the words are
744 /// literally too. One vocabulary, greppable across all three. 757 /// `quic.keyRefusalBody`'s, and this half owns only the prefix and the
758 /// `; staying on ssh` that says what the refusal cost.
745 fn reportKeyRefusal(path: []const u8, err: anyerror) void { 759 fn reportKeyRefusal(path: []const u8, err: anyerror) void {
746 switch (err) { 760 var buf: [quic.key_refusal_len]u8 = undefined;
747 error.KeyFileMissing => std.debug.print( 761 std.debug.print(
748 "muxd endpoint: no such key file: {s}; staying on ssh\n", 762 "muxd endpoint: {s}; staying on ssh\n",
749 .{path}, 763 .{quic.keyRefusalBody(&buf, err, path)},
750 ), 764 );
751 error.KeyFilePermissive => std.debug.print(
752 "muxd endpoint: {s} is readable by group or other; chmod 600 it; staying on ssh\n",
753 .{path},
754 ),
755 error.KeyFileMalformed => std.debug.print(
756 "muxd endpoint: {s} is not a key: want 32 raw bytes or 64 hex characters; staying on ssh\n",
757 .{path},
758 ),
759 else => std.debug.print(
760 "muxd endpoint: cannot read {s}: {s}; staying on ssh\n",
761 .{ path, @errorName(err) },
762 ),
763 }
764 } 765 }
765 766
766 /// One observer round-trip: `endpoint_req`, then a bounded wait for the 767 /// One observer round-trip: `endpoint_req`, then a bounded wait for the
@@ -1188,6 +1189,27 @@ test "askEndpointPort: a socket nobody serves answers 0, quickly" {
1188 try std.testing.expect(std.time.milliTimestamp() - t0 < 500); 1189 try std.testing.expect(std.time.milliTimestamp() - t0 < 500);
1189 } 1190 }
1190 1191
1192 test "oneShotQuery: a socket nobody serves is exit 1, and the verb is in the line" {
1193 const testtmp = @import("testtmp");
1194 var tmp = try testtmp.TmpDir.make();
1195 defer tmp.cleanup();
1196 var buf: [280]u8 = undefined;
1197 const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()});
1198
1199 // The opposite verdict from `stopCmd` below, on the identical input, and
1200 // both are right: `stop` asked for a state the absence already satisfies,
1201 // while `dump` and `stats` asked a question nothing answered. Sharing one
1202 // round-trip between the two query verbs must not quietly make it three.
1203 try std.testing.expectEqual(
1204 @as(u8, 1),
1205 try oneShotQuery(std.testing.allocator, sock, "dump", .debug_dump, "", .dump_reply),
1206 );
1207 try std.testing.expectEqual(
1208 @as(u8, 1),
1209 try oneShotQuery(std.testing.allocator, sock, "stats", .stats_req, "", .stats_reply),
1210 );
1211 }
1212
1191 test "stopCmd: a socket path with nothing on it is exit 0, not a failure" { 1213 test "stopCmd: a socket path with nothing on it is exit 0, not a failure" {
1192 const testtmp = @import("testtmp"); 1214 const testtmp = @import("testtmp");
1193 var tmp = try testtmp.TmpDir.make(); 1215 var tmp = try testtmp.TmpDir.make();
src/quic.zig
Old New
@@ -122,6 +122,42 @@ pub const Key = struct {
122 } 122 }
123 }; 123 };
124 124
125 /// A buffer big enough for any refusal body: the longest sentence, a path
126 /// at PATH_MAX, and an error name. Past this the body clips — see the
127 /// truncation note on `keyRefusalBody`.
128 pub const key_refusal_len = std.fs.max_path_bytes + 128;
129
130 /// The middle sentence of every key refusal, in every binary — one owner
131 /// because four literal copies were held in sync by prose comments, and
132 /// their catch-alls had already drifted. Callers add their own prefix and
133 /// suffix.
134 ///
135 /// `err` is `anyerror` rather than `Key.LoadError` because `load` widens
136 /// past its own set: a stat or a read that fails arrives here as a plain
137 /// posix error, and the catch-all is what those are for. Classification is
138 /// therefore by value, and identical at every caller — which is the point,
139 /// since a key is rejected for the same reasons whichever binary read it.
140 ///
141 /// Truncating rather than failing is `failedMsg`'s policy in client.zig,
142 /// for its reason: this line is the user's only account of the refusal, so
143 /// a clipped one beats none.
144 pub fn keyRefusalBody(buf: []u8, err: anyerror, path: []const u8) []const u8 {
145 var w: std.Io.Writer = .fixed(buf);
146 switch (err) {
147 error.KeyFileMissing => w.print("no such key file: {s}", .{path}) catch {},
148 error.KeyFilePermissive => w.print(
149 "{s} is readable by group or other; chmod 600 it",
150 .{path},
151 ) catch {},
152 error.KeyFileMalformed => w.print(
153 "{s} is not a key: want 32 raw bytes or 64 hex characters",
154 .{path},
155 ) catch {},
156 else => w.print("cannot read {s}: {s}", .{ path, @errorName(err) }) catch {},
157 }
158 return w.buffered();
159 }
160
125 /// Test helper: chmod a file inside a Dir. `Dir.chmod` applies to the 161 /// Test helper: chmod a file inside a Dir. `Dir.chmod` applies to the
126 /// directory itself, not to an entry in it. 162 /// directory itself, not to an entry in it.
127 fn chmodAt(dir: std.fs.Dir, sub: []const u8, mode: std.posix.mode_t) !void { 163 fn chmodAt(dir: std.fs.Dir, sub: []const u8, mode: std.posix.mode_t) !void {
@@ -229,6 +265,40 @@ test "Key.load: refuses a permissive mode, a missing file, and a bad length" {
229 ); 265 );
230 } 266 }
231 267
268 test "keyRefusalBody: the words four binaries print, byte for byte" {
269 // These bytes ARE the contract. Every key refusal any binary prints is
270 // a prefix, this body, and at most a suffix:
271 //
272 // muxd: <body> (muxd run --quic)
273 // muxd endpoint: <body>; staying on ssh
274 // muxd: endpoint_req: <body> (the daemon's lazy bind)
275 // mux: <body> (the client's dial)
276 //
277 // so a change here is a change to all four at once — which is what the
278 // four literal copies this replaced could never guarantee, and did not:
279 // one of their catch-alls had drifted to a different verb.
280 var buf: [key_refusal_len]u8 = undefined;
281 try std.testing.expectEqualStrings(
282 "no such key file: /etc/mux/key",
283 keyRefusalBody(&buf, error.KeyFileMissing, "/etc/mux/key"),
284 );
285 try std.testing.expectEqualStrings(
286 "/etc/mux/key is readable by group or other; chmod 600 it",
287 keyRefusalBody(&buf, error.KeyFilePermissive, "/etc/mux/key"),
288 );
289 try std.testing.expectEqualStrings(
290 "/etc/mux/key is not a key: want 32 raw bytes or 64 hex characters",
291 keyRefusalBody(&buf, error.KeyFileMalformed, "/etc/mux/key"),
292 );
293 // Anything `load` failed with that is not one of the three: the body
294 // names the error rather than inventing a cause for it, and the path
295 // still comes first so the line reads the same way as the other three.
296 try std.testing.expectEqualStrings(
297 "cannot read /etc/mux/key: AccessDenied",
298 keyRefusalBody(&buf, error.AccessDenied, "/etc/mux/key"),
299 );
300 }
301
232 // --------------------------------------------------------------------------- 302 // ---------------------------------------------------------------------------
233 // Egress: the ring, and what one writev_stream return means for it 303 // Egress: the ring, and what one writev_stream return means for it
234 // --------------------------------------------------------------------------- 304 // ---------------------------------------------------------------------------
src/quic_client.zig
Old New
@@ -38,6 +38,11 @@ pub const Key = quic.Key;
38 pub const key_len = quic.key_len; 38 pub const key_len = quic.key_len;
39 pub const default_port = quic.default_port; 39 pub const default_port = quic.default_port;
40 pub const default_idle_ms = quic.default_idle_ms; 40 pub const default_idle_ms = quic.default_idle_ms;
41 /// The refusal words, re-exported for the same reason `Key` is: client.zig
42 /// prints the daemon's sentences for a key the daemon would also refuse,
43 /// and it reaches the vocabulary module through this one.
44 pub const key_refusal_len = quic.key_refusal_len;
45 pub const keyRefusalBody = quic.keyRefusalBody;
41 46
42 /// wolfSSL's PSK callback carries no user pointer, so the key has to be 47 /// wolfSSL's PSK callback carries no user pointer, so the key has to be
43 /// reachable without one. A client process runs one connection at a time, 48 /// reachable without one. A client process runs one connection at a time,
src/server.zig
Old New
@@ -826,38 +826,18 @@ pub const Server = struct {
826 } 826 }
827 return 0; 827 return 0;
828 }; 828 };
829 // The same three refusals main.zig gives the --quic path, in the same 829 // The same refusals main.zig gives the --quic path, in the same
830 // words: a key is rejected for the same reasons however the daemon 830 // words — literally the same, since `quic.keyRefusalBody` owns them
831 // came to read it, and an operator who has seen one message should 831 // and this site owns only the prefix. An operator who has seen one
832 // not have to learn a second phrasing for it. 832 // message should not have to learn a second phrasing for it, and
833 const key = quic.Key.load(key_path) catch |err| switch (err) { 833 // this site's catch-all used to be exactly that second phrasing.
834 error.KeyFileMissing => { 834 const key = quic.Key.load(key_path) catch |err| {
835 std.debug.print("muxd: endpoint_req: no such key file: {s}\n", .{key_path}); 835 var buf: [quic.key_refusal_len]u8 = undefined;
836 return 0; 836 std.debug.print(
837 }, 837 "muxd: endpoint_req: {s}\n",
838 error.KeyFilePermissive => { 838 .{quic.keyRefusalBody(&buf, err, key_path)},
839 std.debug.print( 839 );
840 "muxd: endpoint_req: {s} is readable by group or other; chmod 600 it\n", 840 return 0;
841 .{key_path},
842 );
843 return 0;
844 },
845 error.KeyFileMalformed => {
846 std.debug.print(
847 "muxd: endpoint_req: {s} is not a key: want 32 raw bytes or 64 hex characters\n",
848 .{key_path},
849 );
850 return 0;
851 },
852 // Anything else is a read that failed for a reason this code has
853 // no words for, so it hands over the one it has.
854 else => |e| {
855 std.debug.print(
856 "muxd: endpoint_req: cannot load key {s}: {s}\n",
857 .{ key_path, @errorName(e) },
858 );
859 return 0;
860 },
861 }; 841 };
862 return self.lazyBindQuic(key) catch |err| { 842 return self.lazyBindQuic(key) catch |err| {
863 std.debug.print("muxd: endpoint_req: cannot bind udp: {s}\n", .{@errorName(err)}); 843 std.debug.print("muxd: endpoint_req: cannot bind udp: {s}\n", .{@errorName(err)});