Skip to main content

coop/
errors.rs

1use anyhow::Error as AnyhowError;
2use anyhow::Result;
3use thiserror::Error;
4
5pub const EXIT_NO_MASTER: i32 = 3;
6pub const EXIT_TIMEOUT: i32 = 4;
7pub const EXIT_ORPHAN: i32 = 5;
8pub const EXIT_DROPPED: i32 = 6;
9
10#[derive(Debug, Error, PartialEq, Eq)]
11pub enum CoopError {
12    #[error(
13        "no control master for {host}\n  \
14         run: ssh -MNf -S {socket} -o ControlPersist=8h {target}\n  \
15         a human may need to tap a hardware key; ask rather than retrying"
16    )]
17    NoMaster {
18        host: String,
19        socket: String,
20        target: String,
21    },
22    #[error(
23        "the master is down or its one slot is held\n  run ssh -O check with coop's configured ControlPath to tell which"
24    )]
25    SessionChannelBusy,
26    #[error(
27        "the local ssh agent is unreachable; ssh-add -l cannot use SSH_AUTH_SOCK\n  re-establish or re-attach the agent; an authentication prompt may be waiting where you cannot see it"
28    )]
29    SshAgentUnreachable,
30    #[error(
31        "the local ssh agent has no keys loaded\n  add the required key with ssh-add; an authentication prompt may be waiting where you cannot see it"
32    )]
33    SshAgentHasNoKeys,
34    #[error(
35        "keyboard-interactive authentication failed; this is either a local ssh-agent problem or a busy control-master channel\n  run ssh-add -l, then ssh -O check with coop's configured ControlPath"
36    )]
37    KeyboardInteractiveAmbiguous,
38    #[error("timed out waiting for job {id}; it is still running")]
39    Timeout { id: String },
40    #[error("job {id} is orphaned; no rc will ever arrive")]
41    Orphan { id: String },
42    #[error("lost contact while waiting; the job continues\n  resume: coop tail {id}")]
43    Dropped { id: String },
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum AgentState {
48    Keys,
49    NoKeys,
50    Unreachable,
51    Unknown,
52}
53
54pub fn classify(stderr: &str, agent_state: impl FnOnce() -> AgentState) -> Option<CoopError> {
55    let stderr = stderr.to_ascii_lowercase();
56    if ["session request failed", "session open refused"]
57        .iter()
58        .any(|pattern| stderr.contains(pattern))
59    {
60        return Some(CoopError::SessionChannelBusy);
61    }
62    if !stderr.contains("permission denied (keyboard-interactive)") {
63        return None;
64    }
65    Some(match agent_state() {
66        AgentState::Keys => CoopError::SessionChannelBusy,
67        AgentState::NoKeys => CoopError::SshAgentHasNoKeys,
68        AgentState::Unreachable => CoopError::SshAgentUnreachable,
69        AgentState::Unknown => CoopError::KeyboardInteractiveAmbiguous,
70    })
71}
72
73pub fn exit_code(error: &AnyhowError) -> i32 {
74    match error.downcast_ref::<CoopError>() {
75        Some(CoopError::NoMaster { .. }) => EXIT_NO_MASTER,
76        Some(CoopError::Timeout { .. }) => EXIT_TIMEOUT,
77        Some(CoopError::Orphan { .. }) => EXIT_ORPHAN,
78        Some(CoopError::Dropped { .. }) => EXIT_DROPPED,
79        Some(
80            CoopError::SessionChannelBusy
81            | CoopError::SshAgentUnreachable
82            | CoopError::SshAgentHasNoKeys
83            | CoopError::KeyboardInteractiveAmbiguous,
84        )
85        | None => 1,
86    }
87}
88
89/// Refuse to proceed without a control master, naming the command that opens
90/// one.
91///
92/// Every job verb needs this, not just `run`: without it `poll`, `wait`,
93/// `tail`, `kill` and `rm` fell through to ssh and reported a generic failure
94/// with exit 1, instead of the documented exit 3 and the recovery command. The
95/// exception is `coop host list`, whose whole job is to *report* which hosts
96/// have a master.
97pub fn require_master(
98    t: &dyn crate::transport::Transport,
99    host: &crate::config::Host,
100) -> Result<()> {
101    if t.master_alive(host) {
102        return Ok(());
103    }
104    Err(no_master(host))
105}
106
107/// The missing-master error, with the socket directory prepared first.
108///
109/// ssh will not create the directory holding a control socket: it binds a
110/// temporary name inside it and fails with
111/// `unix_listener: cannot bind to path ...: No such file or directory`. Since
112/// coop defaults `socket` to `~/.ssh/coop/<host>.sock` and never created that
113/// directory, the command coop printed could not work -- and it failed *after*
114/// the 2FA prompt, so the user paid a hardware-token tap to find out, and the
115/// error read as a broken ssh config rather than a missing `mkdir`.
116///
117/// Done here rather than at config load so it is a consequence of asking for a
118/// master, not a side effect of `coop --help`.
119pub fn no_master(host: &crate::config::Host) -> AnyhowError {
120    let mut hint = None;
121    if let Some(parent) = host.socket.parent() {
122        // 0700, because ssh refuses a control socket in a directory others can
123        // write. A 0755 mkdir would trade this error for a subtler one.
124        if let Err(e) = create_private_dir(parent) {
125            hint = Some(format!("{}: {e}", parent.display()));
126        }
127    }
128    let error: AnyhowError = CoopError::NoMaster {
129        host: host.name.clone(),
130        socket: host.socket.display().to_string(),
131        target: host.target.clone(),
132    }
133    .into();
134    match hint {
135        // Say so rather than printing a command that cannot work.
136        Some(why) => error.context(format!("cannot prepare the socket directory {why}")),
137        None => error,
138    }
139}
140
141/// The `ssh -MNf` line that opens a master for this host.
142///
143/// One renderer, used by every verb and by `host list`, so the advice cannot
144/// drift between them. Preparing the socket directory is part of producing the
145/// command: printing one that cannot work is worse than printing nothing, and
146/// the failure arrives only after a 2FA prompt.
147pub fn master_command(host: &crate::config::Host) -> String {
148    if let Some(parent) = host.socket.parent() {
149        let _ = create_private_dir(parent);
150    }
151    format!(
152        "run: ssh -MNf -S {} -o ControlPersist=8h {}",
153        host.socket.display(),
154        host.target
155    )
156}
157
158fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
159    if dir.is_dir() {
160        return Ok(());
161    }
162    std::fs::create_dir_all(dir)?;
163    #[cfg(unix)]
164    {
165        use std::os::unix::fs::PermissionsExt;
166        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
167    }
168    Ok(())
169}