| 1 | //! `dogfood-ssh` — Git over SSH (spec §6, §9). |
| 2 | //! |
| 3 | //! Public-key authentication only. The presented key's SHA256 fingerprint is |
| 4 | //! looked up in `ssh_keys`, which resolves the user; then only |
| 5 | //! `git-upload-pack` and `git-receive-pack` are dispatched. |
| 6 | //! |
| 7 | //! No password auth, no shell, no forwarding, no PTY, no SFTP. |
| 8 | |
| 9 | use std::sync::Arc; |
| 10 | |
| 11 | use anyhow::{Context, Result}; |
| 12 | use russh::server::{Auth, Handler, Msg, Server as _, Session}; |
| 13 | use russh::{Channel, ChannelId, MethodKind, MethodSet}; |
| 14 | use sqlx::PgPool; |
| 15 | use uuid::Uuid; |
| 16 | |
| 17 | mod exec; |
| 18 | mod limits; |
| 19 | mod repo; |
| 20 | |
| 21 | |
| 22 | /// How long a connection may sit before authenticating. |
| 23 | /// |
| 24 | /// Distinct from [`INACTIVITY_TIMEOUT`], which governs an *established* session: |
| 25 | /// a client that connects and then says nothing has done no work we can charge |
| 26 | /// it for, and holding the slot open is the whole attack. |
| 27 | const AUTH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); |
| 28 | |
| 29 | /// How long an authenticated session may sit idle. Generous, because a large |
| 30 | /// `git-upload-pack` can be quiet for a while as the client works. |
| 31 | const INACTIVITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); |
| 32 | |
| 33 | struct Server { |
| 34 | db: PgPool, |
| 35 | repo_root: String, |
| 36 | database_url: String, |
| 37 | hook_binary: String, |
| 38 | /// Per-source-IP connection limits (spec §9). |
| 39 | limits: limits::ConnectionLimits, |
| 40 | } |
| 41 | |
| 42 | impl russh::server::Server for Server { |
| 43 | type Handler = Connection; |
| 44 | |
| 45 | fn new_client(&mut self, peer: Option<std::net::SocketAddr>) -> Connection { |
| 46 | // Admission happens here, before the key exchange — the earliest point |
| 47 | // at which the peer is known and the last one before we spend anything |
| 48 | // on its behalf. A refused connection is still constructed (russh gives |
| 49 | // no way to decline one at this point), but it carries no guard and is |
| 50 | // rejected at the first authentication attempt. |
| 51 | let guard = self.limits.admit(peer); |
| 52 | if guard.is_none() && peer.is_some() { |
| 53 | tracing::warn!(?peer, "ssh connection refused: source rate limit"); |
| 54 | } |
| 55 | |
| 56 | Connection { |
| 57 | db: self.db.clone(), |
| 58 | repo_root: self.repo_root.clone(), |
| 59 | database_url: self.database_url.clone(), |
| 60 | hook_binary: self.hook_binary.clone(), |
| 61 | peer, |
| 62 | admitted: guard.is_some() || peer.is_none(), |
| 63 | _guard: guard, |
| 64 | user: None, |
| 65 | git_stdin: None, |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | fn handle_session_error(&mut self, error: <Connection as Handler>::Error) { |
| 70 | // Client disconnects are routine; log at debug so they do not drown the |
| 71 | // signal. |
| 72 | tracing::debug!("ssh session ended: {error}"); |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | struct Connection { |
| 77 | db: PgPool, |
| 78 | repo_root: String, |
| 79 | database_url: String, |
| 80 | hook_binary: String, |
| 81 | peer: Option<std::net::SocketAddr>, |
| 82 | /// Whether the source's connection limit admitted this connection. |
| 83 | admitted: bool, |
| 84 | /// Holds the source's concurrency slot for the life of the connection. |
| 85 | _guard: Option<limits::ConnectionGuard>, |
| 86 | /// Set once a key has authenticated. |
| 87 | user: Option<AuthedUser>, |
| 88 | /// git's stdin for the running service, fed by `data`. |
| 89 | git_stdin: Option<tokio::process::ChildStdin>, |
| 90 | } |
| 91 | |
| 92 | #[derive(Clone)] |
| 93 | struct AuthedUser { |
| 94 | id: Uuid, |
| 95 | handle: String, |
| 96 | } |
| 97 | |
| 98 | impl Handler for Connection { |
| 99 | type Error = anyhow::Error; |
| 100 | |
| 101 | /// Public-key authentication. |
| 102 | /// |
| 103 | /// The SSH username is ignored entirely — everyone connects as `git`, and |
| 104 | /// the key decides who they are. That is the standard forge convention and |
| 105 | /// means a user does not have to configure a per-host username. |
| 106 | async fn auth_publickey( |
| 107 | &mut self, |
| 108 | _user: &str, |
| 109 | key: &russh::keys::ssh_key::PublicKey, |
| 110 | ) -> Result<Auth, Self::Error> { |
| 111 | // A connection the limiter refused never gets as far as a database |
| 112 | // lookup — which is the work the limit exists to prevent. |
| 113 | if !self.admitted { |
| 114 | return Ok(Auth::Reject { |
| 115 | proceed_with_methods: None, |
| 116 | partial_success: false, |
| 117 | }); |
| 118 | } |
| 119 | |
| 120 | let fingerprint = key |
| 121 | .fingerprint(russh::keys::ssh_key::HashAlg::Sha256) |
| 122 | .to_string(); |
| 123 | |
| 124 | let found: Option<(Uuid, String)> = sqlx::query_as( |
| 125 | "SELECT u.id, u.handle::text |
| 126 | FROM ssh_keys k JOIN users u ON u.id = k.user_id |
| 127 | WHERE k.fingerprint = $1", |
| 128 | ) |
| 129 | .bind(&fingerprint) |
| 130 | .fetch_optional(&self.db) |
| 131 | .await |
| 132 | .unwrap_or(None); |
| 133 | |
| 134 | match found { |
| 135 | Some((id, handle)) => { |
| 136 | tracing::info!(peer = ?self.peer, %handle, "ssh key accepted"); |
| 137 | // Best-effort usage tracking; never fail auth on it. |
| 138 | let _ = sqlx::query("UPDATE ssh_keys SET last_used_at = now() WHERE fingerprint = $1") |
| 139 | .bind(&fingerprint) |
| 140 | .execute(&self.db) |
| 141 | .await; |
| 142 | self.user = Some(AuthedUser { id, handle }); |
| 143 | Ok(Auth::Accept) |
| 144 | } |
| 145 | None => { |
| 146 | tracing::info!(peer = ?self.peer, %fingerprint, "ssh key rejected: unknown"); |
| 147 | Ok(Auth::Reject { |
| 148 | proceed_with_methods: None, |
| 149 | partial_success: false, |
| 150 | }) |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | /// Password authentication is never permitted (spec §9). |
| 156 | async fn auth_password(&mut self, _user: &str, _password: &str) -> Result<Auth, Self::Error> { |
| 157 | Ok(Auth::Reject { |
| 158 | proceed_with_methods: Some(publickey_only()), |
| 159 | partial_success: false, |
| 160 | }) |
| 161 | } |
| 162 | |
| 163 | async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> { |
| 164 | Ok(Auth::Reject { |
| 165 | proceed_with_methods: Some(publickey_only()), |
| 166 | partial_success: false, |
| 167 | }) |
| 168 | } |
| 169 | |
| 170 | async fn channel_open_session( |
| 171 | &mut self, |
| 172 | _channel: Channel<Msg>, |
| 173 | _session: &mut Session, |
| 174 | ) -> Result<bool, Self::Error> { |
| 175 | Ok(true) |
| 176 | } |
| 177 | |
| 178 | /// The only thing a session may do. |
| 179 | async fn exec_request( |
| 180 | &mut self, |
| 181 | channel: ChannelId, |
| 182 | data: &[u8], |
| 183 | session: &mut Session, |
| 184 | ) -> Result<(), Self::Error> { |
| 185 | let Some(user) = self.user.clone() else { |
| 186 | // Cannot happen — russh will not deliver exec before auth — but |
| 187 | // default-deny rather than assume. |
| 188 | return deny(session, channel, "Not authenticated."); |
| 189 | }; |
| 190 | |
| 191 | let command = String::from_utf8_lossy(data).to_string(); |
| 192 | |
| 193 | let request = match exec::parse(&command) { |
| 194 | Ok(r) => r, |
| 195 | Err(e) => { |
| 196 | tracing::info!(handle = %user.handle, "rejected ssh exec: {e}"); |
| 197 | return deny(session, channel, &e.to_string()); |
| 198 | } |
| 199 | }; |
| 200 | |
| 201 | // Authorize before touching the filesystem. |
| 202 | let resolved = match repo::authorize( |
| 203 | &self.db, |
| 204 | &request.owner, |
| 205 | &request.repo, |
| 206 | user.id, |
| 207 | request.service.is_write(), |
| 208 | ) |
| 209 | .await |
| 210 | { |
| 211 | Ok(Some(r)) => r, |
| 212 | Ok(None) => { |
| 213 | // Same message whether the repo is missing or merely invisible |
| 214 | // (spec §9). |
| 215 | return deny( |
| 216 | session, |
| 217 | channel, |
| 218 | &format!( |
| 219 | "Repository '{}/{}' not found.", |
| 220 | request.owner, request.repo |
| 221 | ), |
| 222 | ); |
| 223 | } |
| 224 | Err(e) => { |
| 225 | tracing::error!("authorizing ssh request failed: {e:#}"); |
| 226 | return deny(session, channel, "Internal error."); |
| 227 | } |
| 228 | }; |
| 229 | |
| 230 | if request.service.is_write() && !resolved.can_push { |
| 231 | return deny( |
| 232 | session, |
| 233 | channel, |
| 234 | if resolved.archived { |
| 235 | "This repository is archived and does not accept pushes." |
| 236 | } else { |
| 237 | "You do not have push access to this repository." |
| 238 | }, |
| 239 | ); |
| 240 | } |
| 241 | |
| 242 | let dir = repo::repo_path(&self.repo_root, resolved.repo_id); |
| 243 | if !dir.is_dir() { |
| 244 | tracing::error!(?dir, "repository directory missing"); |
| 245 | return deny(session, channel, "Repository storage is unavailable."); |
| 246 | } |
| 247 | |
| 248 | tracing::info!( |
| 249 | handle = %user.handle, |
| 250 | service = request.service.git_subcommand(), |
| 251 | repo = %resolved.repo_id, |
| 252 | "dispatching git" |
| 253 | ); |
| 254 | |
| 255 | // git's stdout/stderr stream back from inside `run_git`; its stdin is |
| 256 | // returned here so `data` can feed it the client's pack. |
| 257 | let stdin = repo::run_git( |
| 258 | session, |
| 259 | channel, |
| 260 | request.service, |
| 261 | &dir, |
| 262 | &self.db, |
| 263 | &self.database_url, |
| 264 | &self.hook_binary, |
| 265 | resolved.repo_id, |
| 266 | user.id, |
| 267 | ) |
| 268 | .await?; |
| 269 | |
| 270 | self.git_stdin = Some(stdin); |
| 271 | Ok(()) |
| 272 | } |
| 273 | |
| 274 | /// Client -> git. Every frame the client sends is the other half of the |
| 275 | /// pack protocol conversation. |
| 276 | async fn data( |
| 277 | &mut self, |
| 278 | _channel: ChannelId, |
| 279 | data: &[u8], |
| 280 | _session: &mut Session, |
| 281 | ) -> Result<(), Self::Error> { |
| 282 | if let Some(stdin) = self.git_stdin.as_mut() { |
| 283 | use tokio::io::AsyncWriteExt; |
| 284 | if let Err(e) = stdin.write_all(data).await { |
| 285 | // The child exited early — a rejected push, for instance. |
| 286 | tracing::debug!("writing to git stdin failed: {e}"); |
| 287 | self.git_stdin = None; |
| 288 | } |
| 289 | } |
| 290 | Ok(()) |
| 291 | } |
| 292 | |
| 293 | /// The client has finished sending. Closing stdin is what lets |
| 294 | /// receive-pack stop waiting and start processing. |
| 295 | async fn channel_eof( |
| 296 | &mut self, |
| 297 | _channel: ChannelId, |
| 298 | _session: &mut Session, |
| 299 | ) -> Result<(), Self::Error> { |
| 300 | if let Some(mut stdin) = self.git_stdin.take() { |
| 301 | use tokio::io::AsyncWriteExt; |
| 302 | let _ = stdin.shutdown().await; |
| 303 | } |
| 304 | Ok(()) |
| 305 | } |
| 306 | |
| 307 | // ── everything else is refused (spec §9) ───────────────────────────────── |
| 308 | |
| 309 | async fn shell_request( |
| 310 | &mut self, |
| 311 | channel: ChannelId, |
| 312 | session: &mut Session, |
| 313 | ) -> Result<(), Self::Error> { |
| 314 | deny( |
| 315 | session, |
| 316 | channel, |
| 317 | "Dogfood does not provide shell access. Use git or jj.", |
| 318 | ) |
| 319 | } |
| 320 | |
| 321 | async fn pty_request( |
| 322 | &mut self, |
| 323 | channel: ChannelId, |
| 324 | _: &str, |
| 325 | _: u32, |
| 326 | _: u32, |
| 327 | _: u32, |
| 328 | _: u32, |
| 329 | _: &[(russh::Pty, u32)], |
| 330 | session: &mut Session, |
| 331 | ) -> Result<(), Self::Error> { |
| 332 | deny(session, channel, "No PTY.") |
| 333 | } |
| 334 | |
| 335 | async fn subsystem_request( |
| 336 | &mut self, |
| 337 | channel: ChannelId, |
| 338 | name: &str, |
| 339 | session: &mut Session, |
| 340 | ) -> Result<(), Self::Error> { |
| 341 | // Notably SFTP. |
| 342 | tracing::info!("refused subsystem request: {name}"); |
| 343 | deny(session, channel, "No subsystems.") |
| 344 | } |
| 345 | |
| 346 | async fn tcpip_forward( |
| 347 | &mut self, |
| 348 | _address: &str, |
| 349 | _port: &mut u32, |
| 350 | _session: &mut Session, |
| 351 | ) -> Result<bool, Self::Error> { |
| 352 | // Refusing this is what stops the SSH server being an open proxy into |
| 353 | // the internal network. |
| 354 | Ok(false) |
| 355 | } |
| 356 | |
| 357 | } |
| 358 | |
| 359 | /// The only authentication method Dogfood offers. |
| 360 | /// |
| 361 | /// russh 0.51 models this as a list of `MethodKind`, not a bitflag set. |
| 362 | fn publickey_only() -> MethodSet { |
| 363 | MethodSet::from(&[MethodKind::PublicKey][..]) |
| 364 | } |
| 365 | |
| 366 | /// Write a message to the client's stderr and close the channel with failure. |
| 367 | fn deny(session: &mut Session, channel: ChannelId, message: &str) -> Result<()> { |
| 368 | let text = format!("dogfood: {message}\r\n"); |
| 369 | let _ = session.extended_data(channel, 1, text.into_bytes().into()); |
| 370 | let _ = session.exit_status_request(channel, 1); |
| 371 | let _ = session.close(channel); |
| 372 | Ok(()) |
| 373 | } |
| 374 | |
| 375 | #[tokio::main] |
| 376 | async fn main() -> Result<()> { |
| 377 | let _ = dotenvy::dotenv(); |
| 378 | init_tracing(); |
| 379 | |
| 380 | let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; |
| 381 | let repo_root = std::env::var("REPO_ROOT").unwrap_or_else(|_| "/srv/repos".into()); |
| 382 | let bind = std::env::var("SSH_BIND").unwrap_or_else(|_| "0.0.0.0:2222".into()); |
| 383 | let host_key_path = std::env::var("SSH_HOST_KEY_PATH") |
| 384 | .unwrap_or_else(|_| "/etc/dogfood/ssh_host_ed25519_key".into()); |
| 385 | let hook_binary = |
| 386 | std::env::var("DOGFOOD_HOOK_BINARY").unwrap_or_else(|_| "/usr/local/bin/dogfood-hook".into()); |
| 387 | |
| 388 | let db = df_db::connect(&database_url, 5) |
| 389 | .await |
| 390 | .context("connecting to the database")?; |
| 391 | |
| 392 | let host_key = load_or_create_host_key(&host_key_path)?; |
| 393 | |
| 394 | let config = russh::server::Config { |
| 395 | // A host key change looks like an attack to every client, so it is |
| 396 | // generated once and persisted on a volume. |
| 397 | keys: vec![host_key], |
| 398 | auth_rejection_time: std::time::Duration::from_secs(1), |
| 399 | auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)), |
| 400 | inactivity_timeout: Some(INACTIVITY_TIMEOUT), |
| 401 | // A client that connects and never authenticates is holding a slot for |
| 402 | // nothing. russh applies `Limits::rekey_time_limit` to the whole |
| 403 | // pre-auth phase, so this is where the auth deadline lives. |
| 404 | limits: russh::Limits { |
| 405 | rekey_time_limit: AUTH_TIMEOUT, |
| 406 | ..Default::default() |
| 407 | }, |
| 408 | methods: publickey_only(), |
| 409 | ..Default::default() |
| 410 | }; |
| 411 | |
| 412 | let mut server = Server { |
| 413 | limits: limits::ConnectionLimits::new(), |
| 414 | db, |
| 415 | repo_root, |
| 416 | database_url, |
| 417 | hook_binary, |
| 418 | }; |
| 419 | |
| 420 | tracing::info!("dogfood-ssh listening on {bind}"); |
| 421 | server |
| 422 | .run_on_address(Arc::new(config), &bind) |
| 423 | .await |
| 424 | .context("ssh server error")?; |
| 425 | |
| 426 | Ok(()) |
| 427 | } |
| 428 | |
| 429 | /// Load the host key, generating one on first boot. |
| 430 | fn load_or_create_host_key(path: &str) -> Result<russh::keys::PrivateKey> { |
| 431 | let p = std::path::Path::new(path); |
| 432 | |
| 433 | if p.exists() { |
| 434 | let data = std::fs::read_to_string(p) |
| 435 | .with_context(|| format!("reading host key {path}"))?; |
| 436 | let key = russh::keys::PrivateKey::from_openssh(&data) |
| 437 | .with_context(|| format!("parsing host key {path}"))?; |
| 438 | tracing::info!("loaded ssh host key from {path}"); |
| 439 | return Ok(key); |
| 440 | } |
| 441 | |
| 442 | tracing::warn!("no ssh host key at {path}; generating one"); |
| 443 | let key = russh::keys::PrivateKey::random( |
| 444 | &mut rand::thread_rng(), |
| 445 | russh::keys::Algorithm::Ed25519, |
| 446 | ) |
| 447 | .context("generating host key")?; |
| 448 | |
| 449 | if let Some(parent) = p.parent() { |
| 450 | std::fs::create_dir_all(parent).ok(); |
| 451 | } |
| 452 | let pem = key |
| 453 | .to_openssh(russh::keys::ssh_key::LineEnding::LF) |
| 454 | .context("serialising host key")?; |
| 455 | std::fs::write(p, pem.as_bytes()).with_context(|| format!("writing host key {path}"))?; |
| 456 | |
| 457 | #[cfg(unix)] |
| 458 | { |
| 459 | use std::os::unix::fs::PermissionsExt; |
| 460 | let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o600)); |
| 461 | } |
| 462 | |
| 463 | tracing::info!("generated a new ssh host key at {path}"); |
| 464 | Ok(key) |
| 465 | } |
| 466 | |
| 467 | fn init_tracing() { |
| 468 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; |
| 469 | let filter = |
| 470 | EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,df_ssh=debug")); |
| 471 | let json = !std::io::IsTerminal::is_terminal(&std::io::stdout()); |
| 472 | let registry = tracing_subscriber::registry().with(filter); |
| 473 | if json { |
| 474 | registry.with(tracing_subscriber::fmt::layer().json()).init(); |
| 475 | } else { |
| 476 | registry.with(tracing_subscriber::fmt::layer()).init(); |
| 477 | } |
| 478 | } |
478 lines · Rust