Skip to main content

max / makenotwork

12.3 KB · 319 lines History Blame Raw
1 //! Git operation proxy: command parsing and subprocess management.
2 //!
3 //! When a git client connects via SSH (e.g., `git push git@ssh.makenot.work:max/repo.git`),
4 //! the exec_request receives a command like `git-receive-pack 'max/repo.git'`. This module
5 //! parses that command, and spawns the git subprocess with its stdin/stdout/stderr piped
6 //! through the SSH channel.
7
8 use russh::ChannelId;
9 use russh::server::Handle;
10 use tokio::io::AsyncReadExt;
11 use tokio::process::{Child, Command};
12
13 /// Parse a git exec command line.
14 ///
15 /// The grammar lives in `git-command`, shared with the server's `git_ssh` door.
16 /// It used to be parsed here and again there, and the two hand-written parsers
17 /// disagreed on 51 of 5,424 measured command lines: leading whitespace,
18 /// unbalanced quotes, repeated leading slashes, and whether `..` was tested
19 /// before or after the `.git` suffix came off. The last of those was this
20 /// module's, and it was the one with teeth — `/max/repo..git` parsed here as
21 /// repo `repo.`, safe only because a charset guard further in happened to
22 /// catch it.
23 ///
24 /// Returns `None` for anything that is not a well-formed, path-safe git
25 /// request, which is every case this door refuses.
26 pub(crate) fn parse_command(cmd: &str) -> Option<git_command::Request<'_>> {
27 git_command::parse(cmd).ok()
28 }
29
30 /// Spawn a git subprocess and wire its I/O through the SSH channel.
31 ///
32 /// Returns the child's stdin handle so the caller can forward SSH data() to it.
33 /// Stdout/stderr forwarding and process cleanup run in background tasks.
34 pub(crate) fn spawn_git_process(
35 git_user: &str,
36 operation: &str,
37 repo_path: &str,
38 channel: ChannelId,
39 handle: Handle,
40 ) -> anyhow::Result<tokio::process::ChildStdin> {
41 // An empty `git_user` means no separate git account is configured, so run
42 // the operation as whoever we already are. Production always names one
43 // (`--git-user`, default `git`) and takes the sudo path; the direct path is
44 // what lets the backpressure test below drive a real push without needing a
45 // sudo grant in the test environment.
46 let mut command = if git_user.is_empty() {
47 let mut c = Command::new(operation);
48 c.arg(repo_path);
49 c
50 } else {
51 let mut c = Command::new("sudo");
52 c.args(["-u", git_user, operation, repo_path]);
53 c
54 };
55
56 let mut child: Child = command
57 .stdin(std::process::Stdio::piped())
58 .stdout(std::process::Stdio::piped())
59 .stderr(std::process::Stdio::piped())
60 .kill_on_drop(true)
61 .spawn()?;
62
63 let stdin = child
64 .stdin
65 .take()
66 .ok_or_else(|| anyhow::anyhow!("failed to capture child stdin"))?;
67 let stdout = child
68 .stdout
69 .take()
70 .ok_or_else(|| anyhow::anyhow!("failed to capture child stdout"))?;
71 let stderr = child
72 .stderr
73 .take()
74 .ok_or_else(|| anyhow::anyhow!("failed to capture child stderr"))?;
75
76 // Forward stdout → SSH channel data
77 let stdout_handle = handle.clone();
78 let stdout_task = tokio::spawn(async move {
79 let mut reader = stdout;
80 let mut buf = vec![0u8; 32768];
81 loop {
82 match reader.read(&mut buf).await {
83 Ok(0) => break,
84 Ok(n) => {
85 let data = bytes::Bytes::copy_from_slice(&buf[..n]);
86 if stdout_handle.data(channel, data).await.is_err() {
87 break;
88 }
89 }
90 Err(_) => break,
91 }
92 }
93 });
94
95 // Forward stderr → SSH channel extended data (type 1 = stderr)
96 let stderr_handle = handle.clone();
97 let stderr_task = tokio::spawn(async move {
98 let mut reader = stderr;
99 let mut buf = [0u8; 8192];
100 loop {
101 match reader.read(&mut buf).await {
102 Ok(0) => break,
103 Ok(n) => {
104 let data = bytes::Bytes::copy_from_slice(&buf[..n]);
105 if stderr_handle.extended_data(channel, 1, data).await.is_err() {
106 break;
107 }
108 }
109 Err(_) => break,
110 }
111 }
112 });
113
114 // Wait for subprocess to complete, then close the SSH channel
115 tokio::spawn(async move {
116 let _ = stdout_task.await;
117 let _ = stderr_task.await;
118
119 let exit_code = match child.wait().await {
120 Ok(status) => status.code().unwrap_or(1) as u32,
121 Err(_) => 1,
122 };
123
124 let _ = handle.exit_status_request(channel, exit_code).await;
125 let _ = handle.eof(channel).await;
126 let _ = handle.close(channel).await;
127 });
128
129 Ok(stdin)
130 }
131
132 /// Post-receive hook template. `__HMAC__` is replaced with a per-repo HMAC
133 /// signature so the global `BUILD_TRIGGER_TOKEN` never lands on disk. Both
134 /// endpoints verify `HMAC(token, owner:repo)`, not the raw token, so this has
135 /// to match `server`'s `build_runner::POST_RECEIVE_HOOK_TEMPLATE`.
136 const POST_RECEIVE_HOOK: &str = r#"#!/bin/bash
137 while read oldrev newrev refname; do
138 case "$refname" in
139 refs/tags/v[0-9]*)
140 TAG="${refname#refs/tags/}"
141 REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
142 REPO_NAME="$(basename "$REPO_PATH" .git)"
143 OWNER="$(basename "$(dirname "$REPO_PATH")")"
144 curl -sf -X POST \
145 -H "Authorization: Bearer __HMAC__" \
146 -H "Content-Type: application/json" \
147 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"tag\": \"$TAG\"}" \
148 "http://localhost:3000/api/internal/builds/trigger" \
149 >/dev/null 2>&1 &
150 ;;
151 refs/heads/*)
152 BRANCH="${refname#refs/heads/}"
153 REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
154 REPO_NAME="$(basename "$REPO_PATH" .git)"
155 OWNER="$(basename "$(dirname "$REPO_PATH")")"
156 curl -sf -X POST \
157 -H "Authorization: Bearer __HMAC__" \
158 -H "Content-Type: application/json" \
159 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$BRANCH\", \"before\": \"$oldrev\", \"after\": \"$newrev\"}" \
160 "http://localhost:3000/api/internal/issues/process-push" \
161 >/dev/null 2>&1 &
162 ;;
163 refs/mnw/notes-inbox/*)
164 # Not backgrounded, unlike the two above. A notes merge is the
165 # answer to this push, and post-receive stdout is the only way back
166 # to the person who made it. The timeouts stop a quiet server from
167 # hanging a push.
168 REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
169 REPO_NAME="$(basename "$REPO_PATH" .git)"
170 OWNER="$(basename "$(dirname "$REPO_PATH")")"
171 if curl -sf --connect-timeout 5 --max-time 30 -X POST \
172 -H "Authorization: Bearer __HMAC__" \
173 -H "Content-Type: application/json" \
174 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$refname\"}" \
175 "http://localhost:3000/api/internal/notes/merge-inbox" >/dev/null 2>&1; then
176 echo "notes: merged into refs/notes/${refname#refs/mnw/notes-inbox/}"
177 else
178 # The notes are in the inbox ref regardless, so nothing is lost
179 # and the next push merges them.
180 echo "notes: received, merge deferred (the server did not answer)"
181 fi
182 ;;
183 esac
184 done
185 "#;
186
187 /// Compute the per-repo HMAC the internal endpoints expect. Mirrors
188 /// `build_runner::repo_hmac` on the server side; the two must agree.
189 fn repo_hmac(token: &str, owner: &str, repo: &str) -> String {
190 use hmac::{Hmac, KeyInit, Mac};
191 use sha2::Sha256;
192 let mut mac =
193 <Hmac<Sha256>>::new_from_slice(token.as_bytes()).expect("HMAC accepts any key length");
194 mac.update(format!("{owner}:{repo}").as_bytes());
195 hex::encode(mac.finalize().into_bytes())
196 }
197
198 /// Split a bare-repo path into (owner, repo), matching what the hook derives
199 /// at run time from its own location: repo = basename minus `.git`, owner =
200 /// the containing directory's name.
201 fn owner_and_repo(repo_path: &str) -> Option<(String, String)> {
202 let path = std::path::Path::new(repo_path);
203 let repo = path.file_name()?.to_str()?;
204 let repo = repo.strip_suffix(".git").unwrap_or(repo);
205 let owner = path.parent()?.file_name()?.to_str()?;
206 Some((owner.to_string(), repo.to_string()))
207 }
208
209 /// Install the post-receive hook in a bare repository.
210 pub(crate) async fn install_post_receive_hook(
211 _git_user: &str,
212 repo_path: &str,
213 token: &str,
214 ) -> anyhow::Result<()> {
215 let (owner, repo) = owner_and_repo(repo_path)
216 .ok_or_else(|| anyhow::anyhow!("cannot derive owner/repo from {repo_path}"))?;
217 let hook_content = POST_RECEIVE_HOOK.replace("__HMAC__", &repo_hmac(token, &owner, &repo));
218 let hook_path = std::path::PathBuf::from(repo_path).join("hooks/post-receive");
219
220 // Write directly — mnw-cli is in the git group, setgid dir gives correct ownership
221 tokio::fs::write(&hook_path, hook_content.as_bytes()).await?;
222
223 #[cfg(unix)]
224 {
225 use std::os::unix::fs::PermissionsExt;
226 tokio::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755)).await?;
227 }
228
229 tracing::debug!(path = %hook_path.display(), "installed post-receive hook");
230 Ok(())
231 }
232
233 #[cfg(test)]
234 mod tests {
235 use super::*;
236
237 // The grammar's tests live in `git-command`, which owns the parser. What
238 // this door owes is proof that it reaches it and refuses what it should.
239
240 #[test]
241 fn the_three_verbs_a_client_sends() {
242 let r = parse_command("git-upload-pack '/max/repo.git'").unwrap();
243 assert_eq!(r.operation.command(), "git-upload-pack");
244 assert_eq!((r.owner, r.repo), ("max", "repo"));
245
246 let r = parse_command("git-receive-pack max/repo.git").unwrap();
247 assert_eq!(r.operation.command(), "git-receive-pack");
248
249 let r = parse_command("git-upload-archive \"/max/repo.git\"").unwrap();
250 assert_eq!(r.operation.command(), "git-upload-archive");
251 }
252
253 #[test]
254 fn non_git_commands_are_refused() {
255 assert!(parse_command("ls -la").is_none());
256 assert!(parse_command("scp -t /tmp/file").is_none());
257 }
258
259 #[test]
260 fn path_unsafe_requests_are_refused() {
261 for cmd in [
262 "git-upload-pack ../evil/repo.git",
263 "git-upload-pack max/../../etc.git",
264 "git-upload-pack max/sub/repo.git",
265 "git-upload-pack max/.git",
266 "git-upload-pack max/",
267 "git-upload-pack /",
268 ] {
269 assert!(parse_command(cmd).is_none(), "{cmd}");
270 }
271 }
272
273 /// The divergence this door owned. `.git` used to come off before `..` was
274 /// tested, so this parsed as repo `repo.`.
275 #[test]
276 fn dotdot_is_tested_before_the_git_suffix() {
277 assert!(parse_command("git-upload-pack /max/repo..git").is_none());
278 }
279
280 /// Locks the derivation to the same vector the server's `repo_hmac`
281 /// produces (HMAC-SHA256 over `owner:repo`, hex). If this drifts, every
282 /// hook mnw-cli installs starts failing auth silently.
283 #[test]
284 fn repo_hmac_matches_server_vector() {
285 assert_eq!(
286 repo_hmac("test-token", "max", "repo"),
287 "198b4a4f542c0c27c4ca333030dfe09fe670aa44bae34d7a586481bb13fd9eb0"
288 );
289 }
290
291 #[test]
292 fn owner_and_repo_from_bare_path() {
293 let (owner, repo) = owner_and_repo("/srv/git/max/repo.git").unwrap();
294 assert_eq!(owner, "max");
295 assert_eq!(repo, "repo");
296
297 // No .git suffix is still valid.
298 let (owner, repo) = owner_and_repo("/srv/git/max/repo").unwrap();
299 assert_eq!(owner, "max");
300 assert_eq!(repo, "repo");
301
302 // A bare name has no owner directory to read.
303 assert!(owner_and_repo("repo.git").is_none());
304 }
305
306 /// The hook that lands on disk must carry the per-repo HMAC and never the
307 /// global token itself.
308 #[test]
309 fn hook_body_carries_hmac_not_token() {
310 let token = "super-secret-token";
311 let (owner, repo) = owner_and_repo("/srv/git/max/repo.git").unwrap();
312 let body = POST_RECEIVE_HOOK.replace("__HMAC__", &repo_hmac(token, &owner, &repo));
313
314 assert!(!body.contains(token));
315 assert!(!body.contains("__HMAC__"));
316 assert!(body.contains(&repo_hmac(token, "max", "repo")));
317 }
318 }
319