Skip to main content

choir_ssh/
choir-ssh.rs

1//! `choir-ssh`: the forced command behind choir's git-over-SSH account
2//! (D31). See [`choir_node::ssh`] for the design and the
3//! `authorized_keys` line that installs it.
4//!
5//! ```text
6//! choir-ssh --root <repo-root> --user <choir-user> [--acl-file <path>]
7//!           [--handoff <path>] [--git-binary <path>]
8//! ```
9//!
10//! Every flag comes from the forced command the operator wrote, never
11//! from the client: sshd runs that command and puts whatever the client
12//! asked for in `SSH_ORIGINAL_COMMAND`, which is the only input this
13//! program takes from the far end. `--handoff` names the file the daemon
14//! wrote at startup (`choir-node --ssh-handoff`); without it this account
15//! serves fetches and refuses pushes, because an unsequenced push is
16//! worse than no push.
17//!
18//! `--git-binary` exists because sshd runs the forced command through a
19//! non-interactive login shell, whose `PATH` frequently lacks the git the
20//! operator means: `/opt/homebrew/bin/git` is not on the default macOS
21//! non-interactive path. Naming the binary is one line in
22//! `authorized_keys` and removes the whole class of "works in my shell".
23
24use std::path::PathBuf;
25
26use choir_node::ssh::Shim;
27
28/// Exit code for every refusal. git surfaces the message on stderr to
29/// whoever ran the command, so the reason reaches a person.
30const REFUSED: i32 = 1;
31
32fn main() {
33    let args: Vec<String> = std::env::args().skip(1).collect();
34    let value = |name: &str| -> Option<String> {
35        args.iter()
36            .position(|a| a == name)
37            .and_then(|i| args.get(i + 1))
38            .cloned()
39    };
40    for flag in [
41        "--root",
42        "--user",
43        "--acl-file",
44        "--handoff",
45        "--git-binary",
46    ] {
47        if args.iter().any(|a| a == flag) && value(flag).is_none() {
48            refuse(&format!("{flag} needs a value"));
49        }
50    }
51    let (Some(root), Some(user)) = (value("--root"), value("--user")) else {
52        refuse(
53            "choir-ssh needs --root and --user; it is meant to be run by sshd as a forced command",
54        );
55    };
56    let shim = Shim {
57        root: PathBuf::from(root),
58        user: user.clone(),
59        acl_file: value("--acl-file").map(PathBuf::from),
60        handoff: value("--handoff").map(PathBuf::from),
61    };
62    let git = value("--git-binary").unwrap_or_else(|| "git".to_string());
63
64    // Set by sshd, and the one input that comes from the far end. Absent
65    // means the client asked for a shell rather than for git.
66    let Ok(original) = std::env::var("SSH_ORIGINAL_COMMAND") else {
67        refuse(&format!(
68            "hi {user}, your key works. This account serves git and has no shell; \
69             clone with git@<host>:owner/repo.git"
70        ));
71    };
72    let exec = match shim.decide(&original) {
73        Ok(exec) => exec,
74        Err(message) => refuse(&message),
75    };
76
77    let mut command = std::process::Command::new(&git);
78    command.arg(exec.verb).arg(&exec.dir);
79    for (key, value) in &exec.env {
80        command.env(key, value);
81    }
82    #[cfg(unix)]
83    {
84        use std::os::unix::process::CommandExt;
85        // `exec`, not spawn: git then owns this process's stdin, stdout
86        // and exit status directly, so the pack protocol and any signal
87        // pass through with nothing in the middle to get them wrong. It
88        // only returns on failure.
89        let error = command.exec();
90        refuse(&format!("could not run `{git}`: {error}"));
91    }
92    #[cfg(not(unix))]
93    refuse("choir-ssh needs a unix host: it is an sshd forced command");
94}
95
96/// Prints one line to stderr and exits. Never returns.
97fn refuse(message: &str) -> ! {
98    eprintln!("choir: {message}");
99    std::process::exit(REFUSED);
100}