Skip to main content

max / makenotwork

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