//! SSH-based git transport. //! //! Called from the `mnw-admin git-auth` command, which is invoked by sshd's //! `command=` prefix in authorized_keys. Handles git push/pull access control //! only. The management verbs (repo list, key management) live in mnw-cli and //! are reached through cli.makenot.work. use git_command::{Operation, Request}; use sqlx::PgPool; use std::fmt::Write as _; use crate::db::{self, UserId, Username}; use crate::validation::validate_git_repo_name; // --- Constants --- pub const MNW_ADMIN_PATH: &str = "/opt/mnw/current/mnw-admin"; /// The git user's home directory (`GIT_HOME`, default `/opt/git`). Configurable /// so a relocated home (prod uses `/var/lib/mnw/git`) needs an env var rather /// than a load-bearing symlink for `rebuild-keys` to keep writing to a live /// path. Matches the `GIT_HOME` used by `deploy/setup-git-ssh.sh`. fn git_home() -> std::path::PathBuf { std::path::PathBuf::from(std::env::var("GIT_HOME").unwrap_or_else(|_| "/opt/git".to_string())) } /// Path to the git user's `authorized_keys`, managed by `mnw-admin rebuild-keys` /// and consulted by sshd's `command=` routing. Derived from [`git_home`] so a /// relocated home needs only `GIT_HOME` set, not a symlink. pub fn authorized_keys_path() -> std::path::PathBuf { git_home().join(".ssh").join("authorized_keys") } // --- Git operations --- // // The command grammar itself lives in `git-command`, shared with mnw-cli's // russh door. It used to be parsed here and again there, and the two parsers // disagreed on 51 of 5,424 measured command lines. See that crate's module docs // for the four divergences and how each was settled. /// Authenticate and dispatch an SSH git-auth invocation. /// /// Reads `SSH_ORIGINAL_COMMAND`. Git operations (git-upload-pack, /// git-receive-pack) are served; anything else is answered with the notice /// that management commands moved to mnw-cli. pub async fn dispatch(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> { let original_cmd = std::env::var("SSH_ORIGINAL_COMMAND") .map_err(|_| anyhow::anyhow!("SSH_ORIGINAL_COMMAND not set"))?; // Look up the SSH key → user let key_id: db::SshKeyId = key_id_str .parse() .map_err(|_| anyhow::anyhow!("invalid key ID"))?; let (_, user_id, ssh_username) = db::ssh_keys::get_key_with_user(pool, key_id) .await? .ok_or_else(|| anyhow::anyhow!("SSH key not found"))?; // Verify user is not suspended or deactivated let user = db::users::get_user_by_id(pool, user_id) .await? .ok_or_else(|| anyhow::anyhow!("user not found for SSH key"))?; if user.is_suspended() { anyhow::bail!("account is suspended"); } if user.is_deactivated() { anyhow::bail!("account is deactivated"); } // Only git transport is served here. The management verbs (repo list, // key rm, ...) moved to mnw-cli on 2026-07-31 and are reached through // cli.makenot.work, the SSH front door that is actually live. They used to // be implemented in this file and dispatched below, where nothing could // reach them: the git transport had already migrated to mnw-cli's russh // server and these did not follow. So `repo set-visibility` existed, worked // and was unreachable, which is how a repo came to be published with no // supported way to unpublish it. let _ = &ssh_username; if original_cmd.starts_with("git-") { exec_git_operation(pool, user_id, &original_cmd).await } else { anyhow::bail!( "management commands have moved; run `ssh cli.makenot.work repo list` (or `help`)" ) } } async fn exec_git_operation( pool: &PgPool, user_id: UserId, original_cmd: &str, ) -> anyhow::Result<()> { // `git_command::parse` guarantees path safety: both segments are single, // non-empty, non-traversing components, so nothing below can leave the git // root. What it deliberately does not decide is identity policy, which is // this deployment's and stays here. let request: Request<'_> = git_command::parse(original_cmd).map_err(|e| anyhow::anyhow!("{e}"))?; let (operation, owner, repo_name) = (request.operation, request.owner, request.repo); // `Username::new` is the identity rule (3-50 chars, alphanumeric and // underscore) and is stricter than the path-safety floor the parser // enforces. `validate_git_repo_name` is the product's repo-name policy, the // same one the web API applies when a repo is created there; it currently // matches `git_command::valid_segment` exactly, and it is kept because the // two answer different questions and are free to diverge. let owner_username = Username::new(owner).map_err(|_| anyhow::anyhow!("repository not found"))?; validate_git_repo_name(repo_name).map_err(|_| anyhow::anyhow!("repository not found"))?; let owner_user = db::users::get_user_by_username(pool, &owner_username) .await? .ok_or_else(|| anyhow::anyhow!("repository not found"))?; let repo = match db::git_repos::get_repo_by_user_and_name(pool, owner_user.id, repo_name).await? { Some(repo) => repo, None => { // Auto-create on push if the authenticated user owns the namespace. if operation != Operation::ReceivePack || user_id != owner_user.id { anyhow::bail!("repository not found"); } tracing::info!(owner = %owner, repo = %repo_name, "registering new repository"); db::git_repos::create_repo(pool, owner_user.id, repo_name).await? } }; // Permission check, owner always has full access, collaborators checked via DB let is_owner = user_id == owner_user.id; match operation { Operation::ReceivePack => { if !is_owner { let can_push = db::repo_collaborators::can_user_push(pool, repo.id, user_id) .await .unwrap_or_else(|e| { // Fail closed, but don't do it silently: a DB error here // denies a legitimate push with no trace (audit Run 17 // Observability). tracing::warn!(repo_id = %repo.id, user_id = %user_id, error = ?e, "can_user_push check failed; denying push"); false }); if !can_push { anyhow::bail!( "permission denied: you do not have push access to {owner}/{repo_name}" ); } } // Per-account disk quota. The push consumes the *namespace owner's* // storage, so the quota is checked against `owner_username`. Enforced // via the shared `git::enforce_disk_quota` so the SSH and smart-HTTP // push paths share one guard (fuzz 2026-07-06 M1). let owner_dir = git_repos_root().join(owner_username.as_ref()); crate::git::enforce_disk_quota(owner_dir).await?; ensure_bare_repo_on_disk(&git_repos_root(), owner_username.as_ref(), repo_name)?; } Operation::UploadPack | Operation::UploadArchive => { if repo.visibility == db::Visibility::Private && !is_owner { let is_collab = db::repo_collaborators::is_collaborator(pool, repo.id, user_id) .await .unwrap_or_else(|e| { // Fail closed (treat as not-a-collaborator) but log: a DB // error here hides a private repo from a legitimate // collaborator with no trace (audit Run 17 Observability). tracing::warn!(repo_id = %repo.id, user_id = %user_id, error = ?e, "is_collaborator check failed; denying read"); false }); if !is_collab { anyhow::bail!("repository not found"); } } } } // Authorized. Exec git-shell with a command rebuilt from validated // components rather than with the line the client sent, which is what keeps // argument injection out. The rebuild lives in `git_command` so there is one // format string on this path and mnw-cli's, and the segments going into it // cannot carry a quote or a separator: `valid_segment` is a whitelist and // ran before this request existed. run_git_shell(&request.shell_command()).await } /// Create the bare repo a push is about to write into, if it is not there. /// /// Registering the repo in the database and creating it on disk are two steps, /// and this path must do both. A row with no directory hands `git-shell` a path /// that does not exist: `git-receive-pack` neither serves the push nor fails, /// so the client hangs until [`GIT_SSH_OP_TIMEOUT_SECS`] kills it, and every /// retry does the same because the row already exists. /// /// Idempotent by the `exists` check, which also repairs a repo already in that /// state: the row is there, the directory is not, and the next push makes it. /// `mnw-cli`'s russh transport does the same thing at the same point for the /// same reason (`src/ssh/handler.rs`), and that is the door live for /// `ssh.makenot.work`. /// /// [`GIT_SSH_OP_TIMEOUT_SECS`]: crate::constants::GIT_SSH_OP_TIMEOUT_SECS /// Takes the root rather than reading `GIT_REPOS_PATH` itself, so the test /// below can point it at a temp directory without mutating process env. fn ensure_bare_repo_on_disk( root: &std::path::Path, owner: &str, repo_name: &str, ) -> anyhow::Result<()> { let owner_dir = root.join(owner); let repo_dir = owner_dir.join(format!("{repo_name}.git")); if repo_dir.exists() { return Ok(()); } tracing::info!(path = %repo_dir.display(), "creating bare repository on disk"); std::fs::create_dir_all(&owner_dir)?; crate::git::init_bare_repo(&repo_dir)?; // Build triggers are optional, and a repo that pushes without firing one is // a working repo. Warn rather than fail: refusing the push over a missing // hook would trade an empty repo for an unpushable one. let token = std::env::var("BUILD_TRIGGER_TOKEN").ok(); if let Err(error) = install_hooks_for_repo(&repo_dir, token.as_deref(), owner, repo_name) { tracing::warn!(error = ?error, path = %repo_dir.display(), "hooks not installed"); } Ok(()) } /// The configured git repository root (`GIT_REPOS_PATH`, default `/opt/git`). fn git_repos_root() -> std::path::PathBuf { std::path::PathBuf::from( std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string()), ) } /// Run git-shell as a child with inherited stdio (the ssh channel's fds) under a /// runaway-backstop timeout, then exit this process with the child's status. /// /// Spawn rather than `exec()` into git-shell: `exec` leaves no opportunity to /// bound a stalled transfer, so a client that stops reading pins the process /// indefinitely. Spawning allows a `timeout` on the wait and a kill of a stuck /// operation (`GIT_SSH_OP_TIMEOUT_SECS`). The process still terminates here on /// every path, so the git client sees git-shell's own exit code; this returns /// `Err` only if git-shell cannot be spawned. async fn run_git_shell(original_cmd: &str) -> anyhow::Result<()> { use tokio::process::Command; let mut child = Command::new("git-shell") .args(["-c", original_cmd]) .spawn() .map_err(|e| anyhow::anyhow!("failed to spawn git-shell: {e}"))?; let timeout = std::time::Duration::from_secs(crate::constants::GIT_SSH_OP_TIMEOUT_SECS); match tokio::time::timeout(timeout, child.wait()).await { Ok(Ok(status)) => std::process::exit(status.code().unwrap_or(0)), Ok(Err(e)) => anyhow::bail!("git-shell wait failed: {e}"), Err(_elapsed) => { let _ = child.start_kill(); let _ = child.wait().await; eprintln!( "git operation timed out after {}s", crate::constants::GIT_SSH_OP_TIMEOUT_SECS ); std::process::exit(124); // matches coreutils `timeout` exit code } } } /// Install every hook a bare repository needs. /// /// One call rather than one per hook, because the hooks are not independent: /// `post-receive` reindexes a notes push and `update` decides whether that push /// is allowed at all, and a repository with only the first enforces no policy /// while looking installed. A caller that has to remember the second is a /// caller that eventually does not. /// `token` is optional because `post-receive` carries a per-repo HMAC and is /// useless without one, while `update` carries nothing and enforces a policy /// that holds whether or not this deployment runs builds. Gating both on the /// token would leave a server with no `BUILD_TRIGGER_TOKEN` accepting pushes to /// namespaces MNW owns. pub fn install_hooks_for_repo( repo_dir: &std::path::Path, token: Option<&str>, owner: &str, repo_name: &str, ) -> anyhow::Result<()> { if let Some(token) = token { install_hook( repo_dir, "post-receive", &crate::build_runner::post_receive_hook(token, owner, repo_name), )?; } install_hook(repo_dir, "update", crate::build_runner::UPDATE_HOOK)?; Ok(()) } /// Write one executable hook into a bare repository's `hooks/`. fn install_hook( repo_dir: &std::path::Path, hook_name: &str, hook_content: &str, ) -> anyhow::Result<()> { let hooks_dir = repo_dir.join("hooks"); std::fs::create_dir_all(&hooks_dir)?; let hook_path = hooks_dir.join(hook_name); std::fs::write(&hook_path, hook_content)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?; } Ok(()) } // --- authorized_keys --- // // All that remains of the management half. mnw-cli authenticates from the // database rather than this file, so it is written for whatever still consults // sshd: a key removed from one door but not the other is a key the user // believes is gone. /// Write the authorized_keys file from all DB keys. Optionally set git:git ownership. pub async fn write_authorized_keys(pool: &PgPool, set_ownership: bool) -> anyhow::Result<()> { let keys = db::ssh_keys::get_all_keys_with_username(pool).await?; let mut content = String::new(); content.push_str("# Managed by mnw-admin rebuild-keys. Do not edit manually.\n"); for key in &keys { writeln!( content, "command=\"{} git-auth {}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {}", MNW_ADMIN_PATH, key.id, key.public_key, ) .unwrap(); } let keys_path = authorized_keys_path(); let tmp_path = keys_path.with_extension("tmp"); std::fs::write(&tmp_path, &content)?; std::fs::rename(&tmp_path, &keys_path)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&keys_path, std::fs::Permissions::from_mode(0o600))?; if set_ownership { let status = std::process::Command::new("chown") .arg("git:git") .arg(&keys_path) .status()?; if !status.success() { anyhow::bail!("chown git:git failed on {}", keys_path.display()); } } } Ok(()) } #[cfg(test)] mod tests { use super::*; // --- the git-command door --- // The push path's half of repo creation. Registering the row was never the // part that broke; this is. #[test] fn a_first_push_gets_a_bare_repo_on_disk() { let root = tempfile::tempdir().unwrap(); let repo_dir = root.path().join("max").join("shop.git"); ensure_bare_repo_on_disk(root.path(), "max", "shop").unwrap(); assert!( gix::open(&repo_dir).is_ok(), "a push to a name with no repo has somewhere to write", ); // Idempotent, because this runs on every push and not only the first. // It is also what repairs a repo registered before the fix: the row is // there, the directory is not, and the branch above makes it. ensure_bare_repo_on_disk(root.path(), "max", "shop").unwrap(); assert!(gix::open(&repo_dir).is_ok()); } // The grammar's own tests live in `git-command`, which owns the parser. // What is worth asserting here is that this door reaches it and keeps the // guarantee the rest of the function leans on: a request that parses names // one owner and one repo, and neither can leave the git root. #[test] fn the_door_parses_what_a_client_sends() { let r = git_command::parse("git-receive-pack '/user/repo.git'").unwrap(); assert_eq!(r.operation, Operation::ReceivePack); assert_eq!((r.owner, r.repo), ("user", "repo")); } #[test] fn the_shell_argument_is_rebuilt_from_validated_parts() { let r = git_command::parse("git-upload-pack '/user/repo.git'").unwrap(); assert_eq!(r.shell_command(), "git-upload-pack '/user/repo.git'"); } #[test] fn a_traversing_path_never_reaches_the_db_lookup() { for cmd in [ "git-upload-pack '/../etc/passwd'", "git-upload-pack '/user/../../etc'", "git-receive-pack '/user/.hidden.git'", "git-upload-pack '/user/a/b.git'", ] { assert!(git_command::parse(cmd).is_err(), "{cmd}"); } } #[test] fn management_verbs_are_not_this_grammar() { assert!(git_command::parse("repo list").is_err()); } }