src/server/server_test_agent.zig
Ref: Size: 43.0 KiB History
const std = @import("std");
const proto = @import("term").protocol;
const quic = @import("quic");
const quic_server = @import("quic_server.zig");
const h = @import("server_test_harness.zig");
const dial = h.dial;
const srv_mod = @import("server.zig");
const Server = srv_mod.Server;
const max_agent_chans = srv_mod.max_agent_chans;
const attachNamed = h.attachNamed;
const ClientSlot = h.ClientSlot;
const Lead = h.Lead;
const pumpUntil = h.pumpUntil;
const awaitFrame = h.awaitFrame;
const awaitGridText = h.awaitGridText;
const findFrame = h.findFrame;
const quicPump = h.quicPump;
const quicTestServer = h.quicTestServer;
const writeDyingGapShell = h.writeDyingGapShell;
/// `statFile` opens, and open(2) on a unix socket is ENXIO, so the obvious
/// spelling reports a missing socket for one that is right there.
fn isSocketAt(path: [:0]const u8) bool {
const st = std.posix.fstatatZ(std.posix.AT.FDCWD, path, 0) catch return false;
return std.posix.S.ISSOCK(st.mode);
}
test "Server: a session shell is born with a live SSH_AUTH_SOCK" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agentenv", .{ .shell = "/bin/sh" });
defer td.deinit();
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
try std.testing.expect(td.srv.ses(0).agentFd() >= 0);
try std.testing.expect(isSocketAt(path));
// And the shell was TOLD, which is the half no field can answer: the
// pair reaches the child through the spawn's environment or not at all.
// Matched on the basename rather than on `test -S` alone, because the
// box running this test may itself be inside an ssh session — an
// inherited SSH_AUTH_SOCK would pass a bare "is it a socket" with the
// daemon's own value never set.
const cmd = try std.fmt.allocPrint(
alloc,
"case \"$SSH_AUTH_SOCK\" in */{s}) test -S \"$SSH_AUTH_SOCK\" && echo AGENT\"\"OK ;; esac\n",
.{std.fs.path.basename(path)},
);
defer alloc.free(cmd);
try proto.writeAllFd(td.srv.ses(0).pty.master, cmd);
// The typed line the pty echoes back carries AGENT""OK, so only the
// command's own output can satisfy this needle.
try std.testing.expect(try awaitGridText(alloc, &td.srv, "AGENTOK", 5000));
}
test "Server: a reaped session takes its agent socket with it" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "agentreap");
defer td.deinit();
const script = try writeDyingGapShell(alloc, &td.tmp);
defer alloc.free(script);
try td.start(.{ .shell = script });
// Copied, because the reap frees the session's own: the whole point is
// to ask about the path after the session that owned it is gone.
const path = try alloc.dupeZ(u8, td.srv.ses(0).agentPath() orelse return error.NoAgentSocket);
defer alloc.free(path);
try std.testing.expect(isSocketAt(path));
// The script reads twice before it exits; the first line is what makes
// the shell live and pumping before the second one kills it.
try proto.writeAllFd(td.srv.ses(0).pty.master, "go\n");
try std.testing.expect(try awaitGridText(alloc, &td.srv, "after-osc", 3000));
try proto.writeAllFd(td.srv.ses(0).pty.master, "die\n");
var reaped = false;
for (0..600) |_| {
td.srv.pumpOnce(5) catch break;
if (td.srv.sessions.table[0] == null) {
reaped = true;
break;
}
}
try std.testing.expect(reaped);
// Unlinked with the close, not merely closed: a name left behind is
// what stops the next session of the same name from binding, and
// deinit's deleteTree is too late for a daemon that runs on.
try std.testing.expect(!isSocketAt(path));
}
test "Server: agent_offer flags the slot, and an unknown type leaves the client answering" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "offer", .{ .shell = "/bin/cat" });
defer td.deinit();
const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "");
defer c.close();
const slot: ClientSlot = .{ .srv = &td.srv, .n = 0 };
try std.testing.expect(try pumpUntil(&td.srv, 2000, slot, ClientSlot.seated));
// The offer is opt-in, so the flag must start false or every client
// would look like it had offered.
try std.testing.expect(!slot.offering());
try proto.writeFrame(c.handle, .agent_offer, "");
try std.testing.expect(try pumpUntil(&td.srv, 2000, slot, ClientSlot.offering));
// 0x7e is unmapped in MsgType. Skipping it rather than dropping the
// client is what lets a client offering agent forwarding talk to a
// daemon built before agent_offer existed, so the liveness assertion is
// the point: the client is still seated AND still answering.
try proto.writeFrame(c.handle, @enumFromInt(0x7e), "");
try proto.writeFrame(c.handle, .stats_req, "");
const reply = try awaitFrame(alloc, &td.srv, c.handle, .stats_reply, 400);
defer if (reply) |f| f.deinit(alloc);
try std.testing.expect(reply != null);
try std.testing.expect(td.srv.clients[0] != null);
}
// ---------------------------------------------------------------------------
// The blind pump: a connection to a session's SSH_AUTH_SOCK, routed to a
// client or refused, and bytes carried both ways without the daemon reading
// them. Every assertion below is made through the wire — an agent connection
// on one side, frames on the other — because that pair IS the feature; the
// channel table is only how the daemon remembers it.
// ---------------------------------------------------------------------------
/// Test helper: pump until `fd` has something to say, then take it once.
/// Null means it never spoke inside the budget, which the absence probes
/// below read as "and never would have"; 0 is EOF, an answer in its own
/// right. The budget is milliseconds of pumping, not a round count — the
/// counts it replaced were 5 ms rounds wearing no unit at all.
fn pumpUntilReadable(srv: *Server, fd: std.posix.fd_t, buf: []u8, budget_ms: u64) !?usize {
const Readable = struct {
fd: std.posix.fd_t,
fn yes(self: @This()) bool {
var pfd = [_]std.posix.pollfd{
.{ .fd = self.fd, .events = std.posix.POLL.IN, .revents = 0 },
};
return (std.posix.poll(&pfd, 0) catch 0) != 0;
}
};
if (!try pumpUntil(srv, budget_ms, Readable{ .fd = fd }, Readable.yes)) return null;
return std.posix.read(fd, buf) catch 0;
}
/// Test helper: seat a client in `slot` and have it volunteer an agent,
/// pumping until the daemon holds both facts. Attaches one at a time by
/// contract — freeClientSlot hands out the lowest free slot, so the caller's
/// slot number is only the attach order if nobody attaches concurrently.
fn attachOffering(srv: *Server, fd: std.posix.fd_t, slot: usize, name: []const u8) !void {
try attachNamed(fd, 80, 24, name);
const s: ClientSlot = .{ .srv = srv, .n = slot };
if (!try pumpUntil(srv, 2000, s, ClientSlot.seated)) return error.ClientNeverSeated;
try proto.writeFrame(fd, .agent_offer, "");
if (!try pumpUntil(srv, 2000, s, ClientSlot.offering)) return error.OfferNeverLanded;
}
test "Server: a session with no agent socket does not inherit the daemon's" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "agentless");
defer td.deinit();
// The daemon is given an agent of its own, which is the whole hazard:
// the daemon is often started from a desktop session that has one, and every
// session it spawns inherits whatever the daemon's own environment holds.
const inherited = try std.fmt.allocPrintSentinel(alloc, "{s}/daemons-own.sock", .{td.tmp.path()}, 0);
defer alloc.free(inherited);
const libc = @cImport({
@cInclude("stdlib.h");
});
const prior = std.posix.getenv(proto.agent_sock_env);
const prior_z = if (prior) |p| try alloc.dupeZ(u8, p) else null;
defer if (prior_z) |p| alloc.free(p);
_ = libc.setenv(proto.agent_sock_env, inherited.ptr, 1);
defer if (prior_z) |p| {
_ = libc.setenv(proto.agent_sock_env, p.ptr, 1);
} else {
_ = libc.unsetenv(proto.agent_sock_env);
};
try td.start(.{ .shell = "/bin/sh" });
// Every later failure of makeAgentDir looks exactly like this: no
// directory, so no session bound after it gets a socket. Reached by
// nulling the field the failure nulls rather than by a stand-in — and
// freeing what deinit can no longer see, which the real failure never
// allocated in the first place.
if (td.srv.agents.dir) |d| alloc.free(d);
td.srv.agents.dir = null;
const probe = try std.fmt.allocPrint(alloc, "{s}/probe", .{td.tmp.path()});
defer alloc.free(probe);
const done = try std.fmt.allocPrint(alloc, "{s}/probe.done", .{td.tmp.path()});
defer alloc.free(done);
const cmd = try std.fmt.allocPrint(
alloc,
"printf %s \"${{{s}-UNSET}}\" > {s}; printf y > {s}\n",
.{ proto.agent_sock_env, probe, done },
);
defer alloc.free(cmd);
const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "nosock");
defer c.close();
if (!try pumpUntil(&td.srv, 2000, ClientSlot{ .srv = &td.srv, .n = 0 }, ClientSlot.seated))
return error.ClientNeverSeated;
const si = td.srv.clients[0].?.session orelse return error.ClientNeverSeated;
try std.testing.expect(td.srv.sessions.table[si].?.agentPath() == null);
try proto.writeFrame(c.handle, .input, cmd);
const Wrote = struct {
path: []const u8,
fn yes(self: @This()) bool {
std.fs.accessAbsolute(self.path, .{}) catch return false;
return true;
}
};
if (!try pumpUntil(&td.srv, 3000, Wrote{ .path = done }, Wrote.yes))
return error.ShellNeverAnswered;
const got = std.fs.cwd().readFileAlloc(alloc, probe, 4096) catch
return error.ShellNeverAnswered;
defer alloc.free(got);
// Not the daemon's: a shell pointed at the daemon's own ssh-agent reaches past
// every client watching it, which is the one thing the socket exists to
// stop. "No socket" has to mean no agent, not somebody else's.
try std.testing.expectEqualSlices(u8, "UNSET", got);
}
test "Server: a full channel table refuses the newest dial and says so once" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agentfull", .{ .shell = "/bin/cat" });
defer td.deinit();
const c = try dial.dial(td.sock_path);
defer c.close();
try attachOffering(&td.srv, c.handle, 0, "");
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
// Exactly the table, then one more. ssh opens a channel per auth attempt,
// so a long-lived agent connection holding a slot is the field failure
// this counts — eight of them turn forwarding off for every session.
var dials: [max_agent_chans + 1]std.net.Stream = undefined;
// Pump between dials, because the listen backlog is `max_agent_chans` and
// this dials one more than that. A queue that nobody accepts from is full
// at the last dial, and the two kernels answer that differently: Linux
// stretches, Darwin refuses the connect outright with ECONNREFUSED, which
// failed the test before the daemon had a chance to say anything. Letting
// the daemon accept between dials is also what the field looks like — ssh
// opens its channels one at a time against a running daemon.
for (&dials) |*d| {
d.* = try dial.dial(path);
try td.srv.pumpOnce(5);
}
defer for (dials) |d| d.close();
const Refusals = struct {
srv: *Server,
want: u32,
fn reached(self: @This()) bool {
return self.srv.agents.refused_full >= self.want;
}
};
try std.testing.expect(try pumpUntil(&td.srv, 2000, Refusals{ .srv = &td.srv, .want = 1 }, Refusals.reached));
for (td.srv.agents.chans) |slot| try std.testing.expect(slot != null);
try std.testing.expectEqual(@as(u32, 1), td.srv.agents.refused_full);
try std.testing.expectEqual(@as(u32, 0), td.srv.agents.refused_no_offer);
// Refused means closed, not accepted and left silent: ssh reads the
// hangup as "agent refused" and moves on, where silence costs it a
// timeout on every dial.
var buf: [8]u8 = undefined;
try std.testing.expectEqual(@as(?usize, 0), try pumpUntilReadable(&td.srv, dials[max_agent_chans].handle, &buf, 300));
// Ids are unique across the live table, which is the only property both
// ends rely on — and the counter's wrap must not break it.
var seen: [max_agent_chans]u32 = undefined;
for (td.srv.agents.chans, 0..) |slot, i| seen[i] = slot.?.id;
for (seen, 0..) |id, i| for (seen[i + 1 ..]) |other| try std.testing.expect(id != other);
td.srv.agents.next_id = seen[3];
try std.testing.expect(td.srv.agents.nextId() != seen[3]);
// "Once", which is the half the name claims and nothing asserted: a
// second dial against the same full table counts again and says nothing,
// and only a freed slot re-arms the line. ssh retries, so a log that
// scrolled would be its own outage.
try std.testing.expect(td.srv.agents.full_said);
const again = try dial.dial(path);
defer again.close();
try std.testing.expect(try pumpUntil(&td.srv, 2000, Refusals{ .srv = &td.srv, .want = 2 }, Refusals.reached));
try std.testing.expectEqual(@as(u32, 2), td.srv.agents.refused_full);
try std.testing.expect(td.srv.agents.full_said);
td.srv.agents.closeChan(&td.srv, 0, .notify);
try std.testing.expect(!td.srv.agents.full_said);
}
test "Server: an agent connection with nobody offering is refused fast" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agentrefuse", .{ .shell = "/bin/cat" });
defer td.deinit();
const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "");
defer c.close();
const slot0: ClientSlot = .{ .srv = &td.srv, .n = 0 };
try std.testing.expect(try pumpUntil(&td.srv, 2000, slot0, ClientSlot.seated));
// Attached but never offered — the case this test is about. The socket
// exists for as long as the session does; only the answer comes and goes.
try std.testing.expect(!slot0.offering());
// An offerer, but watching a DIFFERENT session. A socket per session
// exists so a dial can be attributed to one shell; an answerer taken
// from the daemon at large would hand this session's ssh the key of
// somebody sitting in front of another one.
const other = try dial.dial(td.sock_path);
defer other.close();
try attachOffering(&td.srv, other.handle, 1, "b");
try std.testing.expect(td.srv.clients[1].?.session != td.srv.clients[0].?.session);
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
const agent = try dial.dial(path);
defer agent.close();
// EOF, and quickly: ssh reads a closed agent socket as "agent refused
// operation" and falls straight through to its other auth methods,
// where a connection left open and silent would make it wait out a
// timeout on every dial instead.
var buf: [16]u8 = undefined;
const n = try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000);
try std.testing.expectEqual(@as(?usize, 0), n);
}
test "Server: agent bytes pump both ways through a channel" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agentpump", .{ .shell = "/bin/cat" });
defer td.deinit();
const c = try dial.dial(td.sock_path);
defer c.close();
try attachOffering(&td.srv, c.handle, 0, "");
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
const agent = try dial.dial(path);
defer agent.close();
// The daemon allocates the id and announces the channel; the client
// never asks for one, because only the daemon knows a connection
// happened.
const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
return error.NoAgentOpen;
defer open.deinit(alloc);
const id = try proto.decodeAgentId(open.payload);
// Client to agent. "req" is not an agent request and deliberately so:
// the daemon must carry bytes it cannot parse, or it has opinions about
// a protocol it has no stake in.
var req: [proto.agent_id_len + 3]u8 = undefined;
@memcpy(req[0..proto.agent_id_len], &proto.encodeAgentId(id));
@memcpy(req[proto.agent_id_len..], "req");
try proto.writeFrame(c.handle, .agent_data, &req);
var buf: [64]u8 = undefined;
const n = (try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000)) orelse
return error.NoAgentRequest;
try std.testing.expectEqualSlices(u8, "req", buf[0..n]);
// Agent to client, on the same id.
try proto.writeAllFd(agent.handle, "resp");
const data = (try awaitFrame(alloc, &td.srv, c.handle, .agent_data, 200)) orelse
return error.NoAgentReply;
defer data.deinit(alloc);
try std.testing.expectEqual(id, try proto.decodeAgentId(data.payload));
try std.testing.expectEqualSlices(u8, "resp", data.payload[proto.agent_id_len..]);
// Half-closed rather than closed, so the test's own `defer` is still the
// only close of this descriptor. The daemon sees the same read of 0 it
// would from an ssh that finished and exited.
try std.posix.shutdown(agent.handle, .send);
const closed = (try awaitFrame(alloc, &td.srv, c.handle, .agent_close, 200)) orelse
return error.NoAgentClose;
defer closed.deinit(alloc);
try std.testing.expectEqual(id, try proto.decodeAgentId(closed.payload));
}
test "Server: an agent connection is routed to the latest-active offerer" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agentroute", .{ .shell = "/bin/cat" });
defer td.deinit();
const ca = try dial.dial(td.sock_path);
defer ca.close();
try attachOffering(&td.srv, ca.handle, 0, "");
const cb = try dial.dial(td.sock_path);
defer cb.close();
try attachOffering(&td.srv, cb.handle, 1, "");
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
// Two dials, with the lead changing hands in between, because ONE dial
// cannot tell the rule apart from the wrong ones: whoever offered first,
// the lowest slot, the highest slot, and "decided once for the daemon"
// each answer a single connection identically to latest-wins. Only the
// pair of answers disagrees.
try proto.writeFrame(cb.handle, .input, "x");
try std.testing.expect(try pumpUntil(&td.srv, 2000, Lead{ .srv = &td.srv, .ahead = 1, .behind = 0 }, Lead.taken));
const first = try dial.dial(path);
defer first.close();
const to_b = (try awaitFrame(alloc, &td.srv, cb.handle, .agent_open, 200)) orelse
return error.NoAgentOpenForB;
defer to_b.deinit(alloc);
// And A is not told, which is the half that matters for keys: an
// announcement to every offerer would be every offerer's agent asked to
// sign. With the positive already asserted, a bounded null here means
// the frame was routed, not merely slow.
const stray_a = try awaitFrame(alloc, &td.srv, ca.handle, .agent_open, 60);
defer if (stray_a) |f| f.deinit(alloc);
try std.testing.expect(stray_a == null);
// A takes the lead back, and the NEXT dial follows it. The channel
// already open stays B's regardless — it is mid-exchange with an ssh
// that would fail the signature rather than change identity.
try proto.writeFrame(ca.handle, .input, "y");
try std.testing.expect(try pumpUntil(&td.srv, 2000, Lead{ .srv = &td.srv, .ahead = 0, .behind = 1 }, Lead.taken));
const second = try dial.dial(path);
defer second.close();
const to_a = (try awaitFrame(alloc, &td.srv, ca.handle, .agent_open, 200)) orelse
return error.NoAgentOpenForA;
defer to_a.deinit(alloc);
try std.testing.expect(
(try proto.decodeAgentId(to_a.payload)) != (try proto.decodeAgentId(to_b.payload)),
);
const stray_b = try awaitFrame(alloc, &td.srv, cb.handle, .agent_open, 60);
defer if (stray_b) |f| f.deinit(alloc);
try std.testing.expect(stray_b == null);
}
test "Server: agent_data for an unknown or another client's channel is dropped" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agentforeign", .{ .shell = "/bin/cat" });
defer td.deinit();
const ca = try dial.dial(td.sock_path);
defer ca.close();
try attachOffering(&td.srv, ca.handle, 0, "");
const cb = try dial.dial(td.sock_path);
defer cb.close();
try attachOffering(&td.srv, cb.handle, 1, "");
// B attached last, so the channel is B's.
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
const agent = try dial.dial(path);
defer agent.close();
const open = (try awaitFrame(alloc, &td.srv, cb.handle, .agent_open, 200)) orelse
return error.NoAgentOpen;
defer open.deinit(alloc);
const id = try proto.decodeAgentId(open.payload);
// A names B's id, and then an id nobody holds. Neither may reach the
// socket: ids are daemon-wide, so a client that guessed one would
// otherwise be talking into a stranger's ssh-agent.
var foreign: [proto.agent_id_len + 8]u8 = undefined;
@memcpy(foreign[0..proto.agent_id_len], &proto.encodeAgentId(id));
@memcpy(foreign[proto.agent_id_len..], "trespass");
try proto.writeFrame(ca.handle, .agent_data, &foreign);
var unknown: [proto.agent_id_len + 1]u8 = undefined;
@memcpy(unknown[0..proto.agent_id_len], &proto.encodeAgentId(id +% 1000));
unknown[proto.agent_id_len] = 'x';
try proto.writeFrame(cb.handle, .agent_data, &unknown);
var buf: [64]u8 = undefined;
try std.testing.expect((try pumpUntilReadable(&td.srv, agent.handle, &buf, 300)) == null);
// Dropped, not filed: an id nobody holds must not be an id anybody can
// conjure a channel with.
try std.testing.expect(td.srv.agents.chans[1] == null);
// The positive control, so the silence above is a refusal and not a
// pump that was never working: the owner's bytes still land.
var mine: [proto.agent_id_len + 2]u8 = undefined;
@memcpy(mine[0..proto.agent_id_len], &proto.encodeAgentId(id));
@memcpy(mine[proto.agent_id_len..], "ok");
try proto.writeFrame(cb.handle, .agent_data, &mine);
const n = (try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000)) orelse
return error.NoAgentRequest;
try std.testing.expectEqualSlices(u8, "ok", buf[0..n]);
// And neither sender was punished for asking: an id that names nothing
// is dropped like an unknown frame type, not answered with a hangup.
try std.testing.expect(td.srv.clients[0] != null);
try std.testing.expect(td.srv.clients[1] != null);
try proto.writeFrame(ca.handle, .stats_req, "");
const reply = try awaitFrame(alloc, &td.srv, ca.handle, .stats_reply, 400);
defer if (reply) |f| f.deinit(alloc);
try std.testing.expect(reply != null);
}
test "Server: an agent_data frame past the cap hangs the channel up" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agentbig", .{ .shell = "/bin/cat" });
defer td.deinit();
const c = try dial.dial(td.sock_path);
defer c.close();
try attachOffering(&td.srv, c.handle, 0, "");
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
const agent = try dial.dial(path);
defer agent.close();
const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
return error.NoAgentOpen;
defer open.deinit(alloc);
const id = try proto.decodeAgentId(open.payload);
// One byte past what any shipped sender produces. `agent_data_max` is a
// send-side buffer size, so the only thing making it a rule on this side
// is this refusal — and the write it guards is blocking.
const over = try alloc.alloc(u8, proto.agent_id_len + proto.agent_data_max + 1);
defer alloc.free(over);
@memset(over, 'x');
@memcpy(over[0..proto.agent_id_len], &proto.encodeAgentId(id));
try proto.writeFrame(c.handle, .agent_data, over);
// The channel goes, and the client hears so: it is holding an fd to the
// local agent that nothing will answer on again.
const closed = (try awaitFrame(alloc, &td.srv, c.handle, .agent_close, 200)) orelse
return error.NoAgentClose;
defer closed.deinit(alloc);
try std.testing.expectEqual(id, try proto.decodeAgentId(closed.payload));
try std.testing.expect(td.srv.agents.find(id, 0) == null);
// Not one byte of it reached the agent: a truncated request is worse
// than none, and EOF here is the hangup rather than an idle socket.
var buf: [64]u8 = undefined;
try std.testing.expectEqual(@as(?usize, 0), try pumpUntilReadable(&td.srv, agent.handle, &buf, 300));
// And the client itself survives — the frame was refused, not the peer.
try proto.writeFrame(c.handle, .stats_req, "");
const reply = try awaitFrame(alloc, &td.srv, c.handle, .stats_reply, 400);
defer if (reply) |f| f.deinit(alloc);
try std.testing.expect(reply != null);
}
test "Server: a client closing a channel hangs up the agent connection without an echo" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agentcliclose", .{ .shell = "/bin/cat" });
defer td.deinit();
const c = try dial.dial(td.sock_path);
defer c.close();
try attachOffering(&td.srv, c.handle, 0, "");
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
const agent = try dial.dial(path);
defer agent.close();
const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
return error.NoAgentOpen;
defer open.deinit(alloc);
const id = try proto.decodeAgentId(open.payload);
// The other direction of close: the client's ssh-agent hung up, or the
// client is going away tidily. The connection must end here too, or a
// channel the client has forgotten holds a descriptor forever.
try proto.writeFrame(c.handle, .agent_close, &proto.encodeAgentId(id));
var buf: [16]u8 = undefined;
try std.testing.expectEqual(
@as(?usize, 0),
try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000),
);
try std.testing.expect(td.srv.agents.chans[0] == null);
// And no agent_close comes back: the client asked for this, so an echo
// is an event it would have to learn to ignore — and one it could not
// tell from the far end closing a channel it had reopened.
const echo = try awaitFrame(alloc, &td.srv, c.handle, .agent_close, 60);
defer if (echo) |f| f.deinit(alloc);
try std.testing.expect(echo == null);
}
test "Server: the daemon listener, the clients it accepts, an agent listener and its channels are close-on-exec" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agentcloexec", .{ .shell = "/bin/cat" });
defer td.deinit();
const c = try dial.dial(td.sock_path);
defer c.close();
try attachOffering(&td.srv, c.handle, 0, "");
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
const agent = try dial.dial(path);
defer agent.close();
const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
return error.NoAgentOpen;
defer open.deinit(alloc);
// Asked of the descriptors themselves rather than read off the call
// that made them: this daemon forks a shell per session, and every
// session created after this connection would inherit anything missing
// the flag — an agent socket a shell could talk to directly, and a live
// channel that no close of ours could ever finish closing.
const flags = std.posix.FD_CLOEXEC;
try std.testing.expect(
try std.posix.fcntl(td.srv.ses(0).agentFd(), std.posix.F.GETFD, 0) & flags != 0,
);
const ch = td.srv.agents.chans[0] orelse return error.NoChannel;
try std.testing.expect(try std.posix.fcntl(ch.fd, std.posix.F.GETFD, 0) & flags != 0);
// The daemon's own listener and the client connection it accepted, on
// the same grounds and unpinned until now: `serve.bind` and the raw
// `posix.accept` that replaced `std.net` both have to be ASKED for this
// flag, where std.net set it unconditionally, so a conversion can drop
// it and stay green everywhere else.
try std.testing.expect(try std.posix.fcntl(td.srv.bound.fd, std.posix.F.GETFD, 0) & flags != 0);
const seated = td.srv.clients[0] orelse return error.ClientNeverSeated;
const seated_fd = switch (seated.sink) {
.socket => |fd| fd,
.quic => return error.NotAUnixClient,
};
try std.testing.expect(try std.posix.fcntl(seated_fd, std.posix.F.GETFD, 0) & flags != 0);
}
test "Server: a client's agent channels die with the client" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agentorphan", .{ .shell = "/bin/cat" });
defer td.deinit();
const c = try dial.dial(td.sock_path);
try attachOffering(&td.srv, c.handle, 0, "");
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
const agent = try dial.dial(path);
defer agent.close();
const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
return error.NoAgentOpen;
defer open.deinit(alloc);
try std.testing.expect(td.srv.agents.chans[0] != null);
// The client vanishes mid-exchange — a lid closed, a WAN link dropped.
// The ssh at the far end is waiting on a signature that can no longer
// be produced, so the honest answer is the one a missing agent gives:
// EOF now, not a channel held open against a client that is gone.
c.close();
var buf: [16]u8 = undefined;
const n = try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000);
try std.testing.expectEqual(@as(?usize, 0), n);
try std.testing.expect(td.srv.clients[0] == null);
try std.testing.expect(td.srv.agents.chans[0] == null);
}
test "Server: a QUIC client's agent channels die with the client" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "qagentgone", .{ .shell = "/bin/sh" });
const key: quic.Key = .{ .bytes = [_]u8{0x7E} ** quic.key_len };
const q = try quicTestServer(&td.srv, key);
// The listener outlives the server: a QUIC sink closes its connection
// THROUGH the listener, so `Server.deinit` walking its client table needs
// it alive. Defers run in reverse, so the daemon's goes second. The
// success path at the end clears this client by hand and so would not
// notice the other order; every early return before it would, with a
// live slot still in the table.
defer q.l.deinit();
defer td.deinit();
var cl = try quic_server.TestPeer.init(q.addr, key);
defer cl.deinit();
// attachOver cannot say "offering", and the offer rides its own frame
// re-sent after every attach, so both go out in one flight here.
var cbuf: std.ArrayList(u8) = .empty;
defer cbuf.deinit(alloc);
try proto.appendFrame(&cbuf, alloc, .attach, &proto.encodeAttach(80, 24, 0, 0));
try proto.appendFrame(&cbuf, alloc, .agent_offer, "");
cl.out = cbuf.items;
cl.drain();
var only = [_]*quic_server.TestPeer{&cl};
try quicPump(&td.srv, &only, 10000, &td.srv, struct {
fn f(s: *Server) bool {
return s.clients[0] != null and s.clients[0].?.agent_offer;
}
}.f);
if (td.srv.clients[0] == null or !td.srv.clients[0].?.agent_offer) return error.ClientNeverOffered;
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
const agent = try dial.dial(path);
defer agent.close();
try quicPump(&td.srv, &only, 10000, &cl, struct {
fn f(t: *quic_server.TestPeer) bool {
return findFrame(t.cl.in.items, .agent_open) != null;
}
}.f);
if (td.srv.agents.chans[0] == null) return error.NoAgentOpen;
// quic_server.Listener.kill is private; closeConn plus a hand-called
// quicOnClose is faithful to it because it repeats kill's order — the
// conn is freed, then the callback runs. The socket twin above carries
// the scenario.
const qid = td.srv.clients[0].?.sink.quic.id;
q.l.closeConn(qid);
Server.quicOnClose(@ptrCast(&td.srv), qid);
try std.testing.expect(td.srv.clients[0] == null);
// Same verdict the socket path hands down in dropClient: the ssh at
// the far end waits on a signature nobody can produce any more, and a
// channel left behind would route into whoever next takes the slot.
var abuf: [16]u8 = undefined;
try std.testing.expectEqual(
@as(?usize, 0),
try pumpUntilReadable(&td.srv, agent.handle, &abuf, 1000),
);
try std.testing.expect(td.srv.agents.chans[0] == null);
}
/// Test helper: a channel brought to the moment the answer clock starts.
fn openAndAsk(
alloc: std.mem.Allocator,
srv: *Server,
client_fd: std.posix.fd_t,
agent_fd: std.posix.fd_t,
) !u32 {
const open = (try awaitFrame(alloc, srv, client_fd, .agent_open, 200)) orelse
return error.NoAgentOpen;
defer open.deinit(alloc);
const id = try proto.decodeAgentId(open.payload);
try proto.writeAllFd(agent_fd, "req");
const data = (try awaitFrame(alloc, srv, client_fd, .agent_data, 200)) orelse
return error.RequestNeverForwarded;
data.deinit(alloc);
return id;
}
test "Server: an offerer that never answers its first request is hung up on and stops offering" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agentmute", .{ .shell = "/bin/cat" });
defer td.deinit();
td.srv.agents.answer_ms = 150;
const c = try dial.dial(td.sock_path);
defer c.close();
try attachOffering(&td.srv, c.handle, 0, "");
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
const agent = try dial.dial(path);
defer agent.close();
const id = try openAndAsk(alloc, &td.srv, c.handle, agent.handle);
// The client says nothing. Measured (decisions.md): ssh on a socket
// that accepts and never replies blocks past 8s; on one that accepts
// and closes it falls through to its next auth method in 2ms. So the
// daemon's answer to silence is the close the client should have sent.
var buf: [16]u8 = undefined;
const n = try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000);
try std.testing.expectEqual(@as(?usize, 0), n);
try std.testing.expect(td.srv.agents.chans[0] == null);
// The client is told, like any other far-end close it did not ask for.
const closed = (try awaitFrame(alloc, &td.srv, c.handle, .agent_close, 200)) orelse
return error.NoAgentClose;
defer closed.deinit(alloc);
try std.testing.expectEqual(id, try proto.decodeAgentId(closed.payload));
// And it is no longer an offerer: the next dial must not route here
// again, or every ssh pays the bound before falling through.
try std.testing.expect(!td.srv.clients[0].?.agent_offer);
const again = try dial.dial(path);
defer again.close();
try std.testing.expectEqual(@as(?usize, 0), try pumpUntilReadable(&td.srv, again.handle, &buf, 300));
try std.testing.expectEqual(@as(u32, 1), td.srv.agents.refused_no_offer);
}
test "Server: a channel that has answered once is never timed out" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agentslow", .{ .shell = "/bin/cat" });
defer td.deinit();
td.srv.agents.answer_ms = 150;
const c = try dial.dial(td.sock_path);
defer c.close();
try attachOffering(&td.srv, c.handle, 0, "");
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
const agent = try dial.dial(path);
defer agent.close();
const id = try openAndAsk(alloc, &td.srv, c.handle, agent.handle);
// One reply proves the client speaks for an agent.
var resp: [proto.agent_id_len + 4]u8 = undefined;
@memcpy(resp[0..proto.agent_id_len], &proto.encodeAgentId(id));
@memcpy(resp[proto.agent_id_len..], "resp");
try proto.writeFrame(c.handle, .agent_data, &resp);
var buf: [64]u8 = undefined;
_ = (try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000)) orelse return error.NoReply;
// A second request the client takes its time over — a SIGN against a
// token waiting for a touch. The bound is for a peer that cannot
// answer, and must not separate slow from refused (the preflight's
// rule, decisions.md): this is the arm that would pass silently if the
// clock were anchored on the open or re-armed per request.
try proto.writeAllFd(agent.handle, "req2");
const fwd = (try awaitFrame(alloc, &td.srv, c.handle, .agent_data, 200)) orelse
return error.RequestNeverForwarded;
fwd.deinit(alloc);
try std.testing.expectEqual(@as(?usize, null), try pumpUntilReadable(&td.srv, agent.handle, &buf, 500));
try std.testing.expect(td.srv.agents.chans[0] != null);
try std.testing.expect(td.srv.clients[0].?.agent_offer);
}
test "Server: bytes a client sends before it was asked prove nothing" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agenteager", .{ .shell = "/bin/cat" });
defer td.deinit();
td.srv.agents.answer_ms = 150;
const c = try dial.dial(td.sock_path);
defer c.close();
try attachOffering(&td.srv, c.handle, 0, "");
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
const agent = try dial.dial(path);
defer agent.close();
const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
return error.NoAgentOpen;
defer open.deinit(alloc);
const id = try proto.decodeAgentId(open.payload);
// Unsolicited bytes on the fresh channel. A reply is the proof; a
// volley before any question is not, or a mute peer clears the clock
// by talking first and then wedging the real request.
var eager: [proto.agent_id_len + 2]u8 = undefined;
@memcpy(eager[0..proto.agent_id_len], &proto.encodeAgentId(id));
@memcpy(eager[proto.agent_id_len..], "hi");
try proto.writeFrame(c.handle, .agent_data, &eager);
var buf: [64]u8 = undefined;
_ = (try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000)) orelse return error.NotForwarded;
// Then ssh asks and the client goes quiet: the clock must still run.
try proto.writeAllFd(agent.handle, "req");
const fwd = (try awaitFrame(alloc, &td.srv, c.handle, .agent_data, 200)) orelse
return error.RequestNeverForwarded;
fwd.deinit(alloc);
try std.testing.expectEqual(@as(?usize, 0), try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000));
try std.testing.expect(!td.srv.clients[0].?.agent_offer);
}
test "Server: a channel nobody has asked anything on is not timed out" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "agentidle", .{ .shell = "/bin/cat" });
defer td.deinit();
td.srv.agents.answer_ms = 150;
const c = try dial.dial(td.sock_path);
defer c.close();
try attachOffering(&td.srv, c.handle, 0, "");
const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
const agent = try dial.dial(path);
defer agent.close();
const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
return error.NoAgentOpen;
open.deinit(alloc);
// ssh dialled and has not asked yet. The client owes nothing until it
// has been handed a request, so a clock anchored on the open would
// hang up on a working client for the peer's pause.
var buf: [16]u8 = undefined;
try std.testing.expectEqual(@as(?usize, null), try pumpUntilReadable(&td.srv, agent.handle, &buf, 500));
try std.testing.expect(td.srv.agents.chans[0] != null);
try std.testing.expect(td.srv.clients[0].?.agent_offer);
}
test "Server: ending one session unlinks ITS agent socket and leaves every other session's alone" {
const alloc = std.testing.allocator;
// Two sessions, because one cannot show the failure this pins: a
// teardown that unlinks by name without the guard, or one that sweeps
// the agent directory, is indistinguishable from a correct one until a
// second socket is sitting there to be destroyed.
var td = try h.TestDaemon.init(alloc, "agentplural", .{ .shell = "/bin/sh" });
defer td.deinit();
const keep = try dial.dialAttachNamed(td.sock_path, 80, 24, "keep");
defer keep.close();
(try awaitFrame(alloc, &td.srv, keep.handle, .snapshot, 400) orelse
return error.NoState).deinit(alloc);
const doomed = try dial.dialAttachNamed(td.sock_path, 80, 24, "doomed");
defer doomed.close();
(try awaitFrame(alloc, &td.srv, doomed.handle, .snapshot, 400) orelse
return error.NoState).deinit(alloc);
var keep_path: ?[:0]const u8 = null;
var doomed_path: ?[:0]const u8 = null;
for (&td.srv.sessions.table) |*slot| {
const s = &(slot.* orelse continue);
if (std.mem.eql(u8, s.name(), "keep")) keep_path = s.agentPath();
if (std.mem.eql(u8, s.name(), "doomed")) doomed_path = s.agentPath();
}
const kp = keep_path orelse return error.NoAgentSocket;
// Copied, not borrowed: the ending session frees its path, and the
// assertion below has to outlive the free it is checking.
const dp = try alloc.dupeZ(u8, doomed_path orelse return error.NoAgentSocket);
defer alloc.free(dp);
try std.testing.expect(isSocketAt(kp));
try std.testing.expect(isSocketAt(dp));
var rq: [proto.end_req_max_len]u8 = undefined;
try proto.writeFrame(doomed.handle, .end_req, proto.encodeEndReq(&rq, false, "doomed"));
const r = (try awaitFrame(alloc, &td.srv, doomed.handle, .end_reply, 400)) orelse
return error.NoEndReply;
defer r.deinit(alloc);
try std.testing.expect((proto.parseEndReply(r.payload) orelse
return error.BadEndReply).accepted);
const Gone = struct {
path: [:0]const u8,
fn yes(self: @This()) bool {
return !isSocketAt(self.path);
}
};
try std.testing.expect(try pumpUntil(&td.srv, 3000, Gone{ .path = dp }, Gone.yes));
// Asked of the filesystem, not of the daemon: a table that has forgotten
// a session says nothing about whether its name left the directory, and
// the leftover file is what the next session of that name trips over.
try std.testing.expect(!isSocketAt(dp));
try std.testing.expect(isSocketAt(kp));
}
test "AgentRelay.makeDir reaps a dead daemon's agent directory beside the socket, not a live one's" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
const dead = try testtmp.deadPid();
var nb: [48]u8 = undefined;
const left = try std.fmt.bufPrint(&nb, "mux-agent-{d}-000000000000", .{dead});
try tmp.dir.makePath(left);
try tmp.dir.makePath("mux-agent-1-000000000000");
var sb: [64]u8 = undefined;
const sock = try std.fmt.bufPrint(&sb, "{s}/muxd.sock", .{tmp.path()});
const dir = @import("server_agent.zig").AgentRelay.makeDir(std.testing.allocator, sock) orelse
return error.TestUnexpectedResult;
defer std.testing.allocator.free(dir);
try std.testing.expectError(error.FileNotFound, tmp.dir.access(left, .{}));
try tmp.dir.access("mux-agent-1-000000000000", .{});
}