Skip to main content

max / makenotwork

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