a73x

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")));
    }
}