Jump to…
snowattribute changes to their author, and index SSH pushesowzkxxuxzulu1mo
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
9use std::sync::Arc;
10
11use anyhow::{Context, Result};
12use russh::server::{Auth, Handler, Msg, Server as _, Session};
13use russh::{Channel, ChannelId, MethodKind, MethodSet};
14use sqlx::PgPool;
15use uuid::Uuid;
16
17mod exec;
18mod limits;
19mod 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.
27const 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.
31const INACTIVITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
32
33struct 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
42impl 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
76struct 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)]
93struct AuthedUser {
94 id: Uuid,
95 handle: String,
96}
97
98impl 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.
362fn 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.
367fn 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]
376async 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.
430fn 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
467fn 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