use crate::error::GitError; use crate::hook::pool::types::{HookTask, TaskType}; use anyhow::Context; use base64::Engine; use config::AppConfig; use db::cache::AppCache; use db::database::AppDatabase; use deadpool_redis::cluster::Pool as RedisPool; use models::users::{user, user_token}; use russh::keys::PrivateKey; use russh::server::Server; use russh::{MethodKind, MethodSet, SshId, server::Config}; use sea_orm::prelude::*; use sha2::{Digest, Sha256}; use slog::{Logger, error, info}; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; pub mod authz; pub mod handle; pub mod rate_limit; pub mod server; #[derive(Clone)] pub struct SSHHandle { pub db: AppDatabase, pub app: AppConfig, pub cache: AppCache, pub redis_pool: RedisPool, pub logger: Logger, } impl SSHHandle { pub async fn run(&self) { let this = self.clone(); tokio::spawn(async move { if let Err(e) = this.run_ssh().await { error!(this.logger, "SSH server error: {}", e); } }); } pub fn new( db: AppDatabase, app: AppConfig, cache: AppCache, redis_pool: RedisPool, logger: Logger, ) -> Self { SSHHandle { db, app, cache, redis_pool, logger, } } pub async fn run_ssh(&self) -> anyhow::Result<()> { info!(self.logger, "SSH server starting"); let private_key_content = self.app.ssh_server_private_key()?; if private_key_content.is_empty() { return Err(anyhow::anyhow!("SSH server private key is not configured")); } let preview = if private_key_content.len() > 100 { format!("{}...", &private_key_content[..100]) } else { private_key_content.clone() }; info!( self.logger, "Loading SSH private key (hex, {} bytes)", private_key_content.len() ); let private_key_bytes = hex::decode(&private_key_content).with_context(|| { format!( "Failed to decode hex-encoded SSH private key. Preview: {}", preview ) })?; info!( self.logger, "Hex decoded to {} bytes", private_key_bytes.len() ); let private_key_pem = std::str::from_utf8(&private_key_bytes) .with_context(|| "Decoded SSH private key is not valid UTF-8")?; if let Some(first_line) = private_key_pem.lines().next() { info!(self.logger, "PEM format starts with: {}", first_line); } info!( self.logger, "Complete private key content:\n{}", private_key_pem ); let private_key = { match ssh_key::PrivateKey::from_openssh(private_key_pem) { Ok(ssh_key) => { info!(self.logger, "Successfully parsed with ssh-key crate"); let openssh_pem = ssh_key .to_openssh(ssh_key::LineEnding::LF) .with_context(|| "Failed to serialize to OpenSSH format")?; PrivateKey::from_str(&openssh_pem) .with_context(|| "Failed to parse with russh after ssh-key conversion")? } Err(e) => { info!( self.logger, "ssh-key from_openssh failed: {}, trying direct russh parse", e ); PrivateKey::from_str(private_key_pem).with_context(|| { format!("Failed to parse SSH private key with both methods") })? } } }; info!(self.logger, "SSH private key loaded"); let mut config = Config::default(); config.keys = vec![private_key]; let version = format!("SSH-2.0-GitdataAI {}", env!("CARGO_PKG_VERSION")); config.server_id = SshId::Standard(version); let mut method = MethodSet::empty(); method.push(MethodKind::PublicKey); method.push(MethodKind::Password); config.methods = method; config.inactivity_timeout = Some(Duration::from_secs(300)); config.keepalive_interval = Some(Duration::from_secs(60)); config.keepalive_max = 3; info!( self.logger, "SSH server configured with methods: {:?}", config.methods ); let token_service = SshTokenService::new(self.db.clone()); let mut server = server::SSHServer::new( self.db.clone(), self.cache.clone(), self.redis_pool.clone(), self.logger.clone(), token_service, ); let ssh_port = self.app.ssh_port()?; let bind_addr = format!("0.0.0.0:{}", ssh_port); let public_host = self.app.ssh_domain()?; let msg = if ssh_port == 22 { format!( "SSH server listening on port 22. Please use port {} for SSH connections.", ssh_port ) } else { format!( "SSH server listening on port {} (public: {}). Please use port {} for SSH connections.", ssh_port, public_host, ssh_port ) }; info!(self.logger, "{}", msg); server.run_on_address(Arc::new(config), bind_addr).await?; Ok(()) } } #[derive(Clone)] pub struct ReceiveSyncService { pool: RedisPool, logger: Logger, /// Redis key prefix for hook task queues, e.g. "{hook}". redis_prefix: String, } impl ReceiveSyncService { pub fn new(pool: RedisPool, logger: Logger) -> Self { Self { pool, logger, redis_prefix: "{hook}".to_string(), } } pub async fn send(&self, task: RepoReceiveSyncTask) { let hook_task = HookTask { id: uuid::Uuid::new_v4().to_string(), repo_id: task.repo_uid.to_string(), task_type: TaskType::Sync, payload: serde_json::Value::Null, created_at: chrono::Utc::now(), }; let task_json = match serde_json::to_string(&hook_task) { Ok(j) => j, Err(e) => { error!(self.logger, "Failed to serialize hook task: {}", e); return; } }; let queue_key = format!("{}:sync", self.redis_prefix); let redis = match self.pool.get().await { Ok(conn) => conn, Err(e) => { error!(self.logger, "Failed to get Redis connection: {}", e); return; } }; let mut conn: deadpool_redis::cluster::Connection = redis; if let Err(e) = redis::cmd("LPUSH") .arg(&queue_key) .arg(&task_json) .query_async::<()>(&mut conn) .await { error!(self.logger, "Failed to LPUSH sync task"; "error" => %e, "repo_id" => %task.repo_uid); } } } #[derive(Clone)] pub struct RepoReceiveSyncTask { pub repo_uid: uuid::Uuid, } /// SSH token authentication service. /// Uses the same token hash algorithm as user access keys (SHA256 + base64). #[derive(Clone)] pub struct SshTokenService { db: AppDatabase, } impl SshTokenService { pub fn new(db: AppDatabase) -> Self { Self { db } } fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); base64::prelude::BASE64_STANDARD.encode(hasher.finalize()) } pub async fn find_user_by_token(&self, token: &str) -> Result, GitError> { let token_hash = Self::hash_token(token); let token_model = user_token::Entity::find() .filter(user_token::Column::TokenHash.eq(&token_hash)) .filter(user_token::Column::IsRevoked.eq(false)) .one(self.db.reader()) .await .map_err(|e| GitError::Internal(e.to_string()))?; let token_model = match token_model { Some(t) => t, None => return Ok(None), }; // Check expiry if let Some(expires_at) = token_model.expires_at { if expires_at < chrono::Utc::now() { return Ok(None); } } let user_model = user::Entity::find() .filter(user::Column::Uid.eq(token_model.user)) .one(self.db.reader()) .await .map_err(|e| GitError::Internal(e.to_string()))?; Ok(user_model) } } pub async fn run_ssh(config: AppConfig, logger: Logger) -> anyhow::Result<()> { info!(logger, "SSH server initializing"); let db = AppDatabase::init(&config).await?; let cache = AppCache::init(&config).await?; let redis_pool = cache.redis_pool().clone(); SSHHandle::new(db, config.clone(), cache, redis_pool, logger) .run_ssh() .await?; Ok(()) }