src/server/server_agent.zig
Ref: Size: 16.3 KiB History
//! The daemon's agent-forwarding relay: the channel table, the counters that
//! explain a refusal, and the private directory the per-session
//! `SSH_AUTH_SOCK`s are bound in.
//!
//! It needs exactly four things back from the daemon — `agentAnswerer`,
//! `queueFrame`, `revokeAgentOffer`, and a session's listening fd — handed a
//! `*Server` per call rather than held, since `Server` is returned by value
//! from `init` and a back-pointer would name the copy left behind.
//!
//! The relay never reads `ClientSlot`: a client is a slot index to it, and every
//! rule about which index that is lives behind those four calls.
const std = @import("std");
const proto = @import("term").protocol;
const server_os = @import("server_os");
const xdg = @import("xdg");
const serve = @import("serve");
const srv_mod = @import("server.zig");
const Server = srv_mod.Server;
/// A bound, listening `SSH_AUTH_SOCK` for one session: the listener the
/// daemon accepts on and the name the shell was handed. The two travel
/// together because they die together — see `release`.
pub const AgentSock = struct {
bound: serve.Bound,
path: [:0]const u8,
/// Close AND unlink as one act: a leftover socket file outlives the
/// session that owned it, and `Server.deinit` is too late for a live
/// daemon. `Bound.close`'s guard is what keeps this from unlinking a
/// SUCCESSOR's socket — a session ended and another born under the same
/// name puts two owners on one path, and only the newest may be deleted
/// by it.
pub fn release(self: *AgentSock, alloc: std.mem.Allocator) void {
_ = self.bound.close(self.path);
alloc.free(self.path);
}
};
/// The daemon's half of `proto.agent_chans_max` — the table's size is a
/// wire fact, not this module's choice, because the client sizes its own
/// from the same number.
pub const max_agent_chans = proto.agent_chans_max;
/// `client` and `session` decide who may speak for a channel: the client because
/// ids are daemon-wide and a guessed one must not reach a stranger's ssh-agent,
/// the session because a channel dies with the shell that dialled it even when
/// its client has attached elsewhere first — which is the case `killSessionChans`
/// exists for, and the reason `client` alone is not enough to identify a channel.
/// No buffer here: the daemon never holds agent bytes.
pub const AgentChan = struct {
fd: std.posix.fd_t,
id: u32,
client: usize,
session: usize,
/// The answer clock: from the first request handed to the client until its
/// first reply. Started by the REQUEST, because until ssh asks, the client
/// owes nothing. One reply stops it for good: an `agent_offer` only claims
/// the client can reach an agent, and a reply is the proof of that claim,
/// which is why no later request restarts the clock. The bound it is
/// measured against is `AgentRelay.answer_ms`.
answer: union(enum) { unasked, asked: i64, proven } = .unasked,
};
/// Whether closing a channel owes its client an `agent_close`. `.silent` is
/// for the closes the client already knows about — its own close request,
/// and its own death.
pub const AgentCloseKind = enum { notify, silent };
/// The agent half of the daemon, as one value.
pub const AgentRelay = struct {
/// The private directory this daemon's per-session agent sockets live
/// in, or null when one could not be made — in which case no session
/// gets an `SSH_AUTH_SOCK` and every session still works. Owned;
/// removed with its contents in deinit.
dir: ?[]const u8 = null,
/// The live agent connections, across every session. Flat rather than
/// per-session because routing already carries the session index on the
/// channel: one table is one place to sweep from, and both teardown
/// paths sweep.
chans: [max_agent_chans]?AgentChan = @splat(null),
/// Agent dials this daemon turned away, by reason. Refusal is invisible
/// on the wire by design — ssh sees a closed socket and moves on — so
/// without these the field question ("forwarding stopped working, is
/// the table full?") has no answer at all.
refused_no_offer: u32 = 0,
refused_full: u32 = 0,
/// Whether the current full-table episode has been reported. One line per
/// episode, not per dial: ssh retries on refusal, so logging each dial would
/// fill the daemon log fast enough to bury the line that explains it.
full_said: bool = false,
/// The next channel id to hand out. Starts at 1 so an id is never
/// confused with an absent one at a glance, and wraps — `nextId`
/// is what keeps a wrap from colliding with a live channel.
next_id: u32 = 1,
/// How long a client may sit on a channel's FIRST forwarded request before
/// the daemon hangs up for it. An `agent_offer` is a declaration, not a
/// capability: a peer that cannot answer does not degrade forwarding, it
/// WEDGES it. Measured: ssh blocks past 8s on a socket that accepts and
/// never replies, where one that closes falls through to its other methods
/// in 2ms (decisions.md).
///
/// Only the first request is clocked. One reply proves the peer speaks for
/// an agent, and a later SIGN may legitimately wait on a human touching a
/// hardware key.
///
/// 5 s is ten times the client-side preflight's round-trip bound
/// (`mux_main.agent_probe_ms`), so this can never be what separates a slow
/// agent from a refusing one — that distinction is the preflight's job, and
/// this is only here to catch a peer that never answers at all. A field
/// rather than a const so a test does not have to wait it out.
answer_ms: i64 = 5000,
/// What `statsText` prints and `writeManifestTo` carries. A struct so
/// neither has to know the counters are two fields rather than one.
pub const Counters = struct { refused_no_offer: u32, refused_full: u32 };
pub fn counters(self: *const AgentRelay) Counters {
return .{ .refused_no_offer = self.refused_no_offer, .refused_full = self.refused_full };
}
/// with a random half: that parent is a shared `/tmp` without
/// `$XDG_RUNTIME_DIR`, a pid alone is guessable, and a symlink pre-created
/// there would put this daemon's sockets somewhere it does not own.
/// Degrades to null rather than failing the daemon, and says so on stderr —
/// from inside the shell an absent `SSH_AUTH_SOCK` looks like no `-A`.
pub fn makeDir(alloc: std.mem.Allocator, sock_path: []const u8) ?[]const u8 {
const parent = std.fs.path.dirname(sock_path) orelse ".";
// A SIGKILLed predecessor left its directory here with nothing
// running to remove it; this daemon is the first since to look.
xdg.reapDeadPid(parent, "mux-agent-");
const dir = std.fmt.allocPrint(
alloc,
"{s}/mux-agent-{d}-{x:0>12}",
.{ parent, server_os.getpid(), std.crypto.random.int(u48) },
) catch {
std.debug.print(
"mux d: agent forwarding unavailable (out of memory naming the " ++
"socket directory under {s})\n",
.{parent},
);
return null;
};
xdg.makeNewPrivateDir(dir) catch |err| {
std.debug.print("mux d: agent forwarding unavailable ({s}: {t})\n", .{ dir, err });
alloc.free(dir);
return null;
};
return dir;
}
/// Bind and listen on `agent-<name>.sock` in `dir`, or answer null — never
/// an error. Every caller is creating a session and a session outlives
/// forwarding, so the two ways to have no socket are one answer. The 0700
/// DIRECTORY is the access boundary, not the socket's own mode.
pub fn bindSock(alloc: std.mem.Allocator, dir: ?[]const u8, name: []const u8) ?AgentSock {
const d = dir orelse return null;
const path = std.fmt.allocPrintSentinel(
alloc,
"{s}/agent-{s}.sock",
.{ d, name },
0,
) catch return null;
var bound = false;
defer if (!bound) alloc.free(path);
// Backlog 8, not the default 128: the only dialler is the ssh clients
// of one session's shell, so the queue can only be as deep as the
// commands one person has started at once. `clobber_own` because the
// name is `agent-<session>.sock` inside a directory this daemon made
// and owns: anything already at it is a previous us. Overlong paths
// are initUnix's refusal inside `serve.bind` (sockpath.max_sun_path
// is the same 107), not a second rule stated here. CLOEXEC is
// `serve.BindOpts`'s default and is right here: this daemon forks a
// shell per session, and a listener leaked into one is a socket that
// shell could serve. `mux d upgrade` still carries it across the exec
// — `Server.execUpgrade` clears the flag on this fd by name right
// before execve and `Server.sealAdoptedFds` puts it back on the far
// side, so the fds that cross say so one at a time rather than
// standing open to every child.
const b = serve.bind(path, .{ .policy = .clobber_own, .backlog = 8 }) catch |err| {
std.debug.print("mux d: no agent socket for session {s} ({t})\n", .{ name, err });
return null;
};
bound = true;
return .{ .bound = b, .path = path };
}
/// Refusal counts only mean something next to it: 0 is nobody offering.
pub fn live(self: *const AgentRelay) usize {
return srv_mod.countLive(&self.chans);
}
fn freeSlot(self: *const AgentRelay) ?usize {
for (self.chans, 0..) |slot, s| {
if (slot == null) return s;
}
return null;
}
/// Ids need only be unique among the live channels; skipping the ones in
/// the table keeps that true across the counter's wrap instead of arguing
/// four billion dials cannot happen.
pub fn nextId(self: *AgentRelay) u32 {
outer: while (true) {
const id = self.next_id;
self.next_id +%= 1;
for (self.chans) |slot| {
if (slot) |ch| {
if (ch.id == id) continue :outer;
}
}
return id;
}
}
/// Matching on both is the access rule, not a convenience: ids are daemon-
/// wide, so a client that guessed another's number would otherwise be
/// talking into a stranger's ssh-agent.
pub fn find(self: *const AgentRelay, id: u32, owner: usize) ?usize {
for (self.chans, 0..) |slot, s| {
const ch = slot orelse continue;
if (ch.id == id and ch.client == owner) return s;
}
return null;
}
/// One dial at a session's `SSH_AUTH_SOCK`, routed or refused. Refusing
/// means closing AT ONCE: ssh reads that as "agent refused operation" and
/// falls through to its other methods, where a connection accepted and left
/// silent makes it wait out a timeout on every dial. The socket lives as
/// long as the session; only the answer comes and goes.
pub fn accept(self: *AgentRelay, srv: *Server, si: usize) void {
// CLOEXEC for the same reason the listener has it: this daemon
// forks a shell per session, and a live agent connection leaked
// into one would outlive the ssh that opened it, holding a channel
// open against a client that has long since gone.
const fd = std.posix.accept(
srv.ses(si).agentFd(),
null,
null,
std.posix.SOCK.CLOEXEC,
) catch return;
var routed = false;
defer if (!routed) std.posix.close(fd);
const target = srv.agentAnswerer(si) orelse {
self.refused_no_offer +%= 1;
return;
};
const s = self.freeSlot() orelse {
self.refused_full +%= 1;
if (!self.full_said) {
self.full_said = true;
std.debug.print(
"mux d: agent channel table full ({d}); forwarding refused until one frees\n",
.{max_agent_chans},
);
}
return;
};
// Stored BEFORE the frame is queued, so that a client that dies
// inside queueFrame is cleaned up by dropClient's own sweep — one
// owner for this close instead of two paths racing to it. From here
// the channel is the table's, never this frame's.
self.chans[s] = .{ .fd = fd, .id = self.nextId(), .client = target, .session = si };
routed = true;
_ = srv.queueFrame(target, .agent_open, &proto.encodeAgentId(self.chans[s].?.id));
}
/// Bytes off one agent connection, handed to the client that owns it. BLIND:
/// the id is prefixed and the rest copied through unread, because a daemon
/// that parsed the agent protocol would be a second implementation of it.
pub fn service(self: *AgentRelay, srv: *Server, s: usize) void {
const ch = self.chans[s].?;
// The id written first and read into the space after it, so the
// frame's payload is one buffer rather than a concatenation.
var buf: [proto.agent_id_len + proto.agent_data_max]u8 = undefined;
@memcpy(buf[0..proto.agent_id_len], &proto.encodeAgentId(ch.id));
const n = std.posix.read(ch.fd, buf[proto.agent_id_len..]) catch 0;
if (n == 0) {
// EOF, or a connection that broke: the far end is gone and the
// client is waiting on a reply that is never coming.
self.closeChan(srv, s, .notify);
return;
}
if (ch.answer == .unasked) {
self.chans[s].?.answer = .{ .asked = std.time.milliTimestamp() };
}
// Drop-on-backpressure, per `queueFrame`'s contract: an agent exchange is
// one or two KB against an 8 MiB cap, so tripping it means the peer
// stopped reading. A false return is a client already dropped.
_ = srv.queueFrame(ch.client, .agent_data, buf[0 .. proto.agent_id_len + n]);
}
/// Close one channel and forget it. The slot is nulled BEFORE the
/// notification, because queueFrame can drop the client, and a sweep
/// running out of that drop must not find this fd a second time.
pub fn closeChan(self: *AgentRelay, srv: *Server, s: usize, kind: AgentCloseKind) void {
const ch = self.chans[s] orelse return;
std.posix.close(ch.fd);
self.chans[s] = null;
// A freed slot ends the full-table episode, so the next one is
// worth a line again: two outages an hour apart are two events.
self.full_said = false;
if (kind == .notify) {
_ = srv.queueFrame(ch.client, .agent_close, &proto.encodeAgentId(ch.id));
}
}
/// Hang up on every channel whose client has sat on its first request
/// past `agent_answer_ms`, and take that client's offer away: the next
/// dial must route to a peer that answers, or every ssh pays the bound
/// before falling through. Granularity is the pump, like checkAwaits.
pub fn sweepMute(self: *AgentRelay, srv: *Server) void {
const now = std.time.milliTimestamp();
for (0..max_agent_chans) |s| {
const ch = self.chans[s] orelse continue;
const since = switch (ch.answer) {
.asked => |t| now - t,
else => continue,
};
if (since < self.answer_ms) continue;
srv.revokeAgentOffer(ch.client);
self.closeChan(srv, s, .notify);
}
}
/// Close every channel this client owns, silently: the peer that would
/// be told is the one that has gone. Runs from teardownClient, the one
/// path every client death takes.
pub fn closeOfClient(self: *AgentRelay, srv: *Server, i: usize) void {
for (0..max_agent_chans) |s| {
const ch = self.chans[s] orelse continue;
if (ch.client == i) self.closeChan(srv, s, .silent);
}
}
/// Reachable with a client still alive — one that reattached elsewhere
/// keeps its channels while their session dies underneath them — so this
/// one notifies.
pub fn closeOfSession(self: *AgentRelay, srv: *Server, si: usize) void {
for (0..max_agent_chans) |s| {
const ch = self.chans[s] orelse continue;
if (ch.session == si) self.closeChan(srv, s, .notify);
}
}
};