d6a6f944
fix: endpoint refusals all speak; announceKey gains its testable seam
a73x 2026-08-11 13:30
Commit message
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -540,14 +540,28 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 540 | /// The announce is mandatory in both directions. The client blocks on one | 540 | /// The announce is mandatory in both directions. The client blocks on one |
| 541 | /// newline-terminated line, and the daemon side of the frame protocol | 541 | /// newline-terminated line, and the daemon side of the frame protocol |
| 542 | /// sends nothing unprompted — so silence here is indistinguishable from a | 542 | /// sends nothing unprompted — so silence here is indistinguishable from a |
| 543 | /// slow ssh and would hang the attach rather than degrade it. Every path | 543 | /// slow ssh and would hang the attach rather than degrade it. |
| 544 | /// below therefore ends in a line, and the negative one is a line too. | 544 | /// |
| 545 | /// The two kinds of failure therefore end differently. A SOFT one — no | ||
| 546 | /// usable key, no listener — announces `endpoint none` and pumps anyway: | ||
| 547 | /// the ssh session is real and carries the whole session. A HARD one — no | ||
| 548 | /// daemon to pump to, a stdout that will not take the announce — exits, | ||
| 549 | /// and the client reads EOF on the pipe, which `handoff.readLine` already | ||
| 550 | /// tells apart from a line (`UnterminatedLine`). Announcing none and THEN | ||
| 551 | /// exiting is the one dishonest option available: it would tell the client | ||
| 552 | /// it has a working ssh session at the moment that session goes away. | ||
| 545 | /// | 553 | /// |
| 546 | /// stdout carries the announce and then frames, nothing else: every | 554 | /// stdout carries the announce and then frames, nothing else: every |
| 547 | /// human-facing word here goes to stderr, which ssh already carries to the | 555 | /// human-facing word here goes to stderr, which ssh already carries to the |
| 548 | /// user's terminal. That includes `ensureForAttach`'s progress, whose | 556 | /// user's terminal. That includes `ensureForAttach`'s progress, whose |
| 549 | /// Progress is pinned to STDERR_FILENO in spawn.zig — a stray stdout byte | 557 | /// Progress is pinned to STDERR_FILENO in spawn.zig — a stray stdout byte |
| 550 | /// ahead of the announce would land in the middle of the client's parse. | 558 | /// ahead of the announce would land in the middle of the client's parse. |
| 559 | /// | ||
| 560 | /// The pump that follows keeps speaking in its own name: a socket that | ||
| 561 | /// disappears between the ask and the attach is reported by proxy.zig as | ||
| 562 | /// `muxd proxy: cannot connect to …`. That is deliberate — reusing | ||
| 563 | /// `proxy.run` is the whole design, and the line names the code that | ||
| 564 | /// failed rather than the verb that was typed. | ||
| 551 | fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | 565 | fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { |
| 552 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; | 566 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; |
| 553 | const exe = std.fs.selfExePath(&exe_buf) catch { | 567 | const exe = std.fs.selfExePath(&exe_buf) catch { |
| @@ -556,6 +570,14 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 556 | }; | 570 | }; |
| 557 | if (!try spawn.ensureForAttach(alloc, exe, sock_path, "muxd endpoint")) return 1; | 571 | if (!try spawn.ensureForAttach(alloc, exe, sock_path, "muxd endpoint")) return 1; |
| 558 | 572 | ||
| 573 | // The announce goes out on the same stdout the pump is about to use, | ||
| 574 | // so it wants the same EPIPE-not-SIGPIPE treatment — and it wants it | ||
| 575 | // from proxy.zig's installer rather than from a std default this file | ||
| 576 | // would be leaning on. AFTER the auto-start above, never before: the | ||
| 577 | // ignore is SIG_IGN, which survives exec, so installing it first would | ||
| 578 | // hand the spawned daemon an inherited disposition it never chose. | ||
| 579 | proxy.ignoreSigpipe(); | ||
| 580 | |||
| 559 | // Key first, then the ask, and the order is load-bearing: the daemon's | 581 | // Key first, then the ask, and the order is load-bearing: the daemon's |
| 560 | // lazy bind takes the default key path only if the file already exists | 582 | // lazy bind takes the default key path only if the file already exists |
| 561 | // and never creates one (server.zig endpointPortFrom). Creating it here | 583 | // and never creates one (server.zig endpointPortFrom). Creating it here |
| @@ -573,7 +595,10 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 573 | // rather than doing it quietly, so an omission here would fail on | 595 | // rather than doing it quietly, so an omission here would fail on |
| 574 | // this line instead of arriving on another machine as a parse error | 596 | // this line instead of arriving on another machine as a parse error |
| 575 | // about a message we wrote. | 597 | // about a message we wrote. |
| 576 | if (port == 0) break :blk handoff.announce_none; | 598 | if (port == 0) { |
| 599 | reportNoListener(alloc, sock_path); | ||
| 600 | break :blk handoff.announce_none; | ||
| 601 | } | ||
| 577 | // Unreachable in fact — port 0 is gone by here, and line_buf is | 602 | // Unreachable in fact — port 0 is gone by here, and line_buf is |
| 578 | // sized by the same constant that bounds the grammar — but written | 603 | // sized by the same constant that bounds the grammar — but written |
| 579 | // as a fallback rather than `unreachable` because of what the two | 604 | // as a fallback rather than `unreachable` because of what the two |
| @@ -584,76 +609,157 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 584 | break :blk handoff.formatAnnounce(&line_buf, .{ .port = port, .key = k.bytes }) catch | 609 | break :blk handoff.formatAnnounce(&line_buf, .{ .port = port, .key = k.bytes }) catch |
| 585 | handoff.announce_none; | 610 | handoff.announce_none; |
| 586 | }; | 611 | }; |
| 587 | proto.writeAllFd(std.posix.STDOUT_FILENO, line) catch { | 612 | proto.writeAllFd(std.posix.STDOUT_FILENO, line) catch |err| { |
| 588 | // stdout is the pipe the pump is about to need, so there is no | 613 | // stdout is the pipe the pump is about to need, so there is no |
| 589 | // session left to fall back to — only a line about why. | 614 | // session left to fall back to — only a line about why. The error |
| 590 | std.debug.print("muxd endpoint: stdout closed before the announce\n", .{}); | 615 | // is named rather than guessed at: EPIPE (the ssh client gave up |
| 616 | // first) is the likely one, but a full disk under a redirect and a | ||
| 617 | // closed fd reach here too, and they want different reactions. | ||
| 618 | std.debug.print( | ||
| 619 | "muxd endpoint: cannot write the announce to stdout: {s}\n", | ||
| 620 | .{@errorName(err)}, | ||
| 621 | ); | ||
| 591 | return 1; | 622 | return 1; |
| 592 | }; | 623 | }; |
| 593 | 624 | ||
| 594 | return proxy.run(sock_path); | 625 | return proxy.run(sock_path); |
| 595 | } | 626 | } |
| 596 | 627 | ||
| 597 | /// The key `muxd endpoint` announces, or null with one stderr line saying | 628 | /// The daemon is up and answering but has no QUIC listener to offer: no key |
| 598 | /// why not. | 629 | /// it could load, a bind that failed, or a binary too old to know the verb. |
| 630 | /// | ||
| 631 | /// This is the likeliest way the announce goes negative in production, and | ||
| 632 | /// it must not be silent. The daemon wrote the actual reason to ITS log — | ||
| 633 | /// on a box the person reading this line is not sitting at — so the line | ||
| 634 | /// says what happened and where the rest of the story is. | ||
| 599 | /// | 635 | /// |
| 600 | /// Resolution matches the daemon's lazy bind exactly — MUX_KEY_FILE, then | 636 | /// The log clause appears only when the path resolves, and hedges in its |
| 601 | /// the default path — because the announce hands a client the key it will | 637 | /// wording: a foreground `muxd run` logs to its own stderr, so naming the |
| 638 | /// xdg path unconditionally would guess. Both halves are `stopCmd`'s, for | ||
| 639 | /// the same reasons. | ||
| 640 | fn reportNoListener(alloc: std.mem.Allocator, sock_path: []const u8) void { | ||
| 641 | const log: ?[]const u8 = xdg.logPath(alloc) catch null; | ||
| 642 | defer if (log) |l| alloc.free(l); | ||
| 643 | if (log) |l| { | ||
| 644 | std.debug.print( | ||
| 645 | "muxd endpoint: the daemon on {s} produced no QUIC listener; staying on ssh (if it was started detached, its log is {s})\n", | ||
| 646 | .{ sock_path, l }, | ||
| 647 | ); | ||
| 648 | } else { | ||
| 649 | std.debug.print( | ||
| 650 | "muxd endpoint: the daemon on {s} produced no QUIC listener; staying on ssh\n", | ||
| 651 | .{sock_path}, | ||
| 652 | ); | ||
| 653 | } | ||
| 654 | } | ||
| 655 | |||
| 656 | /// The key `muxd endpoint` announces, or null with exactly one stderr line | ||
| 657 | /// saying why not. The reading of the environment and the deciding live in | ||
| 658 | /// `announceKeyFrom` below; this half owns the words. | ||
| 659 | fn announceKey(alloc: std.mem.Allocator) ?quic.Key { | ||
| 660 | // Resolved whether or not it is the one chosen, so the no-HOME case can | ||
| 661 | // be told apart from the have-a-path cases below. | ||
| 662 | const dflt: ?[]const u8 = xdg.keyPath(alloc) catch null; | ||
| 663 | defer if (dflt) |p| alloc.free(p); | ||
| 664 | |||
| 665 | switch (announceKeyFrom(envKey(), dflt)) { | ||
| 666 | .key => |k| return k, | ||
| 667 | .no_path => std.debug.print( | ||
| 668 | "muxd endpoint: no HOME to resolve a key path; staying on ssh\n", | ||
| 669 | .{}, | ||
| 670 | ), | ||
| 671 | .create_failed => |f| std.debug.print( | ||
| 672 | "muxd endpoint: cannot create {s}: {s}; staying on ssh\n", | ||
| 673 | .{ f.path, @errorName(f.err) }, | ||
| 674 | ), | ||
| 675 | .load_failed => |f| reportKeyRefusal(f.path, f.err), | ||
| 676 | } | ||
| 677 | return null; | ||
| 678 | } | ||
| 679 | |||
| 680 | /// What the key resolution decided and why, separated from the printing of | ||
| 681 | /// it. `pickKey` above exists for the same reason and the daemon's | ||
| 682 | /// `endpointPortFrom` is this decision's other half: the two must agree on | ||
| 683 | /// which file "the key" names, and an order that quietly inverted would | ||
| 684 | /// otherwise show up only as a client authenticating to nothing. | ||
| 685 | const KeyResult = union(enum) { | ||
| 686 | key: quic.Key, | ||
| 687 | /// No MUX_KEY_FILE and no HOME to build a default under: there is not | ||
| 688 | /// even a path to try. | ||
| 689 | no_path, | ||
| 690 | /// The default key was absent and could not be created. Kept apart | ||
| 691 | /// from `load_failed` because it is the cause and the load's | ||
| 692 | /// `KeyFileMissing` would only be its symptom. | ||
| 693 | create_failed: struct { path: []const u8, err: anyerror }, | ||
| 694 | load_failed: struct { path: []const u8, err: anyerror }, | ||
| 695 | }; | ||
| 696 | |||
| 697 | /// MUX_KEY_FILE, then the default path — the same order the daemon's lazy | ||
| 698 | /// bind uses, because the announce hands a client the key it will | ||
| 602 | /// authenticate WITH against a listener holding whatever the daemon | 699 | /// authenticate WITH against a listener holding whatever the daemon |
| 603 | /// loaded. Two spellings of "the key" would attach to nothing. | 700 | /// loaded. Two spellings of "the key" would attach to nothing. |
| 604 | /// | 701 | /// |
| 605 | /// Only the default is created when absent: that is the mosh-server move. | 702 | /// Only the default is created when absent: that is the mosh-server move, |
| 606 | /// A MUX_KEY_FILE that is set but missing names a file the user manages, | 703 | /// and it is what lets a first attach to a fresh box produce coordinates |
| 607 | /// and writing one there would be a credential appearing where nobody | 704 | /// at all. A MUX_KEY_FILE that is set but missing names a file the user |
| 608 | /// asked for it. | 705 | /// manages, and writing one there would be a credential appearing where |
| 609 | fn announceKey(alloc: std.mem.Allocator) ?quic.Key { | 706 | /// nobody asked for it — so that path is loaded, never created. |
| 610 | if (envKey()) |p| return loadAnnounceKey(p); | 707 | /// |
| 611 | const dflt = xdg.keyPath(alloc) catch { | 708 | /// Environment handed in and nothing printed, so the order and the |
| 612 | std.debug.print("muxd endpoint: no HOME to resolve a key path; staying on ssh\n", .{}); | 709 | /// create-vs-load distinction are testable: the `*From` discipline xdg.zig |
| 613 | return null; | 710 | /// set and server.zig's `endpointPortFrom` follows. |
| 614 | }; | 711 | fn announceKeyFrom(env: ?[]const u8, dflt: ?[]const u8) KeyResult { |
| 615 | defer alloc.free(dflt); | 712 | if (env) |p| return if (quic.Key.load(p)) |k| |
| 713 | .{ .key = k } | ||
| 714 | else |err| | ||
| 715 | .{ .load_failed = .{ .path = p, .err = err } }; | ||
| 616 | 716 | ||
| 717 | const path = dflt orelse return .no_path; | ||
| 617 | // KeyExists is the ordinary case and no news: the key is already there | 718 | // KeyExists is the ordinary case and no news: the key is already there |
| 618 | // and the load below is what wanted it. Any OTHER create failure is | 719 | // and the load below is what wanted it. Any OTHER create failure is |
| 619 | // kept, because if the load then fails too it is the create that holds | 720 | // kept, because if the load then fails too it is the create that holds |
| 620 | // the real reason — an unwritable config directory, a full disk — and | 721 | // the reason — an unwritable config directory, a full disk — while the |
| 621 | // `no such key file` would name the symptom while burying the cause on | 722 | // load can only report the file's absence, which is its symptom. |
| 622 | // a box the person reading this line is not sitting at. | 723 | const create_failed: ?anyerror = if (xdg.writeNewKey(path)) |_| |
| 623 | const create_failed: ?anyerror = if (xdg.writeNewKey(dflt)) |_| | ||
| 624 | null | 724 | null |
| 625 | else |err| | 725 | else |err| |
| 626 | if (err == error.KeyExists) null else err; | 726 | if (err == error.KeyExists) null else err; |
| 627 | 727 | ||
| 628 | return quic.Key.load(dflt) catch |load_err| { | 728 | return if (quic.Key.load(path)) |k| |
| 629 | if (create_failed) |err| { | 729 | .{ .key = k } |
| 630 | std.debug.print( | 730 | else |load_err| if (create_failed) |err| |
| 631 | "muxd endpoint: cannot create {s}: {s}; staying on ssh\n", | 731 | .{ .create_failed = .{ .path = path, .err = err } } |
| 632 | .{ dflt, @errorName(err) }, | 732 | else |
| 633 | ); | 733 | .{ .load_failed = .{ .path = path, .err = load_err } }; |
| 634 | } else reportKeyRefusal(dflt, load_err); | ||
| 635 | return null; | ||
| 636 | }; | ||
| 637 | } | ||
| 638 | |||
| 639 | fn loadAnnounceKey(path: []const u8) ?quic.Key { | ||
| 640 | return quic.Key.load(path) catch |err| { | ||
| 641 | reportKeyRefusal(path, err); | ||
| 642 | return null; | ||
| 643 | }; | ||
| 644 | } | 734 | } |
| 645 | 735 | ||
| 646 | /// One line, naming the reason and not just the verdict. It rides ssh's | 736 | /// One line, naming the reason and not just the verdict. It rides ssh's |
| 647 | /// stderr to someone who is not on that box: "no usable key" alone would | 737 | /// stderr to someone who is not on that box: "no usable key" alone would |
| 648 | /// cost them the trip to go and find out which of the three it was. Same | 738 | /// cost them the trip to go and find out which of the three it was. |
| 649 | /// words `run` gives the same three refusals. | 739 | /// |
| 740 | /// Word for word what `run` prints for the same three refusals, path in | ||
| 741 | /// the same position — a key is rejected for the same reasons however the | ||
| 742 | /// daemon was asked, and server.zig's `endpoint_req` refusals honour that | ||
| 743 | /// literally too. One vocabulary, greppable across all three. | ||
| 650 | fn reportKeyRefusal(path: []const u8, err: anyerror) void { | 744 | fn reportKeyRefusal(path: []const u8, err: anyerror) void { |
| 651 | std.debug.print("muxd endpoint: {s}: {s}; staying on ssh\n", .{ path, switch (err) { | 745 | switch (err) { |
| 652 | error.KeyFileMissing => "no such key file", | 746 | error.KeyFileMissing => std.debug.print( |
| 653 | error.KeyFilePermissive => "readable by group or other, chmod 600 it", | 747 | "muxd endpoint: no such key file: {s}; staying on ssh\n", |
| 654 | error.KeyFileMalformed => "not a key: want 32 raw bytes or 64 hex characters", | 748 | .{path}, |
| 655 | else => @errorName(err), | 749 | ), |
| 656 | } }); | 750 | error.KeyFilePermissive => std.debug.print( |
| 751 | "muxd endpoint: {s} is readable by group or other; chmod 600 it; staying on ssh\n", | ||
| 752 | .{path}, | ||
| 753 | ), | ||
| 754 | error.KeyFileMalformed => std.debug.print( | ||
| 755 | "muxd endpoint: {s} is not a key: want 32 raw bytes or 64 hex characters; staying on ssh\n", | ||
| 756 | .{path}, | ||
| 757 | ), | ||
| 758 | else => std.debug.print( | ||
| 759 | "muxd endpoint: cannot read {s}: {s}; staying on ssh\n", | ||
| 760 | .{ path, @errorName(err) }, | ||
| 761 | ), | ||
| 762 | } | ||
| 657 | } | 763 | } |
| 658 | 764 | ||
| 659 | /// One observer round-trip: `endpoint_req`, then a bounded wait for the | 765 | /// One observer round-trip: `endpoint_req`, then a bounded wait for the |
| @@ -966,16 +1072,102 @@ test "parseArgs: endpoint is a command and takes --sock" { | |||
| 966 | try std.testing.expect(s.ok.cmd == .endpoint); | 1072 | try std.testing.expect(s.ok.cmd == .endpoint); |
| 967 | try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); | 1073 | try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); |
| 968 | 1074 | ||
| 969 | // `endpoint` is `proxy` plus a preamble, and `proxy` takes exactly one | 1075 | // An unknown flag is refused, as it is for every command: a client of |
| 970 | // flag. A second one arriving here would be a client of some later | 1076 | // some later version asking this binary for something it cannot do |
| 971 | // version asking for something this binary cannot do, and the refusal | 1077 | // gets a legible refusal rather than silence. |
| 972 | // is what makes that legible instead of silently ignored. | 1078 | // |
| 1079 | // Only UNKNOWN, though. The value-taking flags share one loop, so | ||
| 1080 | // `endpoint --cols 100` parses and is then ignored — `--sock` is the | ||
| 1081 | // only one this command reads. `keygen` is the sole verb that narrows | ||
| 1082 | // its own surface, and widening that rule to `proxy` and `endpoint` | ||
| 1083 | // together is its own change, not this one's. | ||
| 973 | try std.testing.expect(parse(&.{ "muxd", "endpoint", "--quiet" }).err == .unknown_arg); | 1084 | try std.testing.expect(parse(&.{ "muxd", "endpoint", "--quiet" }).err == .unknown_arg); |
| 1085 | try std.testing.expect(parse(&.{ "muxd", "endpoint", "--cols", "100" }) == .ok); | ||
| 974 | const missing = parse(&.{ "muxd", "endpoint", "--sock" }); | 1086 | const missing = parse(&.{ "muxd", "endpoint", "--sock" }); |
| 975 | try std.testing.expect(missing.err == .missing_value); | 1087 | try std.testing.expect(missing.err == .missing_value); |
| 976 | try std.testing.expectEqualStrings("--sock", missing.err.missing_value); | 1088 | try std.testing.expectEqualStrings("--sock", missing.err.missing_value); |
| 977 | } | 1089 | } |
| 978 | 1090 | ||
| 1091 | test "announceKeyFrom: MUX_KEY_FILE wins, and the default it skipped is not created" { | ||
| 1092 | const testtmp = @import("testtmp"); | ||
| 1093 | var tmp = try testtmp.TmpDir.make(); | ||
| 1094 | defer tmp.cleanup(); | ||
| 1095 | |||
| 1096 | var ebuf: [280]u8 = undefined; | ||
| 1097 | var dbuf: [280]u8 = undefined; | ||
| 1098 | const env = try std.fmt.bufPrint(&ebuf, "{s}/env-key", .{tmp.path()}); | ||
| 1099 | const dflt = try std.fmt.bufPrint(&dbuf, "{s}/cfg/mux/key", .{tmp.path()}); | ||
| 1100 | try xdg.writeNewKey(env); | ||
| 1101 | |||
| 1102 | const r = announceKeyFrom(env, dflt); | ||
| 1103 | try std.testing.expect(r == .key); | ||
| 1104 | // The key it returned is the file it was pointed at, not merely some | ||
| 1105 | // key: the announce is only worth anything if it names the one the | ||
| 1106 | // daemon will authenticate with. | ||
| 1107 | var on_disk: [32]u8 = undefined; | ||
| 1108 | try std.testing.expectEqualSlices(u8, try std.fs.cwd().readFile(env, &on_disk), &r.key.bytes); | ||
| 1109 | |||
| 1110 | // The default is not merely unused, it is uncreated. Creating a key | ||
| 1111 | // beside one the user named would leave a credential nobody asked for | ||
| 1112 | // and, worse, one the daemon might later pick up instead. | ||
| 1113 | try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(dflt, .{})); | ||
| 1114 | } | ||
| 1115 | |||
| 1116 | test "announceKeyFrom: the default is created when absent, and no path at all is no_path" { | ||
| 1117 | const testtmp = @import("testtmp"); | ||
| 1118 | var tmp = try testtmp.TmpDir.make(); | ||
| 1119 | defer tmp.cleanup(); | ||
| 1120 | |||
| 1121 | var dbuf: [280]u8 = undefined; | ||
| 1122 | const dflt = try std.fmt.bufPrint(&dbuf, "{s}/cfg/mux/key", .{tmp.path()}); | ||
| 1123 | |||
| 1124 | // The mosh-server move: a fresh box gets a key rather than a lecture. | ||
| 1125 | const made = announceKeyFrom(null, dflt); | ||
| 1126 | try std.testing.expect(made == .key); | ||
| 1127 | const st = try std.fs.cwd().statFile(dflt); | ||
| 1128 | try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(st.mode & 0o777))); | ||
| 1129 | |||
| 1130 | // A second call loads the SAME key rather than rotating it: the | ||
| 1131 | // announce must name what the daemon will authenticate with, and this | ||
| 1132 | // process runs once per attach. | ||
| 1133 | const again = announceKeyFrom(null, dflt); | ||
| 1134 | try std.testing.expect(again == .key); | ||
| 1135 | try std.testing.expectEqualSlices(u8, &made.key.bytes, &again.key.bytes); | ||
| 1136 | |||
| 1137 | try std.testing.expect(announceKeyFrom(null, null) == .no_path); | ||
| 1138 | } | ||
| 1139 | |||
| 1140 | test "announceKeyFrom: a default that cannot be created reports the create, not the load" { | ||
| 1141 | const testtmp = @import("testtmp"); | ||
| 1142 | var tmp = try testtmp.TmpDir.make(); | ||
| 1143 | defer tmp.cleanup(); | ||
| 1144 | |||
| 1145 | var robuf: [280]u8 = undefined; | ||
| 1146 | var dbuf: [280]u8 = undefined; | ||
| 1147 | const ro = try std.fmt.bufPrint(&robuf, "{s}/ro", .{tmp.path()}); | ||
| 1148 | const dflt = try std.fmt.bufPrint(&dbuf, "{s}/mux/key", .{ro}); | ||
| 1149 | try std.fs.cwd().makePath(ro); | ||
| 1150 | { | ||
| 1151 | var d = try std.fs.cwd().openDir(ro, .{ .iterate = true }); | ||
| 1152 | defer d.close(); | ||
| 1153 | try d.chmod(0o500); | ||
| 1154 | } | ||
| 1155 | // Left at 0500 for cleanup, deliberately: 0500 still grants read and | ||
| 1156 | // execute, so deleteTree can enter and list it, and removing the empty | ||
| 1157 | // directory itself needs write on the tmp ROOT, which is untouched. | ||
| 1158 | // Emptiness is not an assumption — it is the assertion below. | ||
| 1159 | |||
| 1160 | // Swallowing the create error leaves the load to speak, and all it can | ||
| 1161 | // say is `no such key file` — which names the symptom and sends | ||
| 1162 | // someone reading it over ssh to look for a file, when the real story | ||
| 1163 | // is a directory they cannot write. The distinction is the whole | ||
| 1164 | // reason the create's error is retained. | ||
| 1165 | const r = announceKeyFrom(null, dflt); | ||
| 1166 | try std.testing.expect(r == .create_failed); | ||
| 1167 | try std.testing.expectEqual(error.AccessDenied, r.create_failed.err); | ||
| 1168 | try std.testing.expectEqualStrings(dflt, r.create_failed.path); | ||
| 1169 | } | ||
| 1170 | |||
| 979 | test "askEndpointPort: a socket nobody serves answers 0, quickly" { | 1171 | test "askEndpointPort: a socket nobody serves answers 0, quickly" { |
| 980 | const testtmp = @import("testtmp"); | 1172 | const testtmp = @import("testtmp"); |
| 981 | var tmp = try testtmp.TmpDir.make(); | 1173 | var tmp = try testtmp.TmpDir.make(); |
src/proxy.zig
| Old | New | ||
|---|---|---|---|
| @@ -6,6 +6,35 @@ | |||
| 6 | const std = @import("std"); | 6 | const std = @import("std"); |
| 7 | const TmpDir = @import("testtmp").TmpDir; | 7 | const TmpDir = @import("testtmp").TmpDir; |
| 8 | 8 | ||
| 9 | /// Make a hangup on any of this process's pipes surface as EPIPE from | ||
| 10 | /// write() instead of killing it. | ||
| 11 | /// | ||
| 12 | /// Defence in depth, not a fix: Zig's start.zig already installs a noop | ||
| 13 | /// SIGPIPE handler, so `pump`'s write-error returns are reachable without | ||
| 14 | /// this. What it pins is that the reachability belongs to this code instead | ||
| 15 | /// of to a std default (`std.options.keep_sigpipe`) another module could | ||
| 16 | /// flip. | ||
| 17 | /// | ||
| 18 | /// Exported because `muxd endpoint` writes its announce to the same stdout | ||
| 19 | /// this pump is about to use, before calling `run` — one installer rather | ||
| 20 | /// than a copy, so the two cannot drift. No protocol knowledge crosses the | ||
| 21 | /// boundary, which is the only thing this file's import list forbids. | ||
| 22 | /// | ||
| 23 | /// The one real difference from the std default: SIG_IGN survives exec, a | ||
| 24 | /// handler does not. So a caller that SPAWNS must install this after the | ||
| 25 | /// spawn, never before, or the child inherits the ignore across its exec — | ||
| 26 | /// which is why client.zig installs its ignore only after spawning the | ||
| 27 | /// transport child, why `muxd endpoint` calls this after its auto-start, | ||
| 28 | /// and why the order does not matter here (the proxy spawns nothing). | ||
| 29 | pub fn ignoreSigpipe() void { | ||
| 30 | var ign: std.posix.Sigaction = .{ | ||
| 31 | .handler = .{ .handler = std.posix.SIG.IGN }, | ||
| 32 | .mask = std.posix.sigemptyset(), | ||
| 33 | .flags = 0, | ||
| 34 | }; | ||
| 35 | std.posix.sigaction(std.posix.SIG.PIPE, &ign, null); | ||
| 36 | } | ||
| 37 | |||
| 9 | /// `muxd proxy` proper: pump between this process's stdio and `sock_path`. | 38 | /// `muxd proxy` proper: pump between this process's stdio and `sock_path`. |
| 10 | pub fn run(sock_path: []const u8) !u8 { | 39 | pub fn run(sock_path: []const u8) !u8 { |
| 11 | return pump(std.posix.STDIN_FILENO, std.posix.STDOUT_FILENO, sock_path); | 40 | return pump(std.posix.STDIN_FILENO, std.posix.STDOUT_FILENO, sock_path); |
| @@ -23,21 +52,7 @@ pub fn pump(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t, sock_path: []const u8 | |||
| 23 | defer stream.close(); | 52 | defer stream.close(); |
| 24 | const sock = stream.handle; | 53 | const sock = stream.handle; |
| 25 | 54 | ||
| 26 | // Defence in depth, not a fix: Zig's start.zig already installs a noop | 55 | ignoreSigpipe(); |
| 27 | // SIGPIPE handler, so a hangup surfaces as EPIPE from write() rather than | ||
| 28 | // killing us, and the error returns below are reachable without this. What | ||
| 29 | // this pins is that the reachability belongs to this file instead of to a | ||
| 30 | // std default (`std.options.keep_sigpipe`) another module could flip. The | ||
| 31 | // one real difference between the two: SIG_IGN survives exec, a handler | ||
| 32 | // does not — which is why client.zig installs its ignore only after | ||
| 33 | // spawning the transport child, and why the order matters there and not | ||
| 34 | // here (the proxy spawns nothing). | ||
| 35 | var ign: std.posix.Sigaction = .{ | ||
| 36 | .handler = .{ .handler = std.posix.SIG.IGN }, | ||
| 37 | .mask = std.posix.sigemptyset(), | ||
| 38 | .flags = 0, | ||
| 39 | }; | ||
| 40 | std.posix.sigaction(std.posix.SIG.PIPE, &ign, null); | ||
| 41 | 56 | ||
| 42 | var buf: [64 * 1024]u8 = undefined; | 57 | var buf: [64 * 1024]u8 = undefined; |
| 43 | while (true) { | 58 | while (true) { |