Jump to…
snowattribute changes to their author, and index SSH pushesowzkxxuxzulu1mo
Matt W1//! `dogfood-ssh` — Git over SSH (spec §6, §9).
Matt W2//!
Matt W3//! Public-key authentication only. The presented key's SHA256 fingerprint is
Matt W4//! looked up in `ssh_keys`, which resolves the user; then only
Matt W5//! `git-upload-pack` and `git-receive-pack` are dispatched.
Matt W6//!
Matt W7//! No password auth, no shell, no forwarding, no PTY, no SFTP.
Matt W8
Matt W9use std::sync::Arc;
Matt W10
Matt W11use anyhow::{Context, Result};
Matt W12use russh::server::{Auth, Handler, Msg, Server as _, Session};
Matt W13use russh::{Channel, ChannelId, MethodKind, MethodSet};
Matt W14use sqlx::PgPool;
Matt W15use uuid::Uuid;
Matt W16
Matt W17mod exec;
Matt W18mod limits;
Matt W19mod repo;
Matt W20
Matt W21
Matt W22/// How long a connection may sit before authenticating.
Matt W23///
Matt W24/// Distinct from [`INACTIVITY_TIMEOUT`], which governs an *established* session:
Matt W25/// a client that connects and then says nothing has done no work we can charge
Matt W26/// it for, and holding the slot open is the whole attack.
Matt W27const AUTH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
Matt W28
Matt W29/// How long an authenticated session may sit idle. Generous, because a large
Matt W30/// `git-upload-pack` can be quiet for a while as the client works.
Matt W31const INACTIVITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
Matt W32
Matt W33struct Server {
Matt W34 db: PgPool,
Matt W35 repo_root: String,
Matt W36 database_url: String,
Matt W37 hook_binary: String,
Matt W38 /// Per-source-IP connection limits (spec §9).
Matt W39 limits: limits::ConnectionLimits,
Matt W40}
Matt W41
Matt W42impl russh::server::Server for Server {
Matt W43 type Handler = Connection;
Matt W44
Matt W45 fn new_client(&mut self, peer: Option<std::net::SocketAddr>) -> Connection {
Matt W46 // Admission happens here, before the key exchange — the earliest point
Matt W47 // at which the peer is known and the last one before we spend anything
Matt W48 // on its behalf. A refused connection is still constructed (russh gives
Matt W49 // no way to decline one at this point), but it carries no guard and is
Matt W50 // rejected at the first authentication attempt.
Matt W51 let guard = self.limits.admit(peer);
Matt W52 if guard.is_none() && peer.is_some() {
Matt W53 tracing::warn!(?peer, "ssh connection refused: source rate limit");
Matt W54 }
Matt W55
Matt W56 Connection {
Matt W57 db: self.db.clone(),
Matt W58 repo_root: self.repo_root.clone(),
Matt W59 database_url: self.database_url.clone(),
Matt W60 hook_binary: self.hook_binary.clone(),
Matt W61 peer,
Matt W62 admitted: guard.is_some() || peer.is_none(),
Matt W63 _guard: guard,
Matt W64 user: None,
Matt W65 git_stdin: None,
Matt W66 }
Matt W67 }
Matt W68
Matt W69 fn handle_session_error(&mut self, error: <Connection as Handler>::Error) {
Matt W70 // Client disconnects are routine; log at debug so they do not drown the
Matt W71 // signal.
Matt W72 tracing::debug!("ssh session ended: {error}");
Matt W73 }
Matt W74}
Matt W75
Matt W76struct Connection {
Matt W77 db: PgPool,
Matt W78 repo_root: String,
Matt W79 database_url: String,
Matt W80 hook_binary: String,
Matt W81 peer: Option<std::net::SocketAddr>,
Matt W82 /// Whether the source's connection limit admitted this connection.
Matt W83 admitted: bool,
Matt W84 /// Holds the source's concurrency slot for the life of the connection.
Matt W85 _guard: Option<limits::ConnectionGuard>,
Matt W86 /// Set once a key has authenticated.
Matt W87 user: Option<AuthedUser>,
Matt W88 /// git's stdin for the running service, fed by `data`.
Matt W89 git_stdin: Option<tokio::process::ChildStdin>,
Matt W90}
Matt W91
Matt W92#[derive(Clone)]
Matt W93struct AuthedUser {
Matt W94 id: Uuid,
Matt W95 handle: String,
Matt W96}
Matt W97
Matt W98impl Handler for Connection {
Matt W99 type Error = anyhow::Error;
Matt W100
Matt W101 /// Public-key authentication.
Matt W102 ///
Matt W103 /// The SSH username is ignored entirely — everyone connects as `git`, and
Matt W104 /// the key decides who they are. That is the standard forge convention and
Matt W105 /// means a user does not have to configure a per-host username.
Matt W106 async fn auth_publickey(
Matt W107 &mut self,
Matt W108 _user: &str,
Matt W109 key: &russh::keys::ssh_key::PublicKey,
Matt W110 ) -> Result<Auth, Self::Error> {
Matt W111 // A connection the limiter refused never gets as far as a database
Matt W112 // lookup — which is the work the limit exists to prevent.
Matt W113 if !self.admitted {
Matt W114 return Ok(Auth::Reject {
Matt W115 proceed_with_methods: None,
Matt W116 partial_success: false,
Matt W117 });
Matt W118 }
Matt W119
Matt W120 let fingerprint = key
Matt W121 .fingerprint(russh::keys::ssh_key::HashAlg::Sha256)
Matt W122 .to_string();
Matt W123
Matt W124 let found: Option<(Uuid, String)> = sqlx::query_as(
Matt W125 "SELECT u.id, u.handle::text
Matt W126 FROM ssh_keys k JOIN users u ON u.id = k.user_id
Matt W127 WHERE k.fingerprint = $1",
Matt W128 )
Matt W129 .bind(&fingerprint)
Matt W130 .fetch_optional(&self.db)
Matt W131 .await
Matt W132 .unwrap_or(None);
Matt W133
Matt W134 match found {
Matt W135 Some((id, handle)) => {
Matt W136 tracing::info!(peer = ?self.peer, %handle, "ssh key accepted");
Matt W137 // Best-effort usage tracking; never fail auth on it.
Matt W138 let _ = sqlx::query("UPDATE ssh_keys SET last_used_at = now() WHERE fingerprint = $1")
Matt W139 .bind(&fingerprint)
Matt W140 .execute(&self.db)
Matt W141 .await;
Matt W142 self.user = Some(AuthedUser { id, handle });
Matt W143 Ok(Auth::Accept)
Matt W144 }
Matt W145 None => {
Matt W146 tracing::info!(peer = ?self.peer, %fingerprint, "ssh key rejected: unknown");
Matt W147 Ok(Auth::Reject {
Matt W148 proceed_with_methods: None,
Matt W149 partial_success: false,
Matt W150 })
Matt W151 }
Matt W152 }
Matt W153 }
Matt W154
Matt W155 /// Password authentication is never permitted (spec §9).
Matt W156 async fn auth_password(&mut self, _user: &str, _password: &str) -> Result<Auth, Self::Error> {
Matt W157 Ok(Auth::Reject {
Matt W158 proceed_with_methods: Some(publickey_only()),
Matt W159 partial_success: false,
Matt W160 })
Matt W161 }
Matt W162
Matt W163 async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> {
Matt W164 Ok(Auth::Reject {
Matt W165 proceed_with_methods: Some(publickey_only()),
Matt W166 partial_success: false,
Matt W167 })
Matt W168 }
Matt W169
Matt W170 async fn channel_open_session(
Matt W171 &mut self,
Matt W172 _channel: Channel<Msg>,
Matt W173 _session: &mut Session,
Matt W174 ) -> Result<bool, Self::Error> {
Matt W175 Ok(true)
Matt W176 }
Matt W177
Matt W178 /// The only thing a session may do.
Matt W179 async fn exec_request(
Matt W180 &mut self,
Matt W181 channel: ChannelId,
Matt W182 data: &[u8],
Matt W183 session: &mut Session,
Matt W184 ) -> Result<(), Self::Error> {
Matt W185 let Some(user) = self.user.clone() else {
Matt W186 // Cannot happen — russh will not deliver exec before auth — but
Matt W187 // default-deny rather than assume.
Matt W188 return deny(session, channel, "Not authenticated.");
Matt W189 };
Matt W190
Matt W191 let command = String::from_utf8_lossy(data).to_string();
Matt W192
Matt W193 let request = match exec::parse(&command) {
Matt W194 Ok(r) => r,
Matt W195 Err(e) => {
Matt W196 tracing::info!(handle = %user.handle, "rejected ssh exec: {e}");
Matt W197 return deny(session, channel, &e.to_string());
Matt W198 }
Matt W199 };
Matt W200
Matt W201 // Authorize before touching the filesystem.
Matt W202 let resolved = match repo::authorize(
Matt W203 &self.db,
Matt W204 &request.owner,
Matt W205 &request.repo,
Matt W206 user.id,
Matt W207 request.service.is_write(),
Matt W208 )
Matt W209 .await
Matt W210 {
Matt W211 Ok(Some(r)) => r,
Matt W212 Ok(None) => {
Matt W213 // Same message whether the repo is missing or merely invisible
Matt W214 // (spec §9).
Matt W215 return deny(
Matt W216 session,
Matt W217 channel,
Matt W218 &format!(
Matt W219 "Repository '{}/{}' not found.",
Matt W220 request.owner, request.repo
Matt W221 ),
Matt W222 );
Matt W223 }
Matt W224 Err(e) => {
Matt W225 tracing::error!("authorizing ssh request failed: {e:#}");
Matt W226 return deny(session, channel, "Internal error.");
Matt W227 }
Matt W228 };
Matt W229
Matt W230 if request.service.is_write() && !resolved.can_push {
Matt W231 return deny(
Matt W232 session,
Matt W233 channel,
Matt W234 if resolved.archived {
Matt W235 "This repository is archived and does not accept pushes."
Matt W236 } else {
Matt W237 "You do not have push access to this repository."
Matt W238 },
Matt W239 );
Matt W240 }
Matt W241
Matt W242 let dir = repo::repo_path(&self.repo_root, resolved.repo_id);
Matt W243 if !dir.is_dir() {
Matt W244 tracing::error!(?dir, "repository directory missing");
Matt W245 return deny(session, channel, "Repository storage is unavailable.");
Matt W246 }
Matt W247
Matt W248 tracing::info!(
Matt W249 handle = %user.handle,
Matt W250 service = request.service.git_subcommand(),
Matt W251 repo = %resolved.repo_id,
Matt W252 "dispatching git"
Matt W253 );
Matt W254
Matt W255 // git's stdout/stderr stream back from inside `run_git`; its stdin is
Matt W256 // returned here so `data` can feed it the client's pack.
Matt W257 let stdin = repo::run_git(
Matt W258 session,
Matt W259 channel,
Matt W260 request.service,
Matt W261 &dir,
Matt W262 &self.db,
Matt W263 &self.database_url,
Matt W264 &self.hook_binary,
Matt W265 resolved.repo_id,
Matt W266 user.id,
Matt W267 )
Matt W268 .await?;
Matt W269
Matt W270 self.git_stdin = Some(stdin);
Matt W271 Ok(())
Matt W272 }
Matt W273
Matt W274 /// Client -> git. Every frame the client sends is the other half of the
Matt W275 /// pack protocol conversation.
Matt W276 async fn data(
Matt W277 &mut self,
Matt W278 _channel: ChannelId,
Matt W279 data: &[u8],
Matt W280 _session: &mut Session,
Matt W281 ) -> Result<(), Self::Error> {
Matt W282 if let Some(stdin) = self.git_stdin.as_mut() {
Matt W283 use tokio::io::AsyncWriteExt;
Matt W284 if let Err(e) = stdin.write_all(data).await {
Matt W285 // The child exited early — a rejected push, for instance.
Matt W286 tracing::debug!("writing to git stdin failed: {e}");
Matt W287 self.git_stdin = None;
Matt W288 }
Matt W289 }
Matt W290 Ok(())
Matt W291 }
Matt W292
Matt W293 /// The client has finished sending. Closing stdin is what lets
Matt W294 /// receive-pack stop waiting and start processing.
Matt W295 async fn channel_eof(
Matt W296 &mut self,
Matt W297 _channel: ChannelId,
Matt W298 _session: &mut Session,
Matt W299 ) -> Result<(), Self::Error> {
Matt W300 if let Some(mut stdin) = self.git_stdin.take() {
Matt W301 use tokio::io::AsyncWriteExt;
Matt W302 let _ = stdin.shutdown().await;
Matt W303 }
Matt W304 Ok(())
Matt W305 }
Matt W306
Matt W307 // ── everything else is refused (spec §9) ─────────────────────────────────
Matt W308
Matt W309 async fn shell_request(
Matt W310 &mut self,
Matt W311 channel: ChannelId,
Matt W312 session: &mut Session,
Matt W313 ) -> Result<(), Self::Error> {
Matt W314 deny(
Matt W315 session,
Matt W316 channel,
Matt W317 "Dogfood does not provide shell access. Use git or jj.",
Matt W318 )
Matt W319 }
Matt W320
Matt W321 async fn pty_request(
Matt W322 &mut self,
Matt W323 channel: ChannelId,
Matt W324 _: &str,
Matt W325 _: u32,
Matt W326 _: u32,
Matt W327 _: u32,
Matt W328 _: u32,
Matt W329 _: &[(russh::Pty, u32)],
Matt W330 session: &mut Session,
Matt W331 ) -> Result<(), Self::Error> {
Matt W332 deny(session, channel, "No PTY.")
Matt W333 }
Matt W334
Matt W335 async fn subsystem_request(
Matt W336 &mut self,
Matt W337 channel: ChannelId,
Matt W338 name: &str,
Matt W339 session: &mut Session,
Matt W340 ) -> Result<(), Self::Error> {
Matt W341 // Notably SFTP.
Matt W342 tracing::info!("refused subsystem request: {name}");
Matt W343 deny(session, channel, "No subsystems.")
Matt W344 }
Matt W345
Matt W346 async fn tcpip_forward(
Matt W347 &mut self,
Matt W348 _address: &str,
Matt W349 _port: &mut u32,
Matt W350 _session: &mut Session,
Matt W351 ) -> Result<bool, Self::Error> {
Matt W352 // Refusing this is what stops the SSH server being an open proxy into
Matt W353 // the internal network.
Matt W354 Ok(false)
Matt W355 }
Matt W356
Matt W357}
Matt W358
Matt W359/// The only authentication method Dogfood offers.
Matt W360///
Matt W361/// russh 0.51 models this as a list of `MethodKind`, not a bitflag set.
Matt W362fn publickey_only() -> MethodSet {
Matt W363 MethodSet::from(&[MethodKind::PublicKey][..])
Matt W364}
Matt W365
Matt W366/// Write a message to the client's stderr and close the channel with failure.
Matt W367fn deny(session: &mut Session, channel: ChannelId, message: &str) -> Result<()> {
Matt W368 let text = format!("dogfood: {message}\r\n");
Matt W369 let _ = session.extended_data(channel, 1, text.into_bytes().into());
Matt W370 let _ = session.exit_status_request(channel, 1);
Matt W371 let _ = session.close(channel);
Matt W372 Ok(())
Matt W373}
Matt W374
Matt W375#[tokio::main]
Matt W376async fn main() -> Result<()> {
Matt W377 let _ = dotenvy::dotenv();
Matt W378 init_tracing();
Matt W379
Matt W380 let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
Matt W381 let repo_root = std::env::var("REPO_ROOT").unwrap_or_else(|_| "/srv/repos".into());
Matt W382 let bind = std::env::var("SSH_BIND").unwrap_or_else(|_| "0.0.0.0:2222".into());
Matt W383 let host_key_path = std::env::var("SSH_HOST_KEY_PATH")
Matt W384 .unwrap_or_else(|_| "/etc/dogfood/ssh_host_ed25519_key".into());
Matt W385 let hook_binary =
Matt W386 std::env::var("DOGFOOD_HOOK_BINARY").unwrap_or_else(|_| "/usr/local/bin/dogfood-hook".into());
Matt W387
Matt W388 let db = df_db::connect(&database_url, 5)
Matt W389 .await
Matt W390 .context("connecting to the database")?;
Matt W391
Matt W392 let host_key = load_or_create_host_key(&host_key_path)?;
Matt W393
Matt W394 let config = russh::server::Config {
Matt W395 // A host key change looks like an attack to every client, so it is
Matt W396 // generated once and persisted on a volume.
Matt W397 keys: vec![host_key],
Matt W398 auth_rejection_time: std::time::Duration::from_secs(1),
Matt W399 auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)),
Matt W400 inactivity_timeout: Some(INACTIVITY_TIMEOUT),
Matt W401 // A client that connects and never authenticates is holding a slot for
Matt W402 // nothing. russh applies `Limits::rekey_time_limit` to the whole
Matt W403 // pre-auth phase, so this is where the auth deadline lives.
Matt W404 limits: russh::Limits {
Matt W405 rekey_time_limit: AUTH_TIMEOUT,
Matt W406 ..Default::default()
Matt W407 },
Matt W408 methods: publickey_only(),
Matt W409 ..Default::default()
Matt W410 };
Matt W411
Matt W412 let mut server = Server {
Matt W413 limits: limits::ConnectionLimits::new(),
Matt W414 db,
Matt W415 repo_root,
Matt W416 database_url,
Matt W417 hook_binary,
Matt W418 };
Matt W419
Matt W420 tracing::info!("dogfood-ssh listening on {bind}");
Matt W421 server
Matt W422 .run_on_address(Arc::new(config), &bind)
Matt W423 .await
Matt W424 .context("ssh server error")?;
Matt W425
Matt W426 Ok(())
Matt W427}
Matt W428
Matt W429/// Load the host key, generating one on first boot.
Matt W430fn load_or_create_host_key(path: &str) -> Result<russh::keys::PrivateKey> {
Matt W431 let p = std::path::Path::new(path);
Matt W432
Matt W433 if p.exists() {
Matt W434 let data = std::fs::read_to_string(p)
Matt W435 .with_context(|| format!("reading host key {path}"))?;
Matt W436 let key = russh::keys::PrivateKey::from_openssh(&data)
Matt W437 .with_context(|| format!("parsing host key {path}"))?;
Matt W438 tracing::info!("loaded ssh host key from {path}");
Matt W439 return Ok(key);
Matt W440 }
Matt W441
Matt W442 tracing::warn!("no ssh host key at {path}; generating one");
Matt W443 let key = russh::keys::PrivateKey::random(
Matt W444 &mut rand::thread_rng(),
Matt W445 russh::keys::Algorithm::Ed25519,
Matt W446 )
Matt W447 .context("generating host key")?;
Matt W448
Matt W449 if let Some(parent) = p.parent() {
Matt W450 std::fs::create_dir_all(parent).ok();
Matt W451 }
Matt W452 let pem = key
Matt W453 .to_openssh(russh::keys::ssh_key::LineEnding::LF)
Matt W454 .context("serialising host key")?;
Matt W455 std::fs::write(p, pem.as_bytes()).with_context(|| format!("writing host key {path}"))?;
Matt W456
Matt W457 #[cfg(unix)]
Matt W458 {
Matt W459 use std::os::unix::fs::PermissionsExt;
Matt W460 let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o600));
Matt W461 }
Matt W462
Matt W463 tracing::info!("generated a new ssh host key at {path}");
Matt W464 Ok(key)
Matt W465}
Matt W466
Matt W467fn init_tracing() {
Matt W468 use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
Matt W469 let filter =
Matt W470 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,df_ssh=debug"));
Matt W471 let json = !std::io::IsTerminal::is_terminal(&std::io::stdout());
Matt W472 let registry = tracing_subscriber::registry().with(filter);
Matt W473 if json {
Matt W474 registry.with(tracing_subscriber::fmt::layer().json()).init();
Matt W475 } else {
Matt W476 registry.with(tracing_subscriber::fmt::layer()).init();
Matt W477 }
Matt W478}

478 lines · Rust