Skip to main content

max / makenotwork

18.1 KB · 434 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 git_command::{Operation, Request};
8 use sqlx::PgPool;
9 use std::fmt::Write as _;
10
11 use crate::db::{self, UserId, Username};
12 use crate::validation::validate_git_repo_name;
13
14 // ── Constants ──
15
16 pub const MNW_ADMIN_PATH: &str = "/opt/mnw/current/mnw-admin";
17
18 /// The git user's home directory (`GIT_HOME`, default `/opt/git`). Configurable
19 /// because the home was relocated to `/var/lib/mnw/git` in the 2026-06 soak
20 /// cleanup: `/opt/git` was deleted, and hardcoding it forced a load-bearing
21 /// symlink so `rebuild-keys` would keep writing to a live path. Matches the
22 /// `GIT_HOME` used by `deploy/setup-git-ssh.sh`.
23 fn git_home() -> std::path::PathBuf {
24 std::path::PathBuf::from(std::env::var("GIT_HOME").unwrap_or_else(|_| "/opt/git".to_string()))
25 }
26
27 /// Path to the git user's `authorized_keys`, managed by `mnw-admin rebuild-keys`
28 /// and consulted by sshd's `command=` routing. Derived from [`git_home`] so a
29 /// relocated home needs only `GIT_HOME` set, not a symlink.
30 pub fn authorized_keys_path() -> std::path::PathBuf {
31 git_home().join(".ssh").join("authorized_keys")
32 }
33
34 // ── Git operations ──
35 //
36 // The command grammar itself lives in `git-command`, shared with mnw-cli's
37 // russh door. It used to be parsed here and again there, and the two parsers
38 // disagreed on 51 of 5,424 measured command lines. See that crate's module docs
39 // for the four divergences and how each was settled.
40
41 /// Authenticate and dispatch an SSH git-auth invocation.
42 ///
43 /// Reads `SSH_ORIGINAL_COMMAND` to determine whether this is a git operation
44 /// (git-upload-pack, git-receive-pack) or a management command (repo list, etc.).
45 pub async fn dispatch(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> {
46 let original_cmd = std::env::var("SSH_ORIGINAL_COMMAND")
47 .map_err(|_| anyhow::anyhow!("SSH_ORIGINAL_COMMAND not set"))?;
48
49 // Look up the SSH key → user
50 let key_id: db::SshKeyId = key_id_str
51 .parse()
52 .map_err(|_| anyhow::anyhow!("invalid key ID"))?;
53
54 let (_, user_id, ssh_username) = db::ssh_keys::get_key_with_user(pool, key_id)
55 .await?
56 .ok_or_else(|| anyhow::anyhow!("SSH key not found"))?;
57
58 // Verify user is not suspended or deactivated
59 let user = db::users::get_user_by_id(pool, user_id)
60 .await?
61 .ok_or_else(|| anyhow::anyhow!("user not found for SSH key"))?;
62 if user.is_suspended() {
63 anyhow::bail!("account is suspended");
64 }
65 if user.is_deactivated() {
66 anyhow::bail!("account is deactivated");
67 }
68
69 // Only git transport is served here. The management verbs (repo list,
70 // key rm, ...) moved to mnw-cli on 2026-07-31 and are reached through
71 // cli.makenot.work, the SSH front door that is actually live. They used to
72 // be implemented in this file and dispatched below, where nothing could
73 // reach them: the git transport had already migrated to mnw-cli's russh
74 // server and these did not follow. So `repo set-visibility` existed, worked
75 // and was unreachable, which is how a repo came to be published with no
76 // supported way to unpublish it.
77 let _ = &ssh_username;
78 if original_cmd.starts_with("git-") {
79 exec_git_operation(pool, user_id, &original_cmd).await
80 } else {
81 anyhow::bail!(
82 "management commands have moved; run `ssh cli.makenot.work repo list` (or `help`)"
83 )
84 }
85 }
86
87 async fn exec_git_operation(
88 pool: &PgPool,
89 user_id: UserId,
90 original_cmd: &str,
91 ) -> anyhow::Result<()> {
92 // `git_command::parse` guarantees path safety: both segments are single,
93 // non-empty, non-traversing components, so nothing below can leave the git
94 // root. What it deliberately does not decide is identity policy, which is
95 // this deployment's and stays here.
96 let request: Request<'_> =
97 git_command::parse(original_cmd).map_err(|e| anyhow::anyhow!("{e}"))?;
98 let (operation, owner, repo_name) = (request.operation, request.owner, request.repo);
99
100 // `Username::new` is the identity rule (3-50 chars, alphanumeric and
101 // underscore) and is stricter than the path-safety floor the parser
102 // enforces. `validate_git_repo_name` is the product's repo-name policy, the
103 // same one the web API applies when a repo is created there; it currently
104 // matches `git_command::valid_segment` exactly, and it is kept because the
105 // two answer different questions and are free to diverge.
106 let owner_username =
107 Username::new(owner).map_err(|_| anyhow::anyhow!("repository not found"))?;
108 validate_git_repo_name(repo_name).map_err(|_| anyhow::anyhow!("repository not found"))?;
109
110 let owner_user = db::users::get_user_by_username(pool, &owner_username)
111 .await?
112 .ok_or_else(|| anyhow::anyhow!("repository not found"))?;
113
114 let repo =
115 match db::git_repos::get_repo_by_user_and_name(pool, owner_user.id, repo_name).await? {
116 Some(repo) => repo,
117 None => {
118 // Auto-create on push if the authenticated user owns the namespace.
119 if operation != Operation::ReceivePack || user_id != owner_user.id {
120 anyhow::bail!("repository not found");
121 }
122
123 tracing::info!(owner = %owner, repo = %repo_name, "registering new repository");
124 db::git_repos::create_repo(pool, owner_user.id, repo_name).await?
125 }
126 };
127
128 // Permission check, owner always has full access, collaborators checked via DB
129 let is_owner = user_id == owner_user.id;
130 match operation {
131 Operation::ReceivePack => {
132 if !is_owner {
133 let can_push = db::repo_collaborators::can_user_push(pool, repo.id, user_id)
134 .await
135 .unwrap_or_else(|e| {
136 // Fail closed, but don't do it silently: a DB error here
137 // denies a legitimate push with no trace (audit Run 17
138 // Observability).
139 tracing::warn!(repo_id = %repo.id, user_id = %user_id, error = ?e, "can_user_push check failed; denying push");
140 false
141 });
142 if !can_push {
143 anyhow::bail!(
144 "permission denied: you do not have push access to {owner}/{repo_name}"
145 );
146 }
147 }
148
149 // Per-account disk quota. The push consumes the *namespace owner's*
150 // storage, so the quota is checked against `owner_username`. Enforced
151 // via the shared `git::enforce_disk_quota` so the SSH and smart-HTTP
152 // push paths share one guard (fuzz 2026-07-06 M1).
153 let owner_dir = git_repos_root().join(owner_username.as_ref());
154 crate::git::enforce_disk_quota(owner_dir).await?;
155
156 ensure_bare_repo_on_disk(&git_repos_root(), owner_username.as_ref(), repo_name)?;
157 }
158 Operation::UploadPack | Operation::UploadArchive => {
159 if repo.visibility == db::Visibility::Private && !is_owner {
160 let is_collab = db::repo_collaborators::is_collaborator(pool, repo.id, user_id)
161 .await
162 .unwrap_or_else(|e| {
163 // Fail closed (treat as not-a-collaborator) but log: a DB
164 // error here hides a private repo from a legitimate
165 // collaborator with no trace (audit Run 17 Observability).
166 tracing::warn!(repo_id = %repo.id, user_id = %user_id, error = ?e, "is_collaborator check failed; denying read");
167 false
168 });
169 if !is_collab {
170 anyhow::bail!("repository not found");
171 }
172 }
173 }
174 }
175
176 // Authorized. Exec git-shell with a command rebuilt from validated
177 // components rather than with the line the client sent, which is what keeps
178 // argument injection out. The rebuild lives in `git_command` so there is one
179 // format string on this path and mnw-cli's, and the segments going into it
180 // cannot carry a quote or a separator: `valid_segment` is a whitelist and
181 // ran before this request existed.
182 run_git_shell(&request.shell_command()).await
183 }
184
185 /// Create the bare repo a push is about to write into, if it is not there.
186 ///
187 /// Registering the repo in the database and creating it on disk are two steps,
188 /// and until 2026-08-01 this path did only the first. The comment above the
189 /// `create_repo` call said the caller made the directory; no caller did.
190 /// `crate::git::init_bare_repo` calls itself the single production path for
191 /// creating a bare repo, and the only thing reaching it was the web API.
192 ///
193 /// What that cost: a first push to a name nobody had pushed before registered
194 /// the repo, listed it on `/git` as public, and then handed `git-shell` a path
195 /// that did not exist. `git-receive-pack` neither served the push nor failed,
196 /// so the client sat there until [`GIT_SSH_OP_TIMEOUT_SECS`] killed it, fifteen
197 /// minutes later. Every retry did the same, because the row existed by then and
198 /// the branch that would have created anything was no longer taken. The repo
199 /// stayed listed, empty, and unpushable, with the web UI the only way to
200 /// remove it.
201 ///
202 /// Idempotent by the `exists` check, which also repairs the repos already in
203 /// that state: the row is there, the directory is not, and the next push makes
204 /// it. `mnw-cli`'s russh transport does the same thing at the same point for
205 /// the same reason (`src/ssh/handler.rs`), and this is the door that is
206 /// actually live for `ssh.makenot.work`.
207 ///
208 /// [`GIT_SSH_OP_TIMEOUT_SECS`]: crate::constants::GIT_SSH_OP_TIMEOUT_SECS
209 /// Takes the root rather than reading `GIT_REPOS_PATH` itself, so the test
210 /// below can point it at a temp directory without mutating process env.
211 fn ensure_bare_repo_on_disk(
212 root: &std::path::Path,
213 owner: &str,
214 repo_name: &str,
215 ) -> anyhow::Result<()> {
216 let owner_dir = root.join(owner);
217 let repo_dir = owner_dir.join(format!("{repo_name}.git"));
218 if repo_dir.exists() {
219 return Ok(());
220 }
221
222 tracing::info!(path = %repo_dir.display(), "creating bare repository on disk");
223 std::fs::create_dir_all(&owner_dir)?;
224 crate::git::init_bare_repo(&repo_dir)?;
225
226 // Build triggers are optional, and a repo that pushes without firing one is
227 // a working repo. Warn rather than fail: refusing the push over a missing
228 // hook would trade an empty repo for an unpushable one.
229 let token = std::env::var("BUILD_TRIGGER_TOKEN").ok();
230 if let Err(error) = install_hooks_for_repo(&repo_dir, token.as_deref(), owner, repo_name) {
231 tracing::warn!(error = ?error, path = %repo_dir.display(), "hooks not installed");
232 }
233
234 Ok(())
235 }
236
237 /// The configured git repository root (`GIT_REPOS_PATH`, default `/opt/git`).
238 fn git_repos_root() -> std::path::PathBuf {
239 std::path::PathBuf::from(
240 std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string()),
241 )
242 }
243
244 /// Run git-shell as a child with inherited stdio (the ssh channel's fds) under a
245 /// runaway-backstop timeout, then exit this process with the child's status.
246 ///
247 /// Replaces the previous `exec()`-into-git-shell: `exec` left no opportunity to
248 /// bound a stalled transfer, so a client that stopped reading could pin the
249 /// process indefinitely. Spawning lets us `timeout` the wait and kill a stuck
250 /// operation (`GIT_SSH_OP_TIMEOUT_SECS`). The process still terminates here on
251 /// every path, so it behaves like the old `exec` to the git client (its exit
252 /// code propagates); it returns `Err` only if git-shell cannot be spawned.
253 async fn run_git_shell(original_cmd: &str) -> anyhow::Result<()> {
254 use tokio::process::Command;
255
256 let mut child = Command::new("git-shell")
257 .args(["-c", original_cmd])
258 .spawn()
259 .map_err(|e| anyhow::anyhow!("failed to spawn git-shell: {e}"))?;
260
261 let timeout = std::time::Duration::from_secs(crate::constants::GIT_SSH_OP_TIMEOUT_SECS);
262 match tokio::time::timeout(timeout, child.wait()).await {
263 Ok(Ok(status)) => std::process::exit(status.code().unwrap_or(0)),
264 Ok(Err(e)) => anyhow::bail!("git-shell wait failed: {e}"),
265 Err(_elapsed) => {
266 let _ = child.start_kill();
267 let _ = child.wait().await;
268 eprintln!(
269 "git operation timed out after {}s",
270 crate::constants::GIT_SSH_OP_TIMEOUT_SECS
271 );
272 std::process::exit(124); // matches coreutils `timeout` exit code
273 }
274 }
275 }
276
277 /// Install every hook a bare repository needs.
278 ///
279 /// One call rather than one per hook, because the hooks are not independent:
280 /// `post-receive` reindexes a notes push and `update` decides whether that push
281 /// is allowed at all, and a repository with only the first enforces no policy
282 /// while looking installed. A caller that has to remember the second is a
283 /// caller that eventually does not.
284 /// `token` is optional because `post-receive` carries a per-repo HMAC and is
285 /// useless without one, while `update` carries nothing and enforces a policy
286 /// that holds whether or not this deployment runs builds. Gating both on the
287 /// token would leave a server with no `BUILD_TRIGGER_TOKEN` accepting pushes to
288 /// namespaces MNW owns.
289 pub fn install_hooks_for_repo(
290 repo_dir: &std::path::Path,
291 token: Option<&str>,
292 owner: &str,
293 repo_name: &str,
294 ) -> anyhow::Result<()> {
295 if let Some(token) = token {
296 install_hook(
297 repo_dir,
298 "post-receive",
299 &crate::build_runner::post_receive_hook(token, owner, repo_name),
300 )?;
301 }
302 install_hook(repo_dir, "update", crate::build_runner::UPDATE_HOOK)?;
303 Ok(())
304 }
305
306 /// Write one executable hook into a bare repository's `hooks/`.
307 fn install_hook(
308 repo_dir: &std::path::Path,
309 hook_name: &str,
310 hook_content: &str,
311 ) -> anyhow::Result<()> {
312 let hooks_dir = repo_dir.join("hooks");
313 std::fs::create_dir_all(&hooks_dir)?;
314 let hook_path = hooks_dir.join(hook_name);
315 std::fs::write(&hook_path, hook_content)?;
316
317 #[cfg(unix)]
318 {
319 use std::os::unix::fs::PermissionsExt;
320 std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
321 }
322
323 Ok(())
324 }
325
326 // ── authorized_keys ──
327 //
328 // All that remains of the management half. mnw-cli authenticates from the
329 // database rather than this file, so it is written for whatever still consults
330 // sshd: a key removed from one door but not the other is a key the user
331 // believes is gone.
332
333 /// Write the authorized_keys file from all DB keys. Optionally set git:git ownership.
334 pub async fn write_authorized_keys(pool: &PgPool, set_ownership: bool) -> anyhow::Result<()> {
335 let keys = db::ssh_keys::get_all_keys_with_username(pool).await?;
336
337 let mut content = String::new();
338 content.push_str("# Managed by mnw-admin rebuild-keys. Do not edit manually.\n");
339
340 for key in &keys {
341 writeln!(
342 content,
343 "command=\"{} git-auth {}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {}",
344 MNW_ADMIN_PATH, key.id, key.public_key,
345 )
346 .unwrap();
347 }
348
349 let keys_path = authorized_keys_path();
350 let tmp_path = keys_path.with_extension("tmp");
351 std::fs::write(&tmp_path, &content)?;
352 std::fs::rename(&tmp_path, &keys_path)?;
353
354 #[cfg(unix)]
355 {
356 use std::os::unix::fs::PermissionsExt;
357 std::fs::set_permissions(&keys_path, std::fs::Permissions::from_mode(0o600))?;
358
359 if set_ownership {
360 let status = std::process::Command::new("chown")
361 .arg("git:git")
362 .arg(&keys_path)
363 .status()?;
364 if !status.success() {
365 anyhow::bail!("chown git:git failed on {}", keys_path.display());
366 }
367 }
368 }
369
370 Ok(())
371 }
372
373 #[cfg(test)]
374 mod tests {
375 use super::*;
376
377 // ── the git-command door ──
378
379 // The push path's half of repo creation. Registering the row was never the
380 // part that broke; this is.
381 #[test]
382 fn a_first_push_gets_a_bare_repo_on_disk() {
383 let root = tempfile::tempdir().unwrap();
384 let repo_dir = root.path().join("max").join("shop.git");
385
386 ensure_bare_repo_on_disk(root.path(), "max", "shop").unwrap();
387 assert!(
388 gix::open(&repo_dir).is_ok(),
389 "a push to a name with no repo has somewhere to write",
390 );
391
392 // Idempotent, because this runs on every push and not only the first.
393 // It is also what repairs a repo registered before the fix: the row is
394 // there, the directory is not, and the branch above makes it.
395 ensure_bare_repo_on_disk(root.path(), "max", "shop").unwrap();
396 assert!(gix::open(&repo_dir).is_ok());
397 }
398
399 // The grammar's own tests live in `git-command`, which owns the parser.
400 // What is worth asserting here is that this door reaches it and keeps the
401 // guarantee the rest of the function leans on: a request that parses names
402 // one owner and one repo, and neither can leave the git root.
403
404 #[test]
405 fn the_door_parses_what_a_client_sends() {
406 let r = git_command::parse("git-receive-pack '/user/repo.git'").unwrap();
407 assert_eq!(r.operation, Operation::ReceivePack);
408 assert_eq!((r.owner, r.repo), ("user", "repo"));
409 }
410
411 #[test]
412 fn the_shell_argument_is_rebuilt_from_validated_parts() {
413 let r = git_command::parse("git-upload-pack '/user/repo.git'").unwrap();
414 assert_eq!(r.shell_command(), "git-upload-pack '/user/repo.git'");
415 }
416
417 #[test]
418 fn a_traversing_path_never_reaches_the_db_lookup() {
419 for cmd in [
420 "git-upload-pack '/../etc/passwd'",
421 "git-upload-pack '/user/../../etc'",
422 "git-receive-pack '/user/.hidden.git'",
423 "git-upload-pack '/user/a/b.git'",
424 ] {
425 assert!(git_command::parse(cmd).is_err(), "{cmd}");
426 }
427 }
428
429 #[test]
430 fn management_verbs_are_not_this_grammar() {
431 assert!(git_command::parse("repo list").is_err());
432 }
433 }
434