198 lines
5.9 KiB
Rust
198 lines
5.9 KiB
Rust
use std::{sync::Arc, time::Duration};
|
|
|
|
use anyhow::Context;
|
|
use argon2::{
|
|
Argon2,
|
|
password_hash::{PasswordHash, PasswordVerifier},
|
|
};
|
|
use cache::AppCache;
|
|
use config::AppConfig;
|
|
use db::{database::AppDatabase, sqlx};
|
|
use deadpool_redis::cluster::Pool as RedisPool;
|
|
use model::users::{UserModel, UserTokenModel};
|
|
use russh::{
|
|
MethodKind, MethodSet, SshId,
|
|
server::{Config, Server},
|
|
};
|
|
|
|
use crate::errors::GitError;
|
|
|
|
pub mod authz;
|
|
pub mod branch_protect;
|
|
pub mod forward;
|
|
pub mod git_service;
|
|
pub mod handler;
|
|
pub mod rate_limit;
|
|
pub mod ref_update;
|
|
pub mod server;
|
|
|
|
#[derive(Clone)]
|
|
pub struct SSHHandle {
|
|
pub db: AppDatabase,
|
|
pub app: AppConfig,
|
|
pub cache: AppCache,
|
|
pub redis_pool: RedisPool,
|
|
}
|
|
|
|
impl SSHHandle {
|
|
pub fn new(
|
|
db: AppDatabase,
|
|
app: AppConfig,
|
|
cache: AppCache,
|
|
redis_pool: RedisPool,
|
|
) -> Self {
|
|
SSHHandle {
|
|
db,
|
|
app,
|
|
cache,
|
|
redis_pool,
|
|
}
|
|
}
|
|
|
|
pub async fn run_ssh(&self) -> anyhow::Result<()> {
|
|
tracing::info!("SSH server starting");
|
|
let key_file = self.app.ssh_server_private_key_file()?;
|
|
if key_file.is_empty() {
|
|
return Err(anyhow::anyhow!(
|
|
"SSH server private key file is not configured (APP_SSH_SERVER_PRIVATE_KEY_FILE)"
|
|
));
|
|
}
|
|
|
|
tracing::info!("Loading SSH private key from file: {}", key_file);
|
|
|
|
let private_key_pem =
|
|
std::fs::read_to_string(&key_file).with_context(|| {
|
|
format!("Failed to read SSH private key file: {}", key_file)
|
|
})?;
|
|
|
|
let private_key = russh::keys::decode_secret_key(&private_key_pem, None)
|
|
.or_else(|e| {
|
|
tracing::info!("decode_secret_key failed: {}, trying from_openssh", e);
|
|
russh::keys::ssh_key::PrivateKey::from_openssh(&private_key_pem)
|
|
.map_err(|e2| anyhow::anyhow!(
|
|
"Failed to parse SSH private key from {}: decode_secret_key={}, from_openssh={}",
|
|
key_file, e, e2
|
|
))
|
|
})?;
|
|
|
|
tracing::info!("SSH private key loaded");
|
|
let mut config = Config::default();
|
|
config.keys = vec![private_key];
|
|
let version = format!("SSH-2.0-Work {}", env!("CARGO_PKG_VERSION"));
|
|
config.server_id = SshId::Standard(version.into());
|
|
config.methods = MethodSet::empty();
|
|
config.methods.push(MethodKind::PublicKey);
|
|
config.methods.push(MethodKind::Password);
|
|
config.auth_rejection_time = Duration::from_secs(5);
|
|
config.inactivity_timeout = Some(Duration::from_secs(300));
|
|
config.keepalive_interval = Some(Duration::from_secs(60));
|
|
config.keepalive_max = 3;
|
|
|
|
tracing::info!(
|
|
"SSH server configured with methods: {:?}",
|
|
config.methods
|
|
);
|
|
let token_service = SshTokenService::new(self.db.clone());
|
|
let mut ssh_server = server::SSHServer::new(
|
|
self.db.clone(),
|
|
self.cache.clone(),
|
|
self.redis_pool.clone(),
|
|
token_service,
|
|
);
|
|
|
|
let _cleanup = ssh_server.rate_limiter.clone().start_cleanup();
|
|
|
|
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
|
|
)
|
|
};
|
|
tracing::info!("{}", msg);
|
|
ssh_server
|
|
.run_on_address(Arc::new(config), bind_addr)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
#[derive(Clone)]
|
|
pub struct SshTokenService {
|
|
db: AppDatabase,
|
|
}
|
|
|
|
impl SshTokenService {
|
|
pub fn new(db: AppDatabase) -> Self {
|
|
Self { db }
|
|
}
|
|
|
|
pub async fn find_user_by_token(
|
|
&self,
|
|
token: &str,
|
|
) -> Result<Option<UserModel>, GitError> {
|
|
let token_models = sqlx::query_as::<_, UserTokenModel>(
|
|
"SELECT id, user, name, token_hash, scopes, expires_at, is_revoked, created_at, updated_at FROM user_token WHERE is_revoked = false",
|
|
)
|
|
.fetch_all(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?;
|
|
|
|
for token_model in token_models {
|
|
if token_model
|
|
.expires_at
|
|
.map(|expires_at| expires_at < chrono::Utc::now())
|
|
.unwrap_or(false)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let Ok(hash) = PasswordHash::new(&token_model.token_hash) else {
|
|
tracing::warn!(
|
|
token_id = token_model.id,
|
|
"invalid stored SSH token hash"
|
|
);
|
|
continue;
|
|
};
|
|
|
|
if Argon2::default()
|
|
.verify_password(token.as_bytes(), &hash)
|
|
.is_err()
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let user_model = sqlx::query_as::<_, UserModel>(
|
|
"SELECT id, username, display_name, avatar_url, website_url, allow_use, can_search, last_sign_in_at, created_at, updated_at FROM \"user\" WHERE id = $1",
|
|
)
|
|
.bind(token_model.user)
|
|
.fetch_optional(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?;
|
|
|
|
return Ok(user_model);
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
pub async fn run_ssh(
|
|
config: AppConfig,
|
|
db: AppDatabase,
|
|
cache: AppCache,
|
|
redis_pool: RedisPool,
|
|
) -> anyhow::Result<()> {
|
|
tracing::info!("SSH server initializing");
|
|
SSHHandle::new(db, config.clone(), cache, redis_pool)
|
|
.run_ssh()
|
|
.await?;
|
|
Ok(())
|
|
}
|