| 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