Skip to main content

max / makenotwork

Delegate git repo auto-create from server to mnw-cli The server can't create bare repos on disk because it runs under NoNewPrivileges=true and the git directories are owned by the git user. Move repo creation to mnw-cli, which already spawns git operations via sudo -u git. Server now only registers repos in the DB on first push. mnw-cli creates the bare repo and installs the post-receive hook as the git user before spawning git-receive-pack. Relax NoNewPrivileges and RestrictSUIDSGID on mnw-cli.service (required for sudo). Remove /opt/git from server ReadWritePaths (no longer needed). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-03 00:30 UTC
Commit: 44cfcdeb0f4d51fcaac2bf2b02f203f2629d4d4d
Parent: 9f103ba
6 files changed, +121 insertions, -51 deletions
@@ -18,15 +18,17 @@ EnvironmentFile=/opt/mnw-cli/.env
18 18 Environment=HOME=/opt/mnw-cli
19 19
20 20 # Security hardening
21 - NoNewPrivileges=true
21 + # NoNewPrivileges and RestrictSUIDSGID must be false because mnw-cli
22 + # spawns git operations via sudo -u git (requires setuid escalation).
23 + NoNewPrivileges=false
22 24 ProtectSystem=strict
23 25 ProtectHome=true
24 26 PrivateTmp=true
25 - ReadWritePaths=/opt/mnw-cli /var/lib/mnw-cli
27 + ReadWritePaths=/opt/mnw-cli /var/lib/mnw-cli /opt/git
26 28 RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
27 29 RestrictNamespaces=true
28 30 RestrictRealtime=true
29 - RestrictSUIDSGID=true
31 + RestrictSUIDSGID=false
30 32 LockPersonality=true
31 33 ProtectKernelTunables=true
32 34 ProtectKernelModules=true
@@ -145,6 +145,70 @@ pub async fn spawn_git_process(
145 145 Ok(stdin)
146 146 }
147 147
148 + /// Post-receive hook template. __TOKEN__ is replaced with the actual token.
149 + const POST_RECEIVE_HOOK: &str = r#"#!/bin/bash
150 + while read oldrev newrev refname; do
151 + case "$refname" in
152 + refs/tags/v[0-9]*)
153 + TAG="${refname#refs/tags/}"
154 + REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
155 + REPO_NAME="$(basename "$REPO_PATH" .git)"
156 + OWNER="$(basename "$(dirname "$REPO_PATH")")"
157 + curl -sf -X POST \
158 + -H "Authorization: Bearer __TOKEN__" \
159 + -H "Content-Type: application/json" \
160 + -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"tag\": \"$TAG\"}" \
161 + "http://localhost:3000/api/internal/builds/trigger" \
162 + >/dev/null 2>&1 &
163 + ;;
164 + refs/heads/*)
165 + BRANCH="${refname#refs/heads/}"
166 + REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
167 + REPO_NAME="$(basename "$REPO_PATH" .git)"
168 + OWNER="$(basename "$(dirname "$REPO_PATH")")"
169 + curl -sf -X POST \
170 + -H "Authorization: Bearer __TOKEN__" \
171 + -H "Content-Type: application/json" \
172 + -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$BRANCH\", \"before\": \"$oldrev\", \"after\": \"$newrev\"}" \
173 + "http://localhost:3000/api/internal/issues/process-push" \
174 + >/dev/null 2>&1 &
175 + ;;
176 + esac
177 + done
178 + "#;
179 +
180 + /// Install the post-receive hook in a bare repository.
181 + pub async fn install_post_receive_hook(
182 + git_user: &str,
183 + repo_path: &str,
184 + token: &str,
185 + ) -> anyhow::Result<()> {
186 + let hook_content = POST_RECEIVE_HOOK.replace("__TOKEN__", token);
187 + let hook_path = format!("{repo_path}/hooks/post-receive");
188 +
189 + // Write hook content via sudo tee (runs as git user)
190 + let mut child = tokio::process::Command::new("sudo")
191 + .args(["-u", git_user, "tee", &hook_path])
192 + .stdin(std::process::Stdio::piped())
193 + .stdout(std::process::Stdio::null())
194 + .spawn()?;
195 +
196 + if let Some(mut stdin) = child.stdin.take() {
197 + use tokio::io::AsyncWriteExt;
198 + stdin.write_all(hook_content.as_bytes()).await?;
199 + }
200 + child.wait().await?;
201 +
202 + // Make executable
203 + let _ = tokio::process::Command::new("sudo")
204 + .args(["-u", git_user, "chmod", "+x", &hook_path])
205 + .status()
206 + .await;
207 +
208 + tracing::debug!(path = %hook_path, "installed post-receive hook");
209 + Ok(())
210 + }
211 +
148 212 #[cfg(test)]
149 213 mod tests {
150 214 use super::*;
@@ -297,6 +297,50 @@ impl russh::server::Handler for MnwHandler {
297 297 .await
298 298 {
299 299 Ok(auth) => {
300 + // Auto-create bare repo on disk if it doesn't exist yet.
301 + // The server only registers the repo in the DB — we create
302 + // it here as the git user so ownership is correct.
303 + if !std::path::Path::new(&auth.repo_path).exists() {
304 + match tokio::process::Command::new("sudo")
305 + .args(["-u", &self.git_user, "git", "init", "--bare", &auth.repo_path])
306 + .stdout(std::process::Stdio::null())
307 + .stderr(std::process::Stdio::piped())
308 + .status()
309 + .await
310 + {
311 + Ok(s) if s.success() => {
312 + tracing::info!(path = %auth.repo_path, "auto-created bare repository");
313 + // Install post-receive hook if build trigger token is configured
314 + if let Ok(token) = std::env::var("BUILD_TRIGGER_TOKEN") {
315 + let _ = git::install_post_receive_hook(
316 + &self.git_user,
317 + &auth.repo_path,
318 + &token,
319 + )
320 + .await;
321 + }
322 + }
323 + Ok(s) => {
324 + tracing::error!(path = %auth.repo_path, code = ?s.code(), "git init --bare failed");
325 + let msg = bytes::Bytes::from("fatal: failed to create repository\r\n");
326 + let _ = handle.extended_data(channel, 1, msg).await;
327 + let _ = handle.exit_status_request(channel, 1).await;
328 + let _ = handle.eof(channel).await;
329 + let _ = handle.close(channel).await;
330 + return Ok(());
331 + }
332 + Err(e) => {
333 + tracing::error!(error = ?e, "failed to spawn git init");
334 + let msg = bytes::Bytes::from("fatal: internal error\r\n");
335 + let _ = handle.extended_data(channel, 1, msg).await;
336 + let _ = handle.exit_status_request(channel, 1).await;
337 + let _ = handle.eof(channel).await;
338 + let _ = handle.close(channel).await;
339 + return Ok(());
340 + }
341 + }
342 + }
343 +
300 344 match git::spawn_git_process(
301 345 &self.git_user,
302 346 operation,
@@ -34,7 +34,7 @@ NoNewPrivileges=true
34 34 ProtectSystem=strict
35 35 ProtectHome=true
36 36 PrivateTmp=true
37 - ReadWritePaths=/opt/makenotwork /opt/git
37 + ReadWritePaths=/opt/makenotwork
38 38 RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
39 39 RestrictNamespaces=true
40 40 RestrictRealtime=true
@@ -62,28 +62,13 @@ async fn exec_git_operation(
62 62 let repo = match db::git_repos::get_repo_by_user_and_name(pool, owner_user.id, &repo_name).await? {
63 63 Some(repo) => repo,
64 64 None => {
65 - // Auto-create on push if the authenticated user owns the namespace
65 + // Auto-create on push if the authenticated user owns the namespace.
66 + // Only register in the DB — the caller creates the bare repo on disk.
66 67 if !matches!(operation, GitOperation::ReceivePack) || user_id != owner_user.id {
67 68 anyhow::bail!("repository not found");
68 69 }
69 70
70 - let git_root = std::env::var("GIT_REPOS_PATH")
71 - .unwrap_or_else(|_| "/opt/git".to_string());
72 - let owner_dir = std::path::Path::new(&git_root).join(owner);
73 - let repo_dir = owner_dir.join(format!("{repo_name}.git"));
74 -
75 - // The server user is in the git group and owner dirs are
76 - // group-writable, so we can create repos directly via git2.
77 - std::fs::create_dir_all(&owner_dir)?;
78 - git2::Repository::init_bare(&repo_dir)?;
79 -
80 - // Install post-receive hook if build trigger token is configured
81 - if let Ok(token) = std::env::var("BUILD_TRIGGER_TOKEN") {
82 - let hook_content = crate::build_runner::post_receive_hook(&token);
83 - install_hook_for_repo(&repo_dir, &hook_content)?;
84 - }
85 -
86 - eprintln!("Auto-created repository {}/{}", owner, repo_name);
71 + tracing::info!(owner = %owner, repo = %repo_name, "registering new repository");
87 72 db::git_repos::create_repo(pool, owner_user.id, &repo_name).await?
88 73 }
89 74 };
@@ -142,39 +142,14 @@ pub(super) async fn git_authorize(
142 142 {
143 143 Some(repo) => repo,
144 144 None => {
145 - // Auto-create on push if the authenticated user owns the namespace
145 + // Auto-create on push if the authenticated user owns the namespace.
146 + // Only register in the DB here — mnw-cli creates the bare repo on
147 + // disk as the git user (avoids ownership/privilege issues).
146 148 if req.operation != "git-receive-pack" || req.user_id != owner_user.id {
147 149 return Err(AppError::NotFound);
148 150 }
149 151
150 - let owner_dir = std::path::Path::new(git_root).join(&req.owner);
151 - let repo_dir = owner_dir.join(format!("{}.git", req.repo_name));
152 - fn io_err(e: std::io::Error) -> AppError { AppError::Internal(e.into()) }
153 -
154 - // The makenotwork user is in the git group and owner dirs are
155 - // group-writable, so we can create repos directly via git2.
156 - std::fs::create_dir_all(&owner_dir).map_err(io_err)?;
157 - git2::Repository::init_bare(&repo_dir)
158 - .map_err(|e| AppError::Internal(e.into()))?;
159 -
160 - // Install post-receive hook if build trigger token is configured
161 - if let Some(ref token) = state.config.build_trigger_token {
162 - let hook_content = crate::build_runner::post_receive_hook(token);
163 - let hooks_dir = repo_dir.join("hooks");
164 - std::fs::create_dir_all(&hooks_dir).map_err(io_err)?;
165 - let hook_path = hooks_dir.join("post-receive");
166 - std::fs::write(&hook_path, &hook_content).map_err(io_err)?;
167 - #[cfg(unix)]
168 - {
169 - use std::os::unix::fs::PermissionsExt;
170 - let _ = std::fs::set_permissions(
171 - &hook_path,
172 - std::fs::Permissions::from_mode(0o755),
173 - );
174 - }
175 - }
176 -
177 - tracing::info!(owner = %req.owner, repo = %req.repo_name, "auto-created bare repository");
152 + tracing::info!(owner = %req.owner, repo = %req.repo_name, "registering new repository");
178 153 db::git_repos::create_repo(&state.db, owner_user.id, &req.repo_name).await?
179 154 }
180 155 };