289 lines
8.9 KiB
Rust
289 lines
8.9 KiB
Rust
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<Option<user::Model>, 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(())
|
|
}
|