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}