//! 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 crate::db::{self, UserId, Username}; use crate::validation::validate_git_repo_name; // ── Constants ── pub const AUTHORIZED_KEYS_PATH: &str = "/opt/git/.ssh/authorized_keys"; pub const MNW_ADMIN_PATH: &str = "/opt/mnw/current/mnw-admin"; // ── 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"); } // Dispatch: git operations start with "git-", everything else is a management command if original_cmd.starts_with("git-") { exec_git_operation(pool, user_id, &original_cmd).await } else { exec_management_command(pool, user_id, &ssh_username, &original_cmd).await } } 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. // Only register in the DB — the caller creates the bare repo on disk. 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(false); if !can_push { anyhow::bail!("permission denied: you do not have push access to {}/{}", owner, 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(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); let err = exec_git_shell(&sanitized_cmd); anyhow::bail!("failed to exec git-shell: {}", err); } 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)) } /// Replace the current process with git-shell. fn exec_git_shell(original_cmd: &str) -> std::io::Error { use std::os::unix::process::CommandExt; std::process::Command::new("git-shell") .args(["-c", original_cmd]) .exec() } /// Install a post-receive hook in a bare git repository. pub fn install_hook_for_repo( repo_dir: &std::path::Path, 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("post-receive"); 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(()) } // ── SSH management commands ── #[derive(Debug, PartialEq)] enum ManagementCommand { RepoList, RepoInfo { name: String }, RepoDelete { name: String }, RepoSetVisibility { name: String, visibility: db::Visibility }, RepoSetDescription { name: String, description: String }, KeyList, KeyRemove { fingerprint: String }, } /// Split a command string on whitespace, respecting double-quoted segments. fn shell_tokenize(input: &str) -> Vec { let mut tokens = Vec::new(); let mut current = String::new(); let mut in_quotes = false; for ch in input.chars() { if in_quotes { if ch == '"' { in_quotes = false; } else { current.push(ch); } } else if ch == '"' { in_quotes = true; } else if ch.is_ascii_whitespace() { if !current.is_empty() { tokens.push(std::mem::take(&mut current)); } } else { current.push(ch); } } if !current.is_empty() { tokens.push(current); } tokens } fn parse_management_command(tokens: &[String]) -> anyhow::Result { let strs: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect(); match strs.as_slice() { ["repo", "list"] => Ok(ManagementCommand::RepoList), ["repo", "info", name] => Ok(ManagementCommand::RepoInfo { name: name.to_string() }), ["repo", "delete", name, "--confirm"] => Ok(ManagementCommand::RepoDelete { name: name.to_string() }), ["repo", "delete", _, ..] => anyhow::bail!("repo delete requires --confirm flag"), ["repo", "set-visibility", name, vis] => { let visibility: db::Visibility = vis.parse() .map_err(|_| anyhow::anyhow!("visibility must be public, private, or unlisted"))?; Ok(ManagementCommand::RepoSetVisibility { name: name.to_string(), visibility, }) } ["repo", "set-description", name, desc] => Ok(ManagementCommand::RepoSetDescription { name: name.to_string(), description: desc.to_string(), }), ["key", "list"] => Ok(ManagementCommand::KeyList), ["key", "rm", fingerprint] => Ok(ManagementCommand::KeyRemove { fingerprint: fingerprint.to_string() }), _ => anyhow::bail!("unknown command; available: repo list|info|delete|set-visibility|set-description, key list|rm"), } } async fn exec_management_command( pool: &PgPool, user_id: UserId, username: &str, original_cmd: &str, ) -> anyhow::Result<()> { let tokens = shell_tokenize(original_cmd); let cmd = parse_management_command(&tokens)?; match cmd { ManagementCommand::RepoList => cmd_ssh_repo_list(pool, user_id).await, ManagementCommand::RepoInfo { name } => cmd_ssh_repo_info(pool, user_id, &name).await, ManagementCommand::RepoDelete { name } => cmd_ssh_repo_delete(pool, user_id, username, &name).await, ManagementCommand::RepoSetVisibility { name, visibility } => { cmd_ssh_repo_set_visibility(pool, user_id, &name, visibility).await } ManagementCommand::RepoSetDescription { name, description } => { cmd_ssh_repo_set_description(pool, user_id, &name, &description).await } ManagementCommand::KeyList => cmd_ssh_key_list(pool, user_id).await, ManagementCommand::KeyRemove { fingerprint } => cmd_ssh_key_remove(pool, user_id, &fingerprint).await, } } /// Render a value for a fixed-width table column: "-" if empty, ellipsized if /// wider than `max_width` (chars), otherwise the value unchanged. fn display_with_ellipsis(value: &str, max_width: usize) -> String { if value.is_empty() { "-".to_string() } else if value.chars().count() > max_width { let truncated: String = value.chars().take(max_width.saturating_sub(3)).collect(); format!("{truncated}...") } else { value.to_string() } } async fn cmd_ssh_repo_list(pool: &PgPool, user_id: UserId) -> anyhow::Result<()> { let repos = db::git_repos::get_repos_by_user(pool, user_id).await?; if repos.is_empty() { println!("No repositories."); return Ok(()); } println!("{:<30} {:<10} Description", "Name", "Visibility"); println!("{}", "-".repeat(70)); for repo in &repos { let desc = display_with_ellipsis(&repo.description, 28); println!("{:<30} {:<10} {}", repo.name, repo.visibility, desc); } println!("\n{} repo(s).", repos.len()); Ok(()) } async fn cmd_ssh_repo_info(pool: &PgPool, user_id: UserId, name: &str) -> anyhow::Result<()> { let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name) .await? .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?; let (open_issues, closed_issues) = db::issues::get_issue_counts(pool, repo.id).await?; println!("Name: {}", repo.name); println!("Visibility: {}", repo.visibility); println!("Description: {}", if repo.description.is_empty() { "-" } else { &repo.description }); println!("Created: {}", repo.created_at.format("%Y-%m-%d %H:%M UTC")); println!("Issues: {} open, {} closed", open_issues, closed_issues); Ok(()) } async fn cmd_ssh_repo_delete( pool: &PgPool, user_id: UserId, username: &str, name: &str, ) -> anyhow::Result<()> { let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name) .await? .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?; db::git_repos::delete_repo(pool, repo.id).await?; let git_root = std::env::var("GIT_REPOS_PATH") .unwrap_or_else(|_| "/opt/git".to_string()); let git_root_path = std::path::Path::new(&git_root); let repo_dir = git_root_path .join(username) .join(format!("{}.git", name)); if repo_dir.exists() { let canonical = repo_dir.canonicalize()?; let canonical_root = git_root_path.canonicalize()?; if !canonical.starts_with(&canonical_root) { anyhow::bail!("repo path escapes git root"); } std::fs::remove_dir_all(&canonical)?; } println!("Deleted repository '{}'.", name); Ok(()) } async fn cmd_ssh_repo_set_visibility( pool: &PgPool, user_id: UserId, name: &str, visibility: db::Visibility, ) -> anyhow::Result<()> { let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name) .await? .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?; db::git_repos::update_visibility(pool, repo.id, visibility).await?; println!("Set visibility of '{}' to '{}'.", name, visibility); Ok(()) } async fn cmd_ssh_repo_set_description( pool: &PgPool, user_id: UserId, name: &str, description: &str, ) -> anyhow::Result<()> { let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name) .await? .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?; db::git_repos::update_repo_settings(pool, repo.id, description, repo.visibility).await?; println!("Updated description of '{}'.", name); Ok(()) } async fn cmd_ssh_key_list(pool: &PgPool, user_id: UserId) -> anyhow::Result<()> { let keys = db::ssh_keys::list_keys_by_user(pool, user_id).await?; if keys.is_empty() { println!("No SSH keys."); return Ok(()); } println!("{:<50} {:<20} Added", "Fingerprint", "Label"); println!("{}", "-".repeat(80)); for key in &keys { let label = display_with_ellipsis(&key.label, 20); println!( "{:<50} {:<20} {}", key.fingerprint, label, key.created_at.format("%Y-%m-%d"), ); } println!("\n{} key(s).", keys.len()); Ok(()) } async fn cmd_ssh_key_remove(pool: &PgPool, user_id: UserId, fingerprint: &str) -> anyhow::Result<()> { let deleted = db::ssh_keys::delete_key_by_fingerprint(pool, user_id, fingerprint).await?; if !deleted { anyhow::bail!("SSH key with fingerprint '{}' not found", fingerprint); } write_authorized_keys(pool, true).await?; println!("Removed SSH key '{}'.", fingerprint); Ok(()) } /// 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 { content.push_str(&format!( "command=\"{} git-auth {}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {}\n", MNW_ADMIN_PATH, key.id, key.public_key, )); } let tmp_path = format!("{}.tmp", AUTHORIZED_KEYS_PATH); std::fs::write(&tmp_path, &content)?; std::fs::rename(&tmp_path, AUTHORIZED_KEYS_PATH)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(AUTHORIZED_KEYS_PATH, std::fs::Permissions::from_mode(0o600))?; if set_ownership { let status = std::process::Command::new("chown") .args(["git:git", AUTHORIZED_KEYS_PATH]) .status()?; if !status.success() { anyhow::bail!("chown git:git failed on {}", AUTHORIZED_KEYS_PATH); } } } Ok(()) } #[cfg(test)] mod tests { use super::*; // ── shell_tokenize ── #[test] fn tokenize_simple() { assert_eq!(shell_tokenize("repo list"), vec!["repo", "list"]); } #[test] fn tokenize_extra_whitespace() { assert_eq!( shell_tokenize(" repo info myrepo "), vec!["repo", "info", "myrepo"], ); } #[test] fn tokenize_quoted_string() { assert_eq!( shell_tokenize(r#"repo set-description myrepo "A cool project""#), vec!["repo", "set-description", "myrepo", "A cool project"], ); } #[test] fn tokenize_empty_quotes() { assert_eq!( shell_tokenize(r#"repo set-description myrepo """#), vec!["repo", "set-description", "myrepo"], ); } #[test] fn tokenize_unterminated_quote() { assert_eq!( shell_tokenize(r#"repo set-description myrepo "unterminated"#), vec!["repo", "set-description", "myrepo", "unterminated"], ); } #[test] fn tokenize_empty_input() { assert!(shell_tokenize("").is_empty()); assert!(shell_tokenize(" ").is_empty()); } // ── parse_ssh_command ── #[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()); } // ── parse_management_command ── #[test] fn parse_repo_list() { let tokens: Vec = vec!["repo".into(), "list".into()]; assert_eq!(parse_management_command(&tokens).unwrap(), ManagementCommand::RepoList); } #[test] fn parse_repo_info() { let tokens: Vec = vec!["repo".into(), "info".into(), "docengine".into()]; assert_eq!( parse_management_command(&tokens).unwrap(), ManagementCommand::RepoInfo { name: "docengine".into() }, ); } #[test] fn parse_repo_delete_with_confirm() { let tokens: Vec = vec!["repo".into(), "delete".into(), "old".into(), "--confirm".into()]; assert_eq!( parse_management_command(&tokens).unwrap(), ManagementCommand::RepoDelete { name: "old".into() }, ); } #[test] fn parse_repo_delete_without_confirm_fails() { let tokens: Vec = vec!["repo".into(), "delete".into(), "old".into()]; assert!(parse_management_command(&tokens).is_err()); } #[test] fn parse_repo_set_visibility() { let tokens: Vec = vec!["repo".into(), "set-visibility".into(), "myrepo".into(), "private".into()]; assert_eq!( parse_management_command(&tokens).unwrap(), ManagementCommand::RepoSetVisibility { name: "myrepo".into(), visibility: db::Visibility::Private }, ); } #[test] fn parse_repo_set_visibility_invalid() { let tokens: Vec = vec!["repo".into(), "set-visibility".into(), "myrepo".into(), "secret".into()]; assert!(parse_management_command(&tokens).is_err()); } #[test] fn parse_repo_set_description() { let tokens: Vec = vec!["repo".into(), "set-description".into(), "myrepo".into(), "A new description".into()]; assert_eq!( parse_management_command(&tokens).unwrap(), ManagementCommand::RepoSetDescription { name: "myrepo".into(), description: "A new description".into() }, ); } #[test] fn parse_key_list() { let tokens: Vec = vec!["key".into(), "list".into()]; assert_eq!(parse_management_command(&tokens).unwrap(), ManagementCommand::KeyList); } #[test] fn parse_key_rm() { let tokens: Vec = vec!["key".into(), "rm".into(), "SHA256:abc123".into()]; assert_eq!( parse_management_command(&tokens).unwrap(), ManagementCommand::KeyRemove { fingerprint: "SHA256:abc123".into() }, ); } #[test] fn parse_invalid_command() { let tokens: Vec = vec!["frobnicate".into()]; assert!(parse_management_command(&tokens).is_err()); } #[test] fn parse_empty_tokens() { let tokens: Vec = vec![]; assert!(parse_management_command(&tokens).is_err()); } }