Skip to main content

max / makenotwork

20.5 KB · 534 lines History Blame Raw
1 //! SSH-based git operations and management commands.
2 //!
3 //! Called from the `mnw-admin git-auth` command, which is invoked by sshd's
4 //! `command=` prefix in authorized_keys. Handles git push/pull access control
5 //! and interactive management commands (repo list, key management, etc.).
6
7 use sqlx::PgPool;
8 use std::fmt::Write as _;
9
10 use crate::db::{self, UserId, Username};
11 use crate::validation::validate_git_repo_name;
12
13 // ── Constants ──
14
15 pub const MNW_ADMIN_PATH: &str = "/opt/mnw/current/mnw-admin";
16
17 /// The git user's home directory (`GIT_HOME`, default `/opt/git`). Configurable
18 /// because the home was relocated to `/var/lib/mnw/git` in the 2026-06 soak
19 /// cleanup: `/opt/git` was deleted, and hardcoding it forced a load-bearing
20 /// symlink so `rebuild-keys` would keep writing to a live path. Matches the
21 /// `GIT_HOME` used by `deploy/setup-git-ssh.sh`.
22 fn git_home() -> std::path::PathBuf {
23 std::path::PathBuf::from(std::env::var("GIT_HOME").unwrap_or_else(|_| "/opt/git".to_string()))
24 }
25
26 /// Path to the git user's `authorized_keys`, managed by `mnw-admin rebuild-keys`
27 /// and consulted by sshd's `command=` routing. Derived from [`git_home`] so a
28 /// relocated home needs only `GIT_HOME` set, not a symlink.
29 pub fn authorized_keys_path() -> std::path::PathBuf {
30 git_home().join(".ssh").join("authorized_keys")
31 }
32
33 // ── Git operations ──
34
35 #[derive(Debug)]
36 enum GitOperation {
37 UploadPack,
38 ReceivePack,
39 Archive,
40 }
41
42 impl GitOperation {
43 fn command(&self) -> &'static str {
44 match self {
45 Self::UploadPack => "git-upload-pack",
46 Self::ReceivePack => "git-receive-pack",
47 Self::Archive => "git-upload-archive",
48 }
49 }
50 }
51
52 /// Authenticate and dispatch an SSH git-auth invocation.
53 ///
54 /// Reads `SSH_ORIGINAL_COMMAND` to determine whether this is a git operation
55 /// (git-upload-pack, git-receive-pack) or a management command (repo list, etc.).
56 pub async fn dispatch(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> {
57 let original_cmd = std::env::var("SSH_ORIGINAL_COMMAND")
58 .map_err(|_| anyhow::anyhow!("SSH_ORIGINAL_COMMAND not set"))?;
59
60 // Look up the SSH key → user
61 let key_id: db::SshKeyId = key_id_str
62 .parse()
63 .map_err(|_| anyhow::anyhow!("invalid key ID"))?;
64
65 let (_, user_id, ssh_username) = db::ssh_keys::get_key_with_user(pool, key_id)
66 .await?
67 .ok_or_else(|| anyhow::anyhow!("SSH key not found"))?;
68
69 // Verify user is not suspended or deactivated
70 let user = db::users::get_user_by_id(pool, user_id)
71 .await?
72 .ok_or_else(|| anyhow::anyhow!("user not found for SSH key"))?;
73 if user.is_suspended() {
74 anyhow::bail!("account is suspended");
75 }
76 if user.is_deactivated() {
77 anyhow::bail!("account is deactivated");
78 }
79
80 // Only git transport is served here. The management verbs (repo list,
81 // key rm, ...) moved to mnw-cli on 2026-07-31 and are reached through
82 // cli.makenot.work, the SSH front door that is actually live. They used to
83 // be implemented in this file and dispatched below, where nothing could
84 // reach them: the git transport had already migrated to mnw-cli's russh
85 // server and these did not follow. So `repo set-visibility` existed, worked
86 // and was unreachable, which is how a repo came to be published with no
87 // supported way to unpublish it.
88 let _ = &ssh_username;
89 if original_cmd.starts_with("git-") {
90 exec_git_operation(pool, user_id, &original_cmd).await
91 } else {
92 anyhow::bail!(
93 "management commands have moved; run `ssh cli.makenot.work repo list` (or `help`)"
94 )
95 }
96 }
97
98 async fn exec_git_operation(
99 pool: &PgPool,
100 user_id: UserId,
101 original_cmd: &str,
102 ) -> anyhow::Result<()> {
103 let (operation, repo_path) = parse_ssh_command(original_cmd)?;
104 let (owner, repo_name) = parse_repo_path(&repo_path)?;
105
106 // Validate the SSH-supplied owner and repo name before any DB lookup or
107 // shell reconstruction. `parse_repo_path` is a path-shape check, not a
108 // syntax check, without this, a malformed name could reach the DB layer
109 // or end up embedded in the `git-shell -c` argument below.
110 let owner_username =
111 Username::new(owner).map_err(|_| anyhow::anyhow!("repository not found"))?;
112 validate_git_repo_name(repo_name).map_err(|_| anyhow::anyhow!("repository not found"))?;
113
114 let owner_user = db::users::get_user_by_username(pool, &owner_username)
115 .await?
116 .ok_or_else(|| anyhow::anyhow!("repository not found"))?;
117
118 let repo =
119 match db::git_repos::get_repo_by_user_and_name(pool, owner_user.id, repo_name).await? {
120 Some(repo) => repo,
121 None => {
122 // Auto-create on push if the authenticated user owns the namespace.
123 if !matches!(operation, GitOperation::ReceivePack) || user_id != owner_user.id {
124 anyhow::bail!("repository not found");
125 }
126
127 tracing::info!(owner = %owner, repo = %repo_name, "registering new repository");
128 db::git_repos::create_repo(pool, owner_user.id, repo_name).await?
129 }
130 };
131
132 // Permission check, owner always has full access, collaborators checked via DB
133 let is_owner = user_id == owner_user.id;
134 match operation {
135 GitOperation::ReceivePack => {
136 if !is_owner {
137 let can_push = db::repo_collaborators::can_user_push(pool, repo.id, user_id)
138 .await
139 .unwrap_or_else(|e| {
140 // Fail closed, but don't do it silently: a DB error here
141 // denies a legitimate push with no trace (audit Run 17
142 // Observability).
143 tracing::warn!(repo_id = %repo.id, user_id = %user_id, error = ?e, "can_user_push check failed; denying push");
144 false
145 });
146 if !can_push {
147 anyhow::bail!(
148 "permission denied: you do not have push access to {owner}/{repo_name}"
149 );
150 }
151 }
152
153 // Per-account disk quota. The push consumes the *namespace owner's*
154 // storage, so the quota is checked against `owner_username`. Enforced
155 // via the shared `git::enforce_disk_quota` so the SSH and smart-HTTP
156 // push paths share one guard (fuzz 2026-07-06 M1).
157 let owner_dir = git_repos_root().join(owner_username.as_ref());
158 crate::git::enforce_disk_quota(owner_dir).await?;
159
160 ensure_bare_repo_on_disk(&git_repos_root(), owner_username.as_ref(), repo_name)?;
161 }
162 GitOperation::UploadPack | GitOperation::Archive => {
163 if repo.visibility == db::Visibility::Private && !is_owner {
164 let is_collab = db::repo_collaborators::is_collaborator(pool, repo.id, user_id)
165 .await
166 .unwrap_or_else(|e| {
167 // Fail closed (treat as not-a-collaborator) but log: a DB
168 // error here hides a private repo from a legitimate
169 // collaborator with no trace (audit Run 17 Observability).
170 tracing::warn!(repo_id = %repo.id, user_id = %user_id, error = ?e, "is_collaborator check failed; denying read");
171 false
172 });
173 if !is_collab {
174 anyhow::bail!("repository not found");
175 }
176 }
177 }
178 }
179
180 // Authorized, exec git-shell with a sanitized command reconstructed
181 // from validated components (prevents argument injection via the original
182 // command). Use `owner_username` (the `Username`-validated value), not the
183 // raw `owner` &str: `Username::new` preserves the string but constrains the
184 // charset, so the value flowing into the `git-shell -c` argument stays
185 // load-bearing on the validated type even if `parse_repo_path` ever loosens.
186 let sanitized_cmd = format!(
187 "{} '/{}/{}.git'",
188 operation.command(),
189 owner_username.as_ref(),
190 repo_name
191 );
192 run_git_shell(&sanitized_cmd).await
193 }
194
195 /// Create the bare repo a push is about to write into, if it is not there.
196 ///
197 /// Registering the repo in the database and creating it on disk are two steps,
198 /// and until 2026-08-01 this path did only the first. The comment above the
199 /// `create_repo` call said the caller made the directory; no caller did.
200 /// `crate::git::init_bare_repo` calls itself the single production path for
201 /// creating a bare repo, and the only thing reaching it was the web API.
202 ///
203 /// What that cost: a first push to a name nobody had pushed before registered
204 /// the repo, listed it on `/git` as public, and then handed `git-shell` a path
205 /// that did not exist. `git-receive-pack` neither served the push nor failed,
206 /// so the client sat there until [`GIT_SSH_OP_TIMEOUT_SECS`] killed it, fifteen
207 /// minutes later. Every retry did the same, because the row existed by then and
208 /// the branch that would have created anything was no longer taken. The repo
209 /// stayed listed, empty, and unpushable, with the web UI the only way to
210 /// remove it.
211 ///
212 /// Idempotent by the `exists` check, which also repairs the repos already in
213 /// that state: the row is there, the directory is not, and the next push makes
214 /// it. `mnw-cli`'s russh transport does the same thing at the same point for
215 /// the same reason (`src/ssh/handler.rs`), and this is the door that is
216 /// actually live for `ssh.makenot.work`.
217 ///
218 /// [`GIT_SSH_OP_TIMEOUT_SECS`]: crate::constants::GIT_SSH_OP_TIMEOUT_SECS
219 /// Takes the root rather than reading `GIT_REPOS_PATH` itself, so the test
220 /// below can point it at a temp directory without mutating process env.
221 fn ensure_bare_repo_on_disk(
222 root: &std::path::Path,
223 owner: &str,
224 repo_name: &str,
225 ) -> anyhow::Result<()> {
226 let owner_dir = root.join(owner);
227 let repo_dir = owner_dir.join(format!("{repo_name}.git"));
228 if repo_dir.exists() {
229 return Ok(());
230 }
231
232 tracing::info!(path = %repo_dir.display(), "creating bare repository on disk");
233 std::fs::create_dir_all(&owner_dir)?;
234 crate::git::init_bare_repo(&repo_dir)?;
235
236 // Build triggers are optional, and a repo that pushes without firing one is
237 // a working repo. Warn rather than fail: refusing the push over a missing
238 // hook would trade an empty repo for an unpushable one.
239 let token = std::env::var("BUILD_TRIGGER_TOKEN").ok();
240 if let Err(error) = install_hooks_for_repo(&repo_dir, token.as_deref(), owner, repo_name) {
241 tracing::warn!(error = ?error, path = %repo_dir.display(), "hooks not installed");
242 }
243
244 Ok(())
245 }
246
247 /// The configured git repository root (`GIT_REPOS_PATH`, default `/opt/git`).
248 fn git_repos_root() -> std::path::PathBuf {
249 std::path::PathBuf::from(
250 std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string()),
251 )
252 }
253
254 fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> {
255 let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
256 if parts.len() != 2 {
257 anyhow::bail!("invalid git command");
258 }
259
260 let operation = match parts[0] {
261 "git-upload-pack" => GitOperation::UploadPack,
262 "git-receive-pack" => GitOperation::ReceivePack,
263 "git-upload-archive" => GitOperation::Archive,
264 _ => anyhow::bail!("unsupported git command: {}", parts[0]),
265 };
266
267 let repo_path = parts[1].trim_matches('\'').trim_matches('"');
268 Ok((operation, repo_path.to_string()))
269 }
270
271 fn parse_repo_path(path: &str) -> anyhow::Result<(&str, &str)> {
272 let path = path.trim_start_matches('/');
273 let (owner, rest) = path
274 .split_once('/')
275 .ok_or_else(|| anyhow::anyhow!("invalid repository path: missing owner or repo"))?;
276
277 if owner.contains("..") || rest.contains("..") {
278 anyhow::bail!("invalid repository path: path traversal not allowed");
279 }
280
281 // Reject lone-dot segments, `parse_repo_path` is the gate before the
282 // `format!("{op} '/{owner}/{repo_name}.git'")` that flows into `git-shell`.
283 // `validate_git_repo_name` below would also catch most of these, but the
284 // belt-and-braces rejection here keeps the dispatch path itself strict.
285 if owner == "." || rest.split('/').any(|seg| seg == "." || seg == "..") {
286 anyhow::bail!("invalid repository path: lone-dot segment not allowed");
287 }
288
289 let repo_name = rest.strip_suffix(".git").unwrap_or(rest);
290
291 if owner.is_empty() || repo_name.is_empty() {
292 anyhow::bail!("invalid repository path: empty owner or repo name");
293 }
294
295 Ok((owner, repo_name))
296 }
297
298 /// Run git-shell as a child with inherited stdio (the ssh channel's fds) under a
299 /// runaway-backstop timeout, then exit this process with the child's status.
300 ///
301 /// Replaces the previous `exec()`-into-git-shell: `exec` left no opportunity to
302 /// bound a stalled transfer, so a client that stopped reading could pin the
303 /// process indefinitely. Spawning lets us `timeout` the wait and kill a stuck
304 /// operation (`GIT_SSH_OP_TIMEOUT_SECS`). The process still terminates here on
305 /// every path, so it behaves like the old `exec` to the git client (its exit
306 /// code propagates); it returns `Err` only if git-shell cannot be spawned.
307 async fn run_git_shell(original_cmd: &str) -> anyhow::Result<()> {
308 use tokio::process::Command;
309
310 let mut child = Command::new("git-shell")
311 .args(["-c", original_cmd])
312 .spawn()
313 .map_err(|e| anyhow::anyhow!("failed to spawn git-shell: {e}"))?;
314
315 let timeout = std::time::Duration::from_secs(crate::constants::GIT_SSH_OP_TIMEOUT_SECS);
316 match tokio::time::timeout(timeout, child.wait()).await {
317 Ok(Ok(status)) => std::process::exit(status.code().unwrap_or(0)),
318 Ok(Err(e)) => anyhow::bail!("git-shell wait failed: {e}"),
319 Err(_elapsed) => {
320 let _ = child.start_kill();
321 let _ = child.wait().await;
322 eprintln!(
323 "git operation timed out after {}s",
324 crate::constants::GIT_SSH_OP_TIMEOUT_SECS
325 );
326 std::process::exit(124); // matches coreutils `timeout` exit code
327 }
328 }
329 }
330
331 /// Install every hook a bare repository needs.
332 ///
333 /// One call rather than one per hook, because the hooks are not independent:
334 /// `post-receive` reindexes a notes push and `update` decides whether that push
335 /// is allowed at all, and a repository with only the first enforces no policy
336 /// while looking installed. A caller that has to remember the second is a
337 /// caller that eventually does not.
338 /// `token` is optional because `post-receive` carries a per-repo HMAC and is
339 /// useless without one, while `update` carries nothing and enforces a policy
340 /// that holds whether or not this deployment runs builds. Gating both on the
341 /// token would leave a server with no `BUILD_TRIGGER_TOKEN` accepting pushes to
342 /// namespaces MNW owns.
343 pub fn install_hooks_for_repo(
344 repo_dir: &std::path::Path,
345 token: Option<&str>,
346 owner: &str,
347 repo_name: &str,
348 ) -> anyhow::Result<()> {
349 if let Some(token) = token {
350 install_hook(
351 repo_dir,
352 "post-receive",
353 &crate::build_runner::post_receive_hook(token, owner, repo_name),
354 )?;
355 }
356 install_hook(repo_dir, "update", crate::build_runner::UPDATE_HOOK)?;
357 Ok(())
358 }
359
360 /// Write one executable hook into a bare repository's `hooks/`.
361 fn install_hook(
362 repo_dir: &std::path::Path,
363 hook_name: &str,
364 hook_content: &str,
365 ) -> anyhow::Result<()> {
366 let hooks_dir = repo_dir.join("hooks");
367 std::fs::create_dir_all(&hooks_dir)?;
368 let hook_path = hooks_dir.join(hook_name);
369 std::fs::write(&hook_path, hook_content)?;
370
371 #[cfg(unix)]
372 {
373 use std::os::unix::fs::PermissionsExt;
374 std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
375 }
376
377 Ok(())
378 }
379
380 // ── authorized_keys ──
381 //
382 // All that remains of the management half. mnw-cli authenticates from the
383 // database rather than this file, so it is written for whatever still consults
384 // sshd: a key removed from one door but not the other is a key the user
385 // believes is gone.
386
387 /// Write the authorized_keys file from all DB keys. Optionally set git:git ownership.
388 pub async fn write_authorized_keys(pool: &PgPool, set_ownership: bool) -> anyhow::Result<()> {
389 let keys = db::ssh_keys::get_all_keys_with_username(pool).await?;
390
391 let mut content = String::new();
392 content.push_str("# Managed by mnw-admin rebuild-keys. Do not edit manually.\n");
393
394 for key in &keys {
395 writeln!(
396 content,
397 "command=\"{} git-auth {}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {}",
398 MNW_ADMIN_PATH, key.id, key.public_key,
399 )
400 .unwrap();
401 }
402
403 let keys_path = authorized_keys_path();
404 let tmp_path = keys_path.with_extension("tmp");
405 std::fs::write(&tmp_path, &content)?;
406 std::fs::rename(&tmp_path, &keys_path)?;
407
408 #[cfg(unix)]
409 {
410 use std::os::unix::fs::PermissionsExt;
411 std::fs::set_permissions(&keys_path, std::fs::Permissions::from_mode(0o600))?;
412
413 if set_ownership {
414 let status = std::process::Command::new("chown")
415 .arg("git:git")
416 .arg(&keys_path)
417 .status()?;
418 if !status.success() {
419 anyhow::bail!("chown git:git failed on {}", keys_path.display());
420 }
421 }
422 }
423
424 Ok(())
425 }
426
427 #[cfg(test)]
428 mod tests {
429 use super::*;
430
431 // ── parse_ssh_command ──
432
433 // The push path's half of repo creation. Registering the row was never the
434 // part that broke; this is.
435 #[test]
436 fn a_first_push_gets_a_bare_repo_on_disk() {
437 let root = tempfile::tempdir().unwrap();
438 let repo_dir = root.path().join("max").join("shop.git");
439
440 ensure_bare_repo_on_disk(root.path(), "max", "shop").unwrap();
441 assert!(
442 gix::open(&repo_dir).is_ok(),
443 "a push to a name with no repo has somewhere to write",
444 );
445
446 // Idempotent, because this runs on every push and not only the first.
447 // It is also what repairs a repo registered before the fix: the row is
448 // there, the directory is not, and the branch above makes it.
449 ensure_bare_repo_on_disk(root.path(), "max", "shop").unwrap();
450 assert!(gix::open(&repo_dir).is_ok());
451 }
452
453 #[test]
454 fn parse_upload_pack() {
455 let (op, path) = parse_ssh_command("git-upload-pack '/user/repo.git'").unwrap();
456 assert!(matches!(op, GitOperation::UploadPack));
457 assert_eq!(path, "/user/repo.git");
458 }
459
460 #[test]
461 fn parse_receive_pack() {
462 let (op, path) = parse_ssh_command("git-receive-pack '/user/repo.git'").unwrap();
463 assert!(matches!(op, GitOperation::ReceivePack));
464 assert_eq!(path, "/user/repo.git");
465 }
466
467 #[test]
468 fn parse_upload_archive() {
469 let (op, path) = parse_ssh_command("git-upload-archive '/user/repo.git'").unwrap();
470 assert!(matches!(op, GitOperation::Archive));
471 assert_eq!(path, "/user/repo.git");
472 }
473
474 #[test]
475 fn parse_ssh_command_double_quotes() {
476 let (_, path) = parse_ssh_command(r#"git-upload-pack "/user/repo.git""#).unwrap();
477 assert_eq!(path, "/user/repo.git");
478 }
479
480 #[test]
481 fn parse_ssh_command_unsupported() {
482 assert!(parse_ssh_command("git-foo '/user/repo.git'").is_err());
483 }
484
485 #[test]
486 fn parse_ssh_command_no_space() {
487 assert!(parse_ssh_command("git-upload-pack").is_err());
488 }
489
490 // ── parse_repo_path ──
491
492 #[test]
493 fn parse_valid_repo_path() {
494 let (owner, name) = parse_repo_path("/alice/myrepo.git").unwrap();
495 assert_eq!(owner, "alice");
496 assert_eq!(name, "myrepo");
497 }
498
499 #[test]
500 fn parse_repo_path_no_git_suffix() {
501 let (owner, name) = parse_repo_path("/bob/project").unwrap();
502 assert_eq!(owner, "bob");
503 assert_eq!(name, "project");
504 }
505
506 #[test]
507 fn parse_repo_path_no_leading_slash() {
508 let (owner, name) = parse_repo_path("carol/stuff.git").unwrap();
509 assert_eq!(owner, "carol");
510 assert_eq!(name, "stuff");
511 }
512
513 #[test]
514 fn parse_repo_path_traversal_rejected() {
515 assert!(parse_repo_path("../evil/repo").is_err());
516 assert!(parse_repo_path("user/../repo").is_err());
517 }
518
519 #[test]
520 fn parse_repo_path_missing_repo() {
521 assert!(parse_repo_path("/onlyowner").is_err());
522 }
523
524 #[test]
525 fn parse_repo_path_empty_owner() {
526 assert!(parse_repo_path("//repo").is_err());
527 }
528
529 #[test]
530 fn parse_repo_path_bare_git_suffix_only() {
531 assert!(parse_repo_path("/owner/.git").is_err());
532 }
533 }
534