src/server/ssh/session.rs
Ref: Size: 12.4 KiB
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 tokio::sync::mpsc;
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,
/// Sender for forwarding client data (stdin) to the spawned git subprocess.
stdin_tx: Option<mpsc::Sender<Vec<u8>>>,
}
impl SshHandler {
pub fn new(config: Arc<SshServerConfig>) -> Self {
Self {
config,
authenticated: false,
stdin_tx: None,
}
}
}
/// 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 `..` or symlinks).
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);
// Prefix check on the joined path
if !full.starts_with(repos_dir) {
return None;
}
// If the path exists, canonicalize both to catch symlink escapes
if full.exists() {
if let (Ok(canon_repos), Ok(canon_full)) =
(repos_dir.canonicalize(), full.canonicalize())
{
if !canon_full.starts_with(&canon_repos) {
return None;
}
}
}
Some(full)
}
fn ensure_repo_exists_for_command(git_cmd: &str, repo_path: &Path) -> Result<bool, git2::Error> {
if repo_path.exists() {
return Ok(false);
}
if git_cmd != "git-receive-pack" {
return Ok(false);
}
if let Some(parent) = repo_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| git2::Error::from_str(&format!("failed to create repo parent dir: {e}")))?;
}
git2::Repository::init_bare(repo_path)?;
Ok(true)
}
#[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() {
match ensure_repo_exists_for_command(&git_cmd, &resolved_path) {
Ok(true) => {
info!("Created bare repo for receive-pack: {:?}", resolved_path);
}
Ok(false) => {
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(());
}
Err(e) => {
error!("Failed to create repo {:?}: {}", resolved_path, e);
session.exit_status_request(channel, 1);
session.eof(channel);
session.close(channel);
return Ok(());
}
}
}
// Create a channel for forwarding client stdin data to the git subprocess
let (tx, rx) = mpsc::channel::<Vec<u8>>(64);
self.stdin_tx = Some(tx);
// 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, rx).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 git subprocess's stdin
if let Some(ref tx) = self.stdin_tx {
if tx.send(data.to_vec()).await.is_err() {
debug!("stdin channel closed, dropping data");
}
}
Ok(())
}
}
async fn run_git_command(
handle: russh::server::Handle,
channel: ChannelId,
git_cmd: &str,
repo_path: &Path,
mut stdin_rx: mpsc::Receiver<Vec<u8>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut child = Command::new(git_cmd)
.arg(repo_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
let mut child_stdin = child.stdin.take().expect("stdin piped");
let mut stdout = child.stdout.take().expect("stdout piped");
// Spawn a task to forward client data to the child's stdin
tokio::spawn(async move {
while let Some(data) = stdin_rx.recv().await {
if child_stdin.write_all(&data).await.is_err() {
break;
}
}
// EOF: close stdin so the child knows we're done
drop(child_stdin);
});
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::*;
use tempfile::TempDir;
#[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")));
}
#[test]
fn receive_pack_creates_missing_bare_repo() {
let tmp = TempDir::new().unwrap();
let repo_path = tmp.path().join("org").join("new-repo.git");
let created = ensure_repo_exists_for_command("git-receive-pack", &repo_path).unwrap();
assert!(created);
assert!(repo_path.exists());
let repo = git2::Repository::open_bare(&repo_path).unwrap();
assert!(repo.is_bare());
}
#[test]
fn upload_pack_does_not_create_missing_repo() {
let tmp = TempDir::new().unwrap();
let repo_path = tmp.path().join("org").join("missing.git");
let created = ensure_repo_exists_for_command("git-upload-pack", &repo_path).unwrap();
assert!(!created);
assert!(!repo_path.exists());
}
}