1fc273e8
refactor: key refusals have one owner and a pin; dump/stats/logHint dedup
a73x 2026-08-12 19:41
Commit message
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)}); |