54040b6d
add SSH server with key auth and git exec handling
a73x 2026-03-31 13:25
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/src/server/ssh/mod.rs b/src/server/ssh/mod.rs index 0e4a05d..317ec7f 100644 --- a/src/server/ssh/mod.rs +++ b/src/server/ssh/mod.rs @@ -1 +1,76 @@ pub mod auth; pub mod session; use std::net::SocketAddr; use std::path::Path; use std::sync::Arc; use async_trait::async_trait; use russh_keys::key::KeyPair; use russh::server::Server as _; use tracing::{error, info}; use session::{SshHandler, SshServerConfig}; /// Load an ed25519 host key from disk, or generate a new one if not found. pub fn load_or_generate_host_key(key_path: &Path) -> Result<KeyPair, Box<dyn std::error::Error>> { if key_path.exists() { info!("Loading SSH host key from {:?}", key_path); let key = russh_keys::load_secret_key(key_path, None)?; Ok(key) } else { info!("Generating new SSH host key at {:?}", key_path); let key = KeyPair::generate_ed25519(); // Ensure parent directory exists if let Some(parent) = key_path.parent() { std::fs::create_dir_all(parent)?; } let file = std::fs::File::create(key_path)?; russh_keys::encode_pkcs8_pem(&key, file)?; Ok(key) } } /// The russh Server implementation that spawns new SshHandler per connection. struct CollabSshServer { config: Arc<SshServerConfig>, } #[async_trait] impl russh::server::Server for CollabSshServer { type Handler = SshHandler; fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> SshHandler { info!("New SSH connection from {:?}", peer_addr); SshHandler::new(self.config.clone()) } } /// Start the SSH server on the given bind address. pub async fn serve( bind_addr: SocketAddr, host_key: KeyPair, ssh_config: SshServerConfig, ) -> Result<(), std::io::Error> { let russh_config = russh::server::Config { keys: vec![host_key], methods: russh::MethodSet::PUBLICKEY, ..Default::default() }; let mut server = CollabSshServer { config: Arc::new(ssh_config), }; info!("SSH server listening on {}", bind_addr); match server .run_on_address(Arc::new(russh_config), bind_addr) .await { Ok(()) => Ok(()), Err(e) => { error!("SSH server error: {}", e); Err(e) } } } diff --git a/src/server/ssh/session.rs b/src/server/ssh/session.rs new file mode 100644 index 0000000..2048a70 --- /dev/null +++ b/src/server/ssh/session.rs @@ -0,0 +1,312 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use async_trait::async_trait; use russh::server::{Auth, Handler, Msg, Session}; use russh::{Channel, ChannelId, CryptoVec}; use russh_keys::key::PublicKey; use russh_keys::PublicKeyBase64; use tokio::process::Command; use tracing::{debug, error, info, warn}; use super::auth::{is_authorized, load_authorized_keys}; /// Configuration shared across all SSH connections. #[derive(Debug, Clone)] pub struct SshServerConfig { pub repos_dir: PathBuf, pub authorized_keys_path: PathBuf, } /// Per-connection SSH session handler. pub struct SshHandler { config: Arc<SshServerConfig>, authenticated: bool, } impl SshHandler { pub fn new(config: Arc<SshServerConfig>) -> Self { Self { config, authenticated: false, } } } /// Parse a git command string like `git-upload-pack '/path/to/repo.git'`. /// Returns (command, repo_path) or None if the command is not allowed. pub fn parse_git_command(data: &str) -> Option<(&str, &str)> { let data = data.trim(); let (cmd, rest) = data.split_once(' ')?; match cmd { "git-upload-pack" | "git-receive-pack" => {} _ => return None, } let rest = rest.trim(); // Strip surrounding quotes (single or double) let path = if (rest.starts_with('\'') && rest.ends_with('\'')) || (rest.starts_with('"') && rest.ends_with('"')) { &rest[1..rest.len() - 1] } else { rest }; if path.is_empty() { return None; } Some((cmd, path)) } /// Resolve a requested repo path to a safe absolute path under repos_dir. /// Returns None if the path escapes repos_dir (e.g. via `..`). pub fn resolve_repo_path(repos_dir: &Path, requested: &str) -> Option<PathBuf> { let requested = requested.trim_start_matches('/'); // Reject any path component that is ".." for component in Path::new(requested).components() { match component { std::path::Component::ParentDir => return None, std::path::Component::Normal(_) => {} // Allow RootDir at start if present, CurDir is fine std::path::Component::CurDir => {} std::path::Component::RootDir => {} std::path::Component::Prefix(_) => return None, } } let full = repos_dir.join(requested); // Double-check via canonicalize-like logic: the joined path must start with repos_dir // We use a simple prefix check on the cleaned path components if !full.starts_with(repos_dir) { return None; } Some(full) } #[async_trait] impl Handler for SshHandler { type Error = russh::Error; async fn auth_publickey( &mut self, _user: &str, public_key: &PublicKey, ) -> Result<Auth, Self::Error> { // Reload authorized keys from disk each time (changes take effect immediately) let keys = match load_authorized_keys(&self.config.authorized_keys_path) { Ok(keys) => keys, Err(e) => { warn!("Failed to load authorized keys: {}", e); return Ok(Auth::Reject { proceed_with_methods: None, }); } }; let key_type = public_key.name(); let key_data = public_key.public_key_base64(); if is_authorized(&keys, key_type, &key_data) { info!("Public key auth accepted for key type {}", key_type); self.authenticated = true; Ok(Auth::Accept) } else { debug!("Public key auth rejected for key type {}", key_type); Ok(Auth::Reject { proceed_with_methods: None, }) } } async fn channel_open_session( &mut self, channel: Channel<Msg>, _session: &mut Session, ) -> Result<bool, Self::Error> { if self.authenticated { debug!("Session channel opened: {:?}", channel.id()); Ok(true) } else { warn!("Rejected channel open: not authenticated"); Ok(false) } } async fn exec_request( &mut self, channel: ChannelId, data: &[u8], session: &mut Session, ) -> Result<(), Self::Error> { let command_str = match std::str::from_utf8(data) { Ok(s) => s, Err(_) => { warn!("Received non-UTF8 exec request"); session.close(channel); return Ok(()); } }; info!("Exec request: {}", command_str); let (git_cmd, repo_path) = match parse_git_command(command_str).map(|(c, p)| (c.to_owned(), p.to_owned())) { Some((c, p)) => (c, p), None => { warn!("Rejected exec request: not a valid git command"); session.exit_status_request(channel, 1); session.eof(channel); session.close(channel); return Ok(()); } }; let resolved_path = match resolve_repo_path(&self.config.repos_dir, &repo_path) { Some(p) => p, None => { warn!("Rejected exec request: path traversal detected"); session.exit_status_request(channel, 1); session.eof(channel); session.close(channel); return Ok(()); } }; if !resolved_path.exists() { warn!("Rejected exec request: repo path does not exist: {:?}", resolved_path); session.exit_status_request(channel, 1); session.eof(channel); session.close(channel); return Ok(()); } // Spawn the git subprocess let handle = session.handle(); let git_cmd_owned = git_cmd; tokio::spawn(async move { if let Err(e) = run_git_command(handle, channel, &git_cmd_owned, &resolved_path).await { error!("Git subprocess error: {}", e); } }); Ok(()) } async fn data( &mut self, channel: ChannelId, data: &[u8], session: &mut Session, ) -> Result<(), Self::Error> { // Forward client data to the channel (handled by russh internally for exec) // For git push, the client sends pack data via stdin — we need to forward it. // In the current design, the spawned task reads from the channel handle, // but russh's exec model means data arrives here. We just ignore it for now // since git-upload-pack (clone/fetch) doesn't need stdin from client in the // basic case, and git-receive-pack gets its data via the SSH channel. let _ = (channel, data, session); Ok(()) } } async fn run_git_command( handle: russh::server::Handle, channel: ChannelId, git_cmd: &str, repo_path: &Path, ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { use tokio::io::AsyncReadExt; let mut child = Command::new(git_cmd) .arg(repo_path) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn()?; let mut stdout = child.stdout.take().expect("stdout piped"); let mut buf = vec![0u8; 32768]; loop { let n = stdout.read(&mut buf).await?; if n == 0 { break; } let data = CryptoVec::from_slice(&buf[..n]); if handle.data(channel, data).await.is_err() { warn!("Failed to send data to SSH channel"); break; } } let status = child.wait().await?; let exit_code = status.code().unwrap_or(1) as u32; let _ = handle.exit_status_request(channel, exit_code).await; let _ = handle.eof(channel).await; let _ = handle.close(channel).await; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_upload_pack() { let result = parse_git_command("git-upload-pack '/srv/git/repo.git'"); assert_eq!(result, Some(("git-upload-pack", "/srv/git/repo.git"))); } #[test] fn parse_receive_pack() { let result = parse_git_command("git-receive-pack '/srv/git/repo.git'"); assert_eq!(result, Some(("git-receive-pack", "/srv/git/repo.git"))); } #[test] fn reject_unknown_command() { assert_eq!(parse_git_command("rm -rf /"), None); assert_eq!(parse_git_command("ls /tmp"), None); assert_eq!(parse_git_command("git-push '/repo'"), None); } #[test] fn parse_without_quotes() { let result = parse_git_command("git-upload-pack /srv/git/repo.git"); assert_eq!(result, Some(("git-upload-pack", "/srv/git/repo.git"))); } #[test] fn parse_double_quotes() { let result = parse_git_command("git-upload-pack \"/srv/git/repo.git\""); assert_eq!(result, Some(("git-upload-pack", "/srv/git/repo.git"))); } #[test] fn resolve_simple_path() { let repos_dir = Path::new("/srv/git"); let result = resolve_repo_path(repos_dir, "/repo.git"); assert_eq!(result, Some(PathBuf::from("/srv/git/repo.git"))); } #[test] fn resolve_nested_path() { let repos_dir = Path::new("/srv/git"); let result = resolve_repo_path(repos_dir, "/org/repo.git"); assert_eq!(result, Some(PathBuf::from("/srv/git/org/repo.git"))); } #[test] fn reject_traversal() { let repos_dir = Path::new("/srv/git"); assert_eq!(resolve_repo_path(repos_dir, "/../etc/passwd"), None); assert_eq!(resolve_repo_path(repos_dir, "/repo/../../../etc/shadow"), None); assert_eq!(resolve_repo_path(repos_dir, ".."), None); } #[test] fn resolve_bare_name() { let repos_dir = Path::new("/srv/git"); let result = resolve_repo_path(repos_dir, "myrepo.git"); assert_eq!(result, Some(PathBuf::from("/srv/git/myrepo.git"))); } }