Skip to main content

coop/
transport.rs

1//! The seam between coop's logic and the ssh channel.
2//!
3//! Every invariant that matters was measured against a real capped host and is
4//! unreachable from a plain unit test. This trait is what makes test layer 1
5//! possible at all: `Fake` records the exact script coop would have run, so
6//! wrapper construction and state mapping are tested as pure functions.
7//!
8//! One rule shapes the whole file: **`run` takes coop's ticket lock, and
9//! `master_alive` does not.** `ssh -O check` talks only to the mux socket, opens
10//! no session channel, and measured at 0s — so exempting it is safe, and having
11//! it be a *different method* makes the exemption structural instead of a rule
12//! someone has to remember.
13
14use std::process::Command;
15
16use anyhow::{Context, Result};
17
18use crate::config::Host;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Output {
22    /// Raw bytes, never a lossy `String`.
23    ///
24    /// A job may emit a tarball, or simply invalid UTF-8, and the probe reply
25    /// carries log bytes inside this field. `String::from_utf8_lossy` would
26    /// silently substitute replacement characters — corrupting the one artifact
27    /// the entire design treats as the source of truth. Text-only would have
28    /// been a defensible scope cut; silent corruption is not.
29    pub stdout: Vec<u8>,
30    /// Text, because this is ssh's own diagnostics and `errors` pattern-matches
31    /// them. A non-UTF-8 ssh error message is not a case worth carrying bytes
32    /// for.
33    pub stderr: String,
34    pub code: i32,
35}
36
37impl Output {
38    pub fn ok(stdout: impl Into<Vec<u8>>) -> Self {
39        Self {
40            stdout: stdout.into(),
41            stderr: String::new(),
42            code: 0,
43        }
44    }
45
46    /// The stdout as text, for the many call sites parsing a known-ASCII reply
47    /// (`rc=0`, a session count). Lossy on purpose and only here: these fields
48    /// are coop's own output, not the user's.
49    pub fn text(&self) -> std::borrow::Cow<'_, str> {
50        String::from_utf8_lossy(&self.stdout)
51    }
52
53    pub fn fail(code: i32, stderr: impl Into<String>) -> Self {
54        Self {
55            stdout: Vec::new(),
56            stderr: stderr.into(),
57            code,
58        }
59    }
60}
61
62pub trait Transport {
63    /// Run a shell script on the host, WITHOUT taking the ticket lock.
64    ///
65    /// Callers outside this module want [`Transport::run`], which is the same
66    /// thing with the lock held. This is the unlocked primitive an
67    /// implementation provides; calling it directly opens a session channel
68    /// that coop's own fairness gate cannot see, which on a `MaxSessions 1`
69    /// host recreates the contention the whole tool exists to remove.
70    fn run_unlocked(&self, host: &Host, script: &str) -> Result<Output>;
71
72    /// Is there a usable multiplexing socket? `ssh -O check` only — it takes no
73    /// session channel, so this probe never competes with anything, and it is
74    /// the one call deliberately exempt from the lock.
75    fn master_alive(&self, host: &Host) -> bool;
76
77    /// Run a shell script on the host, holding the host's ticket lock.
78    ///
79    /// **This is the only way callers should reach a host.** The lock covers
80    /// every ssh coop issues except `master_alive`, because two concurrent
81    /// reads hit exactly the cap that motivated the tool -- but until now that
82    /// was prose, enforced by six call sites each remembering to wrap
83    /// `run_unlocked` in `with_lock`. A seventh that forgot would compile,
84    /// pass every test, and quietly reintroduce the contention.
85    ///
86    /// Provided rather than required, so no implementation can weaken it: the
87    /// lock is applied here, once, and an implementor supplies only the
88    /// unlocked primitive.
89    fn run(&self, host: &Host, script: &str) -> Result<Output> {
90        crate::lock::with_lock(&host.name, || self.run_unlocked(host, script))?
91    }
92}
93
94/// Every ssh coop runs, configured so it can never prompt.
95///
96/// `BatchMode=yes` alone is not enough. It gags *ssh's* own prompts, but a
97/// `ProxyCommand` is a separate program with its own terminal: a site wrapper
98/// doing 2FA (`ProxyCommand x2ssh ...`) prompts regardless, so every coop call
99/// on a host with a dead master spawned a Duo passcode prompt into the user's
100/// terminal -- repeatedly, since coop is expected to be called often, and with
101/// no indication of which invocation was asking.
102///
103/// Three settings close it:
104///   BatchMode=yes           - ssh itself never asks
105///   ControlMaster=no        - never create a master as a side effect; coop
106///                             requires one to exist and refuses otherwise
107///   ProxyCommand=none       - do not run a site wrapper that can prompt
108///
109/// `ProxyCommand=none` is safe precisely because coop only ever multiplexes
110/// over an EXISTING master: the socket is already connected, so no proxy is
111/// needed to reach the host. The master the user opens by hand keeps its own
112/// ProxyCommand, which is where 2FA belongs -- once per ControlPersist window,
113/// deliberately, with the user watching.
114fn base_args(host: &Host) -> Vec<String> {
115    vec![
116        "-S".into(),
117        host.socket.display().to_string(),
118        "-o".into(),
119        "BatchMode=yes".into(),
120        "-o".into(),
121        "ControlMaster=no".into(),
122        "-o".into(),
123        "ProxyCommand=none".into(),
124        host.target.clone(),
125    ]
126}
127
128/// Arguments for the lock-exempt master probe. Exposed for tests, which assert
129/// that no coop invocation can prompt.
130pub fn probe_args(host: &Host) -> Vec<String> {
131    let mut args = base_args(host);
132    args.extend(["-O".into(), "check".into()]);
133    args
134}
135
136/// Arguments for running a script over the master.
137pub fn run_args(host: &Host, script: &str) -> Vec<String> {
138    let mut args = base_args(host);
139    args.push(script.to_string());
140    args
141}
142
143fn base_command(host: &Host) -> Command {
144    let mut cmd = Command::new("ssh");
145    cmd.args(base_args(host));
146    cmd
147}
148
149fn ssh_agent_state() -> crate::errors::AgentState {
150    match Command::new("ssh-add").arg("-l").output() {
151        Ok(output) if output.status.success() => crate::errors::AgentState::Keys,
152        Ok(output) if output.status.code() == Some(1) => crate::errors::AgentState::NoKeys,
153        Ok(output) if output.status.code() == Some(2) => crate::errors::AgentState::Unreachable,
154        Ok(_) | Err(_) => crate::errors::AgentState::Unknown,
155    }
156}
157
158/// The real thing.
159#[derive(Debug, Default, Clone, Copy)]
160pub struct Ssh;
161
162impl Transport for Ssh {
163    fn run_unlocked(&self, host: &Host, script: &str) -> Result<Output> {
164        let out = base_command(host)
165            .arg(script)
166            .output()
167            .with_context(|| format!("spawning ssh for host {}", host.name))?;
168        let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
169        if let Some(error) = crate::errors::classify(&stderr, ssh_agent_state) {
170            return Err(error.into());
171        }
172        Ok(Output {
173            stdout: out.stdout,
174            stderr,
175            code: out.status.code().unwrap_or(-1),
176        })
177    }
178
179    fn master_alive(&self, host: &Host) -> bool {
180        base_command(host)
181            .args(["-O", "check"])
182            .output()
183            .map(|o| o.status.success())
184            .unwrap_or(false)
185    }
186}
187
188/// A recording transport for test layer 1.
189///
190/// Deliberately **not** `#[cfg(test)]`: integration tests live in their own
191/// crate and could not see it otherwise, and layer 1 is where most of coop's
192/// logic is actually verified.
193#[derive(Debug, Default)]
194pub struct Fake {
195    scripts: std::sync::Mutex<Vec<String>>,
196    outputs: std::sync::Mutex<std::collections::VecDeque<Output>>,
197    master: bool,
198}
199
200impl Fake {
201    /// A fake with a live master and no queued output (every `run` yields an
202    /// empty success).
203    pub fn new() -> Self {
204        Self {
205            master: true,
206            ..Default::default()
207        }
208    }
209
210    /// A fake whose master is down, for the exit-3 path.
211    pub fn no_master() -> Self {
212        Self::default()
213    }
214
215    /// Queue one reply. Replies are consumed in order.
216    pub fn push(&self, out: Output) -> &Self {
217        self.outputs.lock().unwrap().push_back(out);
218        self
219    }
220
221    /// Every script handed to `run`, in order. This is the assertion surface
222    /// for wrapper construction.
223    pub fn scripts(&self) -> Vec<String> {
224        self.scripts.lock().unwrap().clone()
225    }
226}
227
228impl Transport for Fake {
229    fn run_unlocked(&self, _host: &Host, script: &str) -> Result<Output> {
230        self.scripts.lock().unwrap().push(script.to_string());
231        Ok(self
232            .outputs
233            .lock()
234            .unwrap()
235            .pop_front()
236            .unwrap_or_else(|| Output::ok("")))
237    }
238
239    fn master_alive(&self, _host: &Host) -> bool {
240        self.master
241    }
242}