choir_node/ssh.rs
1//! SSH transport for git (D31): the host's `sshd`, a forced command, and
2//! this shim.
3//!
4//! The node speaks git over HTTPS with basic auth, which is what every
5//! agent uses and what no human expects. `git@host:owner/repo.git` is the
6//! address a person types, and the way to serve it here is the way Gitea
7//! and gitolite serve it: one OS account, one line per registered key in
8//! its `authorized_keys`, and a forced command that never gives out a
9//! shell.
10//!
11//! ```text
12//! command="choir-ssh --root /srv/repos --user alice --acl-file /etc/choir/acl \
13//! --handoff /srv/repos/.choir/ssh-handoff",restrict ssh-ed25519 AAAA... alice
14//! ```
15//!
16//! There is deliberately no SSH server inside the daemon. Every Rust SSH
17//! library within reach is tokio-based, and this workspace is synchronous
18//! threads everywhere except `choir-actor`; an in-process server would
19//! make an async runtime the largest dependency the project has taken, to
20//! terminate a protocol the operating system already terminates.
21//!
22//! The three questions stay split exactly as they are on the HTTP path:
23//!
24//! * *which key* — sshd answers it, by matching the presented public key
25//! against `authorized_keys`;
26//! * *which actor* — the matched line answers it, because `--user` is
27//! written into that key's forced command by the operator. The client
28//! cannot influence it: sshd runs the forced command and puts whatever
29//! the client asked for in `SSH_ORIGINAL_COMMAND` instead;
30//! * *which repository* — [`Acl`] answers it, the same file and the same
31//! [`crate::acl::git_requirement`] mapping the HTTP route consults, so
32//! the two paths cannot drift into granting different things.
33//!
34//! A push then runs the repository's `pre-receive` hook like any other
35//! push, because [`Shim::decide`] hands the sequencer callback to git in
36//! the environment. That is the point of the whole exercise: an SSH push
37//! joins the same total order as an HTTPS one, rather than being a second,
38//! unsequenced way in. A shim with no `--handoff` refuses pushes outright
39//! rather than let one through unsequenced.
40//!
41//! The operator's guide to every way a client reaches a node:
42//!
43#![doc = include_str!("../../../docs/operating/transports.md")]
44
45use std::path::{Path, PathBuf};
46
47use crate::acl::{self, Acl, Level};
48
49/// The git service a client asked for.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Service {
52 /// Serving a clone or fetch: `git-upload-pack`.
53 UploadPack,
54 /// Receiving a push: `git-receive-pack`.
55 ReceivePack,
56}
57
58impl Service {
59 /// git's subcommand name, as passed to the real binary.
60 #[must_use]
61 pub fn verb(self) -> &'static str {
62 match self {
63 Self::UploadPack => "upload-pack",
64 Self::ReceivePack => "receive-pack",
65 }
66 }
67
68 /// The smart-HTTP path doing the same thing, used to borrow the HTTP
69 /// route's own grant decision rather than restate it. See
70 /// [`Shim::required_level`].
71 fn http_endpoint(self) -> &'static str {
72 match self {
73 Self::UploadPack => "git-upload-pack",
74 Self::ReceivePack => "git-receive-pack",
75 }
76 }
77}
78
79/// A parsed `SSH_ORIGINAL_COMMAND`: what the client asked to run, and on
80/// which repository, before any of it is believed.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Request {
83 /// Which of the two services was named.
84 pub service: Service,
85 /// The repository argument exactly as the client spelled it, still
86 /// unvalidated. [`canonical_repo`] is what turns it into a name this
87 /// node will act on.
88 pub repo: String,
89}
90
91/// The git invocation a resolved request becomes.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Exec {
94 /// git subcommand: `upload-pack` or `receive-pack`.
95 pub verb: &'static str,
96 /// Absolute path of the bare repository to serve.
97 pub dir: PathBuf,
98 /// Environment to add for the child, which is how the `pre-receive`
99 /// hook learns where to send this push. Empty for a fetch, which runs
100 /// no hook.
101 pub env: Vec<(String, String)>,
102}
103
104/// Where the shim sends the sequencer callback, and the loopback secret
105/// that callback authenticates with.
106///
107/// Written by the daemon at startup ([`crate::Node::write_ssh_handoff`])
108/// and read by the shim on each invocation. It exists because both values
109/// are runtime state: the port may be ephemeral and the secret is minted
110/// per process, so neither can be written into an `authorized_keys` line
111/// that has to survive restarts.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Handoff {
114 /// Base URL of the running daemon, e.g. `http://127.0.0.1:8417`.
115 pub api: String,
116 /// The daemon's loopback secret, sent as `X-Choir-Internal`.
117 pub secret: String,
118 /// The ACL file the daemon itself is enforcing, when it has one.
119 ///
120 /// A forced command whose `--acl-file` was forgotten would otherwise
121 /// be an authenticated key reaching every repository on a node that
122 /// authorizes every HTTP request — the ACL bypassed by the transport
123 /// rather than by a grant. [`Shim::decide`] falls back to this path,
124 /// so forgetting the flag costs an operator nothing and grants
125 /// nobody anything.
126 pub acl: Option<String>,
127 /// The self-service credential store the daemon is enforcing (D36),
128 /// when it has one.
129 ///
130 /// The grants a token was issued with live there rather than in the
131 /// ACL file, so a shim that read only the file would refuse every
132 /// self-served account — the transport disagreeing with the daemon
133 /// about who holds what. Read-only here: the shim never writes an
134 /// account, and takes only the grants.
135 pub accounts: Option<String>,
136}
137
138impl Handoff {
139 /// Parses the two-key file format: `<key> <value>` lines, `#`
140 /// comments, blank lines ignored.
141 ///
142 /// # Errors
143 ///
144 /// Returns a message when a line is malformed, a key is unknown, or
145 /// either key is missing. A half-read handoff is refused rather than
146 /// used, because the failure it produces downstream is a push that
147 /// silently misses the sequencer.
148 pub fn parse(text: &str) -> Result<Self, String> {
149 let (mut api, mut secret, mut acl, mut accounts) = (None, None, None, None);
150 for (index, raw) in text.lines().enumerate() {
151 let line = raw.split('#').next().unwrap_or("").trim();
152 if line.is_empty() {
153 continue;
154 }
155 let number = index + 1;
156 let Some((key, value)) = line.split_once(char::is_whitespace) else {
157 return Err(format!("line {number}: expected `<key> <value>`"));
158 };
159 match key {
160 "api" => api = Some(value.trim().to_string()),
161 "secret" => secret = Some(value.trim().to_string()),
162 "acl" => acl = Some(value.trim().to_string()),
163 "accounts" => accounts = Some(value.trim().to_string()),
164 other => return Err(format!("line {number}: unknown key `{other}`")),
165 }
166 }
167 match (api, secret) {
168 (Some(api), Some(secret)) => Ok(Self {
169 api,
170 secret,
171 acl,
172 accounts,
173 }),
174 (None, _) => Err("no `api` line".to_string()),
175 (_, None) => Err("no `secret` line".to_string()),
176 }
177 }
178
179 /// Reads and parses the file at `path`.
180 ///
181 /// # Errors
182 ///
183 /// Returns a message when the file cannot be read or does not parse.
184 pub fn load(path: &Path) -> Result<Self, String> {
185 let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
186 Self::parse(&text).map_err(|e| format!("{}: {e}", path.display()))
187 }
188}
189
190/// Writes a [`Handoff`] file, readable only by the user that wrote it.
191///
192/// # Errors
193///
194/// Any I/O error creating, writing or chmod-ing the file.
195pub fn write_handoff(
196 path: &Path,
197 api: &str,
198 secret: &str,
199 acl: Option<&Path>,
200 accounts: Option<&Path>,
201) -> std::io::Result<()> {
202 if let Some(parent) = path.parent() {
203 std::fs::create_dir_all(parent)?;
204 }
205 let acl = acl.map_or(String::new(), |path| format!("acl {}\n", path.display()));
206 let accounts = accounts.map_or(String::new(), |path| {
207 format!("accounts {}\n", path.display())
208 });
209 std::fs::write(
210 path,
211 format!(
212 "# choir ssh handoff, rewritten on every start. Not a file to \
213 share: the secret line authenticates the sequencer callback.\n\
214 api {api}\nsecret {secret}\n{acl}{accounts}"
215 ),
216 )?;
217 #[cfg(unix)]
218 {
219 use std::os::unix::fs::PermissionsExt;
220 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
221 }
222 Ok(())
223}
224
225/// Splits a command line the way sshd's client wrote it: single quotes,
226/// no expansion, no operators.
227///
228/// git builds this string itself (`git-upload-pack '<path>'`, quoting by
229/// the same rules `sq_quote` uses), so the grammar accepted here is
230/// deliberately the smallest one that reads what git writes. Anything
231/// carrying shell punctuation is refused rather than interpreted: the
232/// shim never runs a shell, but a string it cannot fully account for is
233/// one it should not act on either.
234fn split_words(command: &str) -> Result<Vec<String>, String> {
235 let mut words = Vec::new();
236 let mut word = String::new();
237 let mut started = false;
238 let mut quoted = false;
239 for c in command.chars() {
240 if quoted {
241 if c == '\'' {
242 quoted = false;
243 } else {
244 word.push(c);
245 }
246 continue;
247 }
248 match c {
249 '\'' => {
250 quoted = true;
251 started = true;
252 }
253 c if c.is_whitespace() => {
254 if started {
255 words.push(std::mem::take(&mut word));
256 started = false;
257 }
258 }
259 ';' | '&' | '|' | '<' | '>' | '`' | '$' | '(' | ')' | '{' | '}' | '\\' | '"' | '*'
260 | '?' | '#' | '~' | '=' | '!' | '\n' => {
261 return Err(format!("`{c}` is not allowed in a git command over ssh"));
262 }
263 c => {
264 word.push(c);
265 started = true;
266 }
267 }
268 }
269 if quoted {
270 return Err("unterminated quote".to_string());
271 }
272 if started {
273 words.push(word);
274 }
275 Ok(words)
276}
277
278/// Parses the command sshd put in `SSH_ORIGINAL_COMMAND`.
279///
280/// Accepts exactly what a git client sends — `git-upload-pack '<repo>'`,
281/// `git-receive-pack '<repo>'`, and the dashless `git upload-pack
282/// '<repo>'` spelling — with exactly one argument. Everything else,
283/// `git-upload-archive` included, is refused: this account exists to
284/// serve two services and a surface it does not serve is a surface it
285/// cannot get wrong.
286///
287/// # Errors
288///
289/// Returns a message safe to print to whoever ran the command.
290pub fn parse_command(original: &str) -> Result<Request, String> {
291 let mut parts = split_words(original)?;
292 // `git upload-pack <repo>` is the same request as `git-upload-pack
293 // <repo>`; join the two spellings before looking at the verb.
294 if parts.first().map(String::as_str) == Some("git") && parts.len() > 1 {
295 let joined = format!("git-{}", parts[1]);
296 parts.splice(0..2, [joined]);
297 }
298 let (verb, rest) = parts
299 .split_first()
300 .ok_or_else(|| "no command given".to_string())?;
301 let service = match verb.as_str() {
302 "git-upload-pack" => Service::UploadPack,
303 "git-receive-pack" => Service::ReceivePack,
304 other => {
305 return Err(format!(
306 "`{other}` is not served here; this account serves git-upload-pack and \
307 git-receive-pack"
308 ))
309 }
310 };
311 match rest {
312 [repo] => Ok(Request {
313 service,
314 repo: repo.clone(),
315 }),
316 [] => Err(format!("{verb} needs a repository")),
317 _ => Err(format!("{verb} takes exactly one repository")),
318 }
319}
320
321/// Canonical repository name for a path a client sent: `owner/repo.git`,
322/// the same spelling the smart-HTTP path derives from a URL.
323///
324/// The spelling matters beyond tidiness. Ref names in the op log are
325/// `<repo>:<refname>` with `<repo>` in exactly this form, so a shim that
326/// normalized differently would push into a second, parallel namespace
327/// that looks fine in isolation and never converges with the HTTP one.
328///
329/// Both `owner/repo` and `owner/repo.git` are accepted, because both are
330/// spellings humans type. Two segments are required: a grant cannot be
331/// written for any other shape (see [`crate::acl`]), so a deeper path
332/// could never be authorized anyway.
333///
334/// # Errors
335///
336/// Returns a message naming what is wrong with the path.
337pub fn canonical_repo(raw: &str) -> Result<String, String> {
338 let path = raw.trim_start_matches('/');
339 let segments: Vec<&str> = path.split('/').collect();
340 let [owner, name] = segments.as_slice() else {
341 return Err(format!(
342 "`{raw}` is not a repository; write `owner/repo.git`"
343 ));
344 };
345 for segment in [owner, name] {
346 if segment.is_empty() {
347 return Err(format!(
348 "`{raw}` is not a repository; write `owner/repo.git`"
349 ));
350 }
351 // A leading dot would put `<root>/.choir` — the node's key, its
352 // log, this very handoff file — one well-chosen path away from a
353 // fetch. `.` and `..` fall out of the same rule, so traversal
354 // needs no separate check.
355 if segment.starts_with('.') {
356 return Err(format!("`{segment}` may not start with a dot"));
357 }
358 if let Some(bad) = segment
359 .chars()
360 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
361 {
362 return Err(format!("`{bad}` is not allowed in a repository name"));
363 }
364 }
365 Ok(format!("{owner}/{}.git", acl::normalize_repo(name)))
366}
367
368/// One invocation's configuration, all of it from the flags the operator
369/// wrote into the forced command.
370#[derive(Debug, Clone)]
371pub struct Shim {
372 /// Repository root, the same directory the daemon serves.
373 pub root: PathBuf,
374 /// The choir user this key belongs to. Written by the operator into
375 /// the key's own `authorized_keys` line; the client cannot reach it.
376 pub user: String,
377 /// The ACL file (D29), re-read on every invocation so an edited grant
378 /// takes effect on the next command rather than on a restart. `None`
379 /// falls back to the ACL the daemon named in the [`Handoff`], and
380 /// only when that is absent too does this become the daemon's
381 /// authenticate-only behaviour: any registered key reaches every
382 /// repository.
383 pub acl_file: Option<PathBuf>,
384 /// The daemon's [`Handoff`] file: where a push reports itself, and
385 /// which ACL the daemon is enforcing. `None` refuses pushes.
386 pub handoff: Option<PathBuf>,
387}
388
389impl Shim {
390 /// The grant this request needs, obtained by asking the HTTP route's
391 /// own mapping about the URL that would do the same thing.
392 ///
393 /// Restating the mapping here would work today and drift later: the
394 /// day a level changes in [`crate::acl::git_requirement`], an SSH
395 /// client would keep getting the old answer. Borrowing it means there
396 /// is one mapping on the node and both transports read it.
397 fn required_level(repo: &str, service: Service) -> Result<Level, String> {
398 let url = format!("/{repo}/{}", service.http_endpoint());
399 acl::git_requirement("POST", &url)
400 .map(|(_, level)| level)
401 .ok_or_else(|| "no such repository".to_string())
402 }
403
404 /// Resolves one `SSH_ORIGINAL_COMMAND` into the git invocation to
405 /// become, or the message to fail with.
406 ///
407 /// # Errors
408 ///
409 /// Every refusal path: an unparseable or unserved command, a name
410 /// that is not a repository, a missing grant, a repository that is
411 /// not there, and a push with nowhere to send the sequencer callback.
412 pub fn decide(&self, original: &str) -> Result<Exec, String> {
413 let request = parse_command(original)?;
414 let repo = canonical_repo(&request.repo)?;
415 let level = Self::required_level(&repo, request.service)?;
416 // Read before the ACL decision and for fetches too, not only for
417 // pushes: the daemon's own ACL path lives in here, so a handoff
418 // that cannot be read is a request whose authorization cannot be
419 // established. That fails the fetch rather than serving it
420 // ungated.
421 let handoff = self.handoff.as_deref().map(Handoff::load).transpose()?;
422 // The flag if the operator wrote one, otherwise whatever the
423 // daemon says it is enforcing. Forgetting `--acl-file` on one
424 // `authorized_keys` line should not be the difference between a
425 // gated repository and an open one.
426 let acl_file = self.acl_file.clone().or_else(|| {
427 handoff
428 .as_ref()
429 .and_then(|h| h.acl.as_deref())
430 .map(PathBuf::from)
431 });
432 // Read the table per invocation rather than caching it: a shim
433 // process handles one command and exits, so "hot reload" here is
434 // just not holding a stale copy.
435 //
436 // Both halves of the daemon's table, for the same reason the
437 // daemon merges them (D36): a grant issued through self-service
438 // lives in the store rather than in the file, and a transport
439 // that consulted only one of them would answer a different
440 // question than the HTTP route answers about the same user.
441 let file_table = acl_file.as_deref().map(Acl::load).transpose()?;
442 let store_table = handoff
443 .as_ref()
444 .and_then(|h| h.accounts.as_deref())
445 .map(|path| crate::accounts::grants_acl(Path::new(path)))
446 .transpose()?;
447 let table = match (file_table, store_table) {
448 (Some(file), Some(store)) => Some(file.merged(&store)),
449 (Some(one), None) | (None, Some(one)) => Some(one),
450 (None, None) => None,
451 };
452 if let Some(table) = table {
453 // Dated here rather than at load, the same rule the HTTP
454 // chokepoint follows (D66): a grant with a lapsed deadline
455 // must not reach a check, and ssh gets one process per
456 // connection so there is nothing cached to go stale.
457 if let Some(denial) = table.at(crate::accounts::now_secs()).check(
458 &self.user,
459 &acl::Scope::Repo(acl::normalize_repo(&repo)),
460 level,
461 ) {
462 return Err(denial.reason);
463 }
464 }
465 let dir = self.root.join(&repo);
466 // Deliberately the same words the ACL uses for a repository the
467 // caller may not read: whether a name is unreadable or absent is
468 // not something an unauthorized caller gets to learn.
469 if !dir.join("objects").is_dir() {
470 return Err("no such repository".to_string());
471 }
472 let mut env = Vec::new();
473 if request.service == Service::ReceivePack {
474 let Some(handoff) = handoff else {
475 return Err(
476 "this account cannot accept pushes: it was installed without --handoff, \
477 so a push would bypass the sequencer"
478 .to_string(),
479 );
480 };
481 let api = handoff.api.trim_end_matches('/');
482 env = vec![
483 ("CHOIR_API".to_string(), format!("{api}/api/git-update")),
484 ("CHOIR_ABORT".to_string(), format!("{api}/api/git-abort")),
485 ("CHOIR_REPO".to_string(), repo),
486 ("CHOIR_USER".to_string(), self.user.clone()),
487 ("CHOIR_INTERNAL".to_string(), handoff.secret),
488 ];
489 }
490 Ok(Exec {
491 verb: request.service.verb(),
492 dir,
493 env,
494 })
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn parses_what_git_sends() {
504 assert_eq!(
505 parse_command("git-upload-pack 'owner/repo.git'").unwrap(),
506 Request {
507 service: Service::UploadPack,
508 repo: "owner/repo.git".to_string()
509 }
510 );
511 assert_eq!(
512 parse_command("git-receive-pack 'owner/repo.git'")
513 .unwrap()
514 .service,
515 Service::ReceivePack
516 );
517 // The dashless spelling, and the leading slash an `ssh://` URL
518 // produces, are the same request.
519 assert_eq!(
520 parse_command("git upload-pack '/owner/repo.git'")
521 .unwrap()
522 .repo,
523 "/owner/repo.git"
524 );
525 // Unquoted is legal too; some clients do not quote a plain name.
526 assert_eq!(
527 parse_command("git-upload-pack owner/repo.git")
528 .unwrap()
529 .repo,
530 "owner/repo.git"
531 );
532 }
533
534 #[test]
535 fn refuses_anything_but_the_two_services() {
536 for command in [
537 "git-upload-archive 'owner/repo.git'",
538 "scp -f /etc/passwd",
539 "sh",
540 "",
541 ] {
542 assert!(parse_command(command).is_err(), "accepted {command:?}");
543 }
544 }
545
546 #[test]
547 fn refuses_shell_punctuation_and_extra_arguments() {
548 for command in [
549 "git-upload-pack 'owner/repo.git'; rm -rf /",
550 "git-upload-pack 'owner/repo.git' && curl evil",
551 "git-upload-pack `whoami`",
552 "git-upload-pack $(whoami)",
553 "git-upload-pack 'a/b.git' 'c/d.git'",
554 "git-upload-pack 'owner/repo.git",
555 ] {
556 assert!(parse_command(command).is_err(), "accepted {command:?}");
557 }
558 }
559
560 #[test]
561 fn punctuation_is_refused_where_the_rule_lives() {
562 // Not what stops an injection — the one-argument rule and the
563 // name charset do that, and both are tested above. This rule is
564 // tested here because it is otherwise invisible: every string it
565 // rejects is also rejected further along, so a mutation that
566 // deleted it would leave every other test green.
567 for command in [
568 "git-upload-pack 'a/b.git' > /tmp/x",
569 "git-upload-pack `id`",
570 "git-upload-pack $(id)",
571 "git-upload-pack a/b.git | tee /tmp/x",
572 ] {
573 assert!(split_words(command).is_err(), "accepted {command:?}");
574 }
575 }
576
577 #[test]
578 fn canonicalizes_to_the_spelling_the_op_log_uses() {
579 for spelling in ["owner/repo", "owner/repo.git", "/owner/repo.git"] {
580 assert_eq!(canonical_repo(spelling).unwrap(), "owner/repo.git");
581 }
582 }
583
584 #[test]
585 fn refuses_paths_that_are_not_two_plain_segments() {
586 for path in [
587 "../etc/passwd",
588 "owner/../../etc/passwd",
589 "a/b/c.git",
590 "owner",
591 "",
592 "/",
593 "owner/",
594 ".choir/node.key",
595 "owner/.choir",
596 "own*er/repo",
597 "owner/re po",
598 ] {
599 assert!(canonical_repo(path).is_err(), "accepted {path:?}");
600 }
601 }
602
603 #[test]
604 fn asks_for_the_level_the_http_route_asks_for() {
605 assert_eq!(
606 Shim::required_level("owner/repo.git", Service::UploadPack).unwrap(),
607 Level::Read
608 );
609 assert_eq!(
610 Shim::required_level("owner/repo.git", Service::ReceivePack).unwrap(),
611 // `propose` since D60, and this test is the proof the
612 // borrowing works: nothing in this file changed to say so.
613 // The refname half is transport-agnostic too, because an SSH
614 // push runs the same `pre-receive` hook and reaches the same
615 // `/api/git-update`.
616 Level::Propose
617 );
618 }
619
620 // A push with no `--handoff` is refused against a real repository in
621 // `tests/it/ssh.rs`; here there is nothing on disk to refuse for the
622 // right reason, and the wrong reason ("no such repository") would
623 // have passed a laxer assertion.
624
625 #[test]
626 fn handoff_round_trips_and_partial_files_are_refused() {
627 let parsed = Handoff::parse("# comment\napi http://127.0.0.1:1/\n\nsecret abc\n").unwrap();
628 assert_eq!(parsed.api, "http://127.0.0.1:1/");
629 assert_eq!(parsed.secret, "abc");
630 assert_eq!(parsed.acl, None);
631 // A daemon enforcing an ACL says so, and the shim inherits it.
632 let gated = Handoff::parse("api http://x\nsecret abc\nacl /etc/choir/acl\n").unwrap();
633 assert_eq!(gated.acl.as_deref(), Some("/etc/choir/acl"));
634 assert!(Handoff::parse("api http://x\n").is_err());
635 assert!(Handoff::parse("secret abc\n").is_err());
636 assert!(Handoff::parse("port 22\n").is_err());
637 assert!(Handoff::parse("api\n").is_err());
638 }
639}