//! SSH-based git operations and management commands. //! //! 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 //! and interactive management commands (repo list, key management, etc.). 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 /// because the home was relocated to `/var/lib/mnw/git` in the 2026-06 soak /// cleanup: `/opt/git` was deleted, and hardcoding it forced a load-bearing /// symlink so `rebuild-keys` would 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 ── #[derive(Debug)] enum GitOperation { UploadPack, ReceivePack, Archive, } impl GitOperation { fn command(&self) -> &'static str { match self { Self::UploadPack => "git-upload-pack", Self::ReceivePack => "git-receive-pack", Self::Archive => "git-upload-archive", } } } /// Authenticate and dispatch an SSH git-auth invocation. /// /// Reads `SSH_ORIGINAL_COMMAND` to determine whether this is a git operation /// (git-upload-pack, git-receive-pack) or a management command (repo list, etc.). 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<()> { let (operation, repo_path) = parse_ssh_command(original_cmd)?; let (owner, repo_name) = parse_repo_path(&repo_path)?; // Validate the SSH-supplied owner and repo name before any DB lookup or // shell reconstruction. `parse_repo_path` is a path-shape check, not a // syntax check, without this, a malformed name could reach the DB layer // or end up embedded in the `git-shell -c` argument below. 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 !matches!(operation, GitOperation::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 { GitOperation::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)?; } GitOperation::UploadPack | GitOperation::Archive => { 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 sanitized command reconstructed // from validated components (prevents argument injection via the original // command). Use `owner_username` (the `Username`-validated value), not the // raw `owner` &str: `Username::new` preserves the string but constrains the // charset, so the value flowing into the `git-shell -c` argument stays // load-bearing on the validated type even if `parse_repo_path` ever loosens. let sanitized_cmd = format!( "{} '/{}/{}.git'", operation.command(), owner_username.as_ref(), repo_name ); run_git_shell(&sanitized_cmd).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 until 2026-08-01 this path did only the first. The comment above the /// `create_repo` call said the caller made the directory; no caller did. /// `crate::git::init_bare_repo` calls itself the single production path for /// creating a bare repo, and the only thing reaching it was the web API. /// /// What that cost: a first push to a name nobody had pushed before registered /// the repo, listed it on `/git` as public, and then handed `git-shell` a path /// that did not exist. `git-receive-pack` neither served the push nor failed, /// so the client sat there until [`GIT_SSH_OP_TIMEOUT_SECS`] killed it, fifteen /// minutes later. Every retry did the same, because the row existed by then and /// the branch that would have created anything was no longer taken. The repo /// stayed listed, empty, and unpushable, with the web UI the only way to /// remove it. /// /// Idempotent by the `exists` check, which also repairs the repos 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 this is the door that is /// actually 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()), ) } fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> { let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); if parts.len() != 2 { anyhow::bail!("invalid git command"); } let operation = match parts[0] { "git-upload-pack" => GitOperation::UploadPack, "git-receive-pack" => GitOperation::ReceivePack, "git-upload-archive" => GitOperation::Archive, _ => anyhow::bail!("unsupported git command: {}", parts[0]), }; let repo_path = parts[1].trim_matches('\'').trim_matches('"'); Ok((operation, repo_path.to_string())) } fn parse_repo_path(path: &str) -> anyhow::Result<(&str, &str)> { let path = path.trim_start_matches('/'); let (owner, rest) = path .split_once('/') .ok_or_else(|| anyhow::anyhow!("invalid repository path: missing owner or repo"))?; if owner.contains("..") || rest.contains("..") { anyhow::bail!("invalid repository path: path traversal not allowed"); } // Reject lone-dot segments, `parse_repo_path` is the gate before the // `format!("{op} '/{owner}/{repo_name}.git'")` that flows into `git-shell`. // `validate_git_repo_name` below would also catch most of these, but the // belt-and-braces rejection here keeps the dispatch path itself strict. if owner == "." || rest.split('/').any(|seg| seg == "." || seg == "..") { anyhow::bail!("invalid repository path: lone-dot segment not allowed"); } let repo_name = rest.strip_suffix(".git").unwrap_or(rest); if owner.is_empty() || repo_name.is_empty() { anyhow::bail!("invalid repository path: empty owner or repo name"); } Ok((owner, repo_name)) } /// 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. /// /// Replaces the previous `exec()`-into-git-shell: `exec` left no opportunity to /// bound a stalled transfer, so a client that stopped reading could pin the /// process indefinitely. Spawning lets us `timeout` the wait and kill a stuck /// operation (`GIT_SSH_OP_TIMEOUT_SECS`). The process still terminates here on /// every path, so it behaves like the old `exec` to the git client (its exit /// code propagates); it 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::*; // ── parse_ssh_command ── // 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()); } #[test] fn parse_upload_pack() { let (op, path) = parse_ssh_command("git-upload-pack '/user/repo.git'").unwrap(); assert!(matches!(op, GitOperation::UploadPack)); assert_eq!(path, "/user/repo.git"); } #[test] fn parse_receive_pack() { let (op, path) = parse_ssh_command("git-receive-pack '/user/repo.git'").unwrap(); assert!(matches!(op, GitOperation::ReceivePack)); assert_eq!(path, "/user/repo.git"); } #[test] fn parse_upload_archive() { let (op, path) = parse_ssh_command("git-upload-archive '/user/repo.git'").unwrap(); assert!(matches!(op, GitOperation::Archive)); assert_eq!(path, "/user/repo.git"); } #[test] fn parse_ssh_command_double_quotes() { let (_, path) = parse_ssh_command(r#"git-upload-pack "/user/repo.git""#).unwrap(); assert_eq!(path, "/user/repo.git"); } #[test] fn parse_ssh_command_unsupported() { assert!(parse_ssh_command("git-foo '/user/repo.git'").is_err()); } #[test] fn parse_ssh_command_no_space() { assert!(parse_ssh_command("git-upload-pack").is_err()); } // ── parse_repo_path ── #[test] fn parse_valid_repo_path() { let (owner, name) = parse_repo_path("/alice/myrepo.git").unwrap(); assert_eq!(owner, "alice"); assert_eq!(name, "myrepo"); } #[test] fn parse_repo_path_no_git_suffix() { let (owner, name) = parse_repo_path("/bob/project").unwrap(); assert_eq!(owner, "bob"); assert_eq!(name, "project"); } #[test] fn parse_repo_path_no_leading_slash() { let (owner, name) = parse_repo_path("carol/stuff.git").unwrap(); assert_eq!(owner, "carol"); assert_eq!(name, "stuff"); } #[test] fn parse_repo_path_traversal_rejected() { assert!(parse_repo_path("../evil/repo").is_err()); assert!(parse_repo_path("user/../repo").is_err()); } #[test] fn parse_repo_path_missing_repo() { assert!(parse_repo_path("/onlyowner").is_err()); } #[test] fn parse_repo_path_empty_owner() { assert!(parse_repo_path("//repo").is_err()); } #[test] fn parse_repo_path_bare_git_suffix_only() { assert!(parse_repo_path("/owner/.git").is_err()); } }