max / makenotwork
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 | }; |