a73x

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);
        }
    }
};