Skip to main content

max / makenotwork

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