From 0c995f9a0aa69cc04c80a5a36a09e532c4fd10f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 12:59:02 +0000 Subject: [PATCH] feat: HTTP git push with API key auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces SSH as the primary git push path — no key generation needed. # Admin UI: Users → Generate key (shown once) git remote add hiy http://hiy:API_KEY@myserver/git/myapp git push hiy main What was added: - api_keys DB table (id, user_id, label, key_hash/SHA-256, created_at) Keys are stored as SHA-256 hashes; the plaintext is shown once on creation and never stored. - routes/api_keys.rs GET/POST /api/users/:id/api-keys — list / generate DELETE /api/api-keys/:key_id — revoke - HTTP Smart Protocol endpoints (public, auth via Basic + API key) GET /git/:app/info/refs — ref advertisement POST /git/:app/git-receive-pack — receive pack, runs post-receive hook Authentication: HTTP Basic where the password is the API key. git prompts once and caches via the OS credential store. post-receive hook fires as normal and queues the build. - Admin UI: API keys section per user with generate/revoke and a one-time reveal box showing the ready-to-use git remote command. SSH path (git-shell + authorized_keys) is still functional for users who prefer it; both paths feed the same build queue. https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH --- server/Cargo.toml | 1 + server/src/db.rs | 12 ++ server/src/main.rs | 6 + server/src/models.rs | 14 ++ server/src/routes/api_keys.rs | 108 +++++++++++++ server/src/routes/git.rs | 283 ++++++++++++++++++++++++++++------ server/src/routes/mod.rs | 1 + server/src/routes/ui.rs | 68 +++++++- 8 files changed, 446 insertions(+), 47 deletions(-) create mode 100644 server/src/routes/api_keys.rs diff --git a/server/Cargo.toml b/server/Cargo.toml index 10f4432..f69c46d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -26,3 +26,4 @@ async-stream = "0.3" bcrypt = "0.15" anyhow = "1" futures = "0.3" +base64 = "0.22" diff --git a/server/src/db.rs b/server/src/db.rs index 8641cfd..1a3980f 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -89,5 +89,17 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> { .execute(pool) .await?; + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + label TEXT NOT NULL, + key_hash TEXT NOT NULL, + created_at TEXT NOT NULL + )"#, + ) + .execute(pool) + .await?; + Ok(()) } diff --git a/server/src/main.rs b/server/src/main.rs index 1257d16..f88a6e2 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -167,6 +167,9 @@ async fn main() -> anyhow::Result<()> { // SSH key management (admin only) .route("/api/users/:id/ssh-keys", get(routes::ssh_keys::list).post(routes::ssh_keys::add)) .route("/api/ssh-keys/:key_id", delete(routes::ssh_keys::remove)) + // API key management (admin only) + .route("/api/users/:id/api-keys", get(routes::api_keys::list).post(routes::api_keys::create)) + .route("/api/api-keys/:key_id", delete(routes::api_keys::revoke)) .route_layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware)); // ── Public routes ───────────────────────────────────────────────────────── @@ -178,6 +181,9 @@ async fn main() -> anyhow::Result<()> { .route("/auth/verify", get(auth::verify)) // GitHub webhooks use HMAC-SHA256 — no session needed. .route("/webhook/:app_id", post(routes::webhooks::github)) + // HTTP Smart Protocol for git push — authenticated by API key. + .route("/git/:app/info/refs", get(routes::git::http_info_refs)) + .route("/git/:app/git-receive-pack", post(routes::git::http_receive_pack)) // Internal routes: called by hiy-git-shell and post-receive hooks. .route("/internal/git/auth", get(routes::git::auth_check)) .route("/internal/git/:app_id/push", post(routes::git::push_trigger)); diff --git a/server/src/models.rs b/server/src/models.rs index 1418d17..8ac30f4 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -89,3 +89,17 @@ pub struct CreateSshKey { pub label: String, pub public_key: String, } + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ApiKey { + pub id: String, + pub user_id: String, + pub label: String, + pub created_at: String, + // key_hash is intentionally not exposed in serialised output +} + +#[derive(Debug, Deserialize)] +pub struct CreateApiKey { + pub label: String, +} diff --git a/server/src/routes/api_keys.rs b/server/src/routes/api_keys.rs new file mode 100644 index 0000000..b0be5e6 --- /dev/null +++ b/server/src/routes/api_keys.rs @@ -0,0 +1,108 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use chrono::Utc; +use sha2::{Digest, Sha256}; +use serde_json::json; +use uuid::Uuid; + +use crate::{models::{ApiKey, CreateApiKey}, AppState}; + +// ── List API keys for a user (hashes never returned) ───────────────────────── + +pub async fn list( + State(s): State, + Path(user_id): Path, +) -> impl IntoResponse { + let keys: Vec = + sqlx::query_as("SELECT id, user_id, label, created_at FROM api_keys WHERE user_id = ? ORDER BY created_at") + .bind(&user_id) + .fetch_all(&s.db) + .await + .unwrap_or_default(); + Json(keys) +} + +// ── Generate a new API key ──────────────────────────────────────────────────── + +pub async fn create( + State(s): State, + Path(user_id): Path, + Json(body): Json, +) -> impl IntoResponse { + let label = body.label.trim().to_string(); + if label.is_empty() { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "label required"}))).into_response(); + } + + // Generate a random key: "hiy_" + 32 random bytes as hex (68 chars total). + let random_bytes: Vec = (0..32).map(|_| rand_byte()).collect(); + let raw_key = format!("hiy_{}", hex::encode(&random_bytes)); + + // Store SHA-256 of the raw key — never the key itself. + let hash = hex::encode(Sha256::digest(raw_key.as_bytes())); + + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + + let res = sqlx::query( + "INSERT INTO api_keys (id, user_id, label, key_hash, created_at) VALUES (?,?,?,?,?)", + ) + .bind(&id) + .bind(&user_id) + .bind(&label) + .bind(&hash) + .bind(&now) + .execute(&s.db) + .await; + + match res { + Ok(_) => ( + StatusCode::CREATED, + // Return the plaintext key once — it cannot be recovered after this. + Json(json!({"id": id, "key": raw_key, "label": label})), + ) + .into_response(), + Err(e) => { + tracing::error!("create api key: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "db error"}))).into_response() + } + } +} + +// ── Revoke an API key ───────────────────────────────────────────────────────── + +pub async fn revoke( + State(s): State, + Path(key_id): Path, +) -> impl IntoResponse { + sqlx::query("DELETE FROM api_keys WHERE id = ?") + .bind(&key_id) + .execute(&s.db) + .await + .ok(); + StatusCode::NO_CONTENT +} + +// ── Lookup by raw key value (used by HTTP git auth) ─────────────────────────── + +/// Returns the user_id if the raw key matches a stored hash. +pub async fn verify_key(s: &AppState, raw_key: &str) -> Option { + let hash = hex::encode(Sha256::digest(raw_key.as_bytes())); + sqlx::query_scalar("SELECT user_id FROM api_keys WHERE key_hash = ?") + .bind(&hash) + .fetch_optional(&s.db) + .await + .unwrap_or(None) +} + +// ── Tiny CSPRNG using uuid entropy ─────────────────────────────────────────── + +fn rand_byte() -> u8 { + // uuid v4 is generated from the OS CSPRNG; pulling one byte at a time is + // wasteful but avoids adding a rand dependency just for this. + Uuid::new_v4().as_bytes()[0] +} diff --git a/server/src/routes/git.rs b/server/src/routes/git.rs index 7b34e54..0f632ba 100644 --- a/server/src/routes/git.rs +++ b/server/src/routes/git.rs @@ -1,17 +1,23 @@ -/// Internal routes called by hiy-git-shell and post-receive hooks. -/// Authenticated by the shared `internal_token` (X-Hiy-Token header). +/// Git-related routes: +/// - Internal routes (X-Hiy-Token) used by hiy-git-shell and post-receive hooks. +/// - HTTP Smart Protocol routes for `git push http://...` with API key auth. use axum::{ + body::Body, extract::{Path, Query, State}, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, Response, StatusCode}, response::IntoResponse, Json, }; +use base64::Engine as _; use serde::{Deserialize, Serialize}; use serde_json::json; +use tokio::io::AsyncWriteExt as _; -use crate::{builder, AppState}; +use crate::{builder, routes::api_keys, AppState}; -// ── Token check ─────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Internal token check (for post-receive hooks and git-shell) +// ───────────────────────────────────────────────────────────────────────────── fn check_token(state: &AppState, headers: &HeaderMap) -> bool { headers @@ -21,7 +27,230 @@ fn check_token(state: &AppState, headers: &HeaderMap) -> bool { .unwrap_or(false) } -// ── GET /internal/git/auth — can this user push to this app? ───────────────── +// ───────────────────────────────────────────────────────────────────────────── +// HTTP Basic Auth (for git push over HTTP) +// ───────────────────────────────────────────────────────────────────────────── + +/// Extracts and verifies an API key from the `Authorization: Basic` header. +/// git sends Basic Auth where the password field is the API key. +/// Returns the user_id on success. +async fn http_authenticate(s: &AppState, headers: &HeaderMap) -> Option { + let value = headers.get("authorization")?.to_str().ok()?; + let encoded = value.strip_prefix("Basic ")?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + let credentials = std::str::from_utf8(&decoded).ok()?; + // credentials is "username:password" — the password IS the API key. + let api_key = credentials.splitn(2, ':').nth(1)?; + api_keys::verify_key(s, api_key).await +} + +fn unauthorized() -> Response { + Response::builder() + .status(401) + .header("WWW-Authenticate", r#"Basic realm="HIY""#) + .header("Content-Type", "text/plain") + .body(Body::from("Authentication required")) + .unwrap() +} + +fn forbidden() -> Response { + Response::builder() + .status(403) + .body(Body::from("Forbidden")) + .unwrap() +} + +/// Resolves an app name/id and checks whether the user may push to it. +/// Returns the app_id on success. +async fn check_push_access(s: &AppState, user_id: &str, app: &str) -> Option { + let app_id: String = + sqlx::query_scalar("SELECT id FROM apps WHERE id = ? OR name = ?") + .bind(app) + .bind(app) + .fetch_optional(&s.db) + .await + .unwrap_or(None)?; + + let is_admin: i64 = sqlx::query_scalar("SELECT is_admin FROM users WHERE id = ?") + .bind(user_id) + .fetch_optional(&s.db) + .await + .unwrap_or(None) + .unwrap_or(0); + + if is_admin != 0 { + return Some(app_id); + } + + let granted: Option = + sqlx::query_scalar("SELECT 1 FROM user_apps WHERE user_id = ? AND app_id = ?") + .bind(user_id) + .bind(&app_id) + .fetch_optional(&s.db) + .await + .unwrap_or(None); + + granted.map(|_| app_id) +} + +// ───────────────────────────────────────────────────────────────────────────── +// HTTP Smart Protocol — push only +// ───────────────────────────────────────────────────────────────────────────── +// +// git push http://myserver/git/myapp main +// +// 1. GET /git/:app/info/refs?service=git-receive-pack +// → advertise what refs the server has +// 2. POST /git/:app/git-receive-pack +// → receive the pack, run hooks (post-receive queues the build) + +/// pkt-line service announcement prepended to the info/refs response. +/// "# service=git-receive-pack\n" = 27 bytes → pkt-line length = 31 = 0x001f +const PKT_SERVICE_HEADER: &[u8] = b"001f# service=git-receive-pack\n0000"; + +#[derive(Deserialize)] +pub struct InfoRefsParams { + service: Option, +} + +pub async fn http_info_refs( + State(s): State, + Path(app_name): Path, + Query(params): Query, + headers: HeaderMap, +) -> Response { + // Authenticate. + let user_id = match http_authenticate(&s, &headers).await { + Some(id) => id, + None => return unauthorized(), + }; + + // Only push (receive-pack) is supported. + if params.service.as_deref() != Some("git-receive-pack") { + return Response::builder() + .status(403) + .body(Body::from("Only git push is supported")) + .unwrap(); + } + + let app_id = match check_push_access(&s, &user_id, &app_name).await { + Some(id) => id, + None => return forbidden(), + }; + + let repo_path = format!("{}/repos/{}.git", s.data_dir, app_id); + + let output = match tokio::process::Command::new("git") + .args(["receive-pack", "--stateless-rpc", "--advertise-refs", &repo_path]) + .output() + .await + { + Ok(o) => o, + Err(e) => { + tracing::error!("git receive-pack advertise-refs: {}", e); + return Response::builder() + .status(500) + .body(Body::from("git error")) + .unwrap(); + } + }; + + if !output.status.success() { + tracing::error!( + "git receive-pack advertise-refs failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + return Response::builder() + .status(500) + .body(Body::from("git error")) + .unwrap(); + } + + let mut body = PKT_SERVICE_HEADER.to_vec(); + body.extend_from_slice(&output.stdout); + + Response::builder() + .status(200) + .header("Content-Type", "application/x-git-receive-pack-advertisement") + .header("Cache-Control", "no-cache, max-age=0, must-revalidate") + .body(Body::from(body)) + .unwrap() +} + +pub async fn http_receive_pack( + State(s): State, + Path(app_name): Path, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Response { + // Authenticate. + let user_id = match http_authenticate(&s, &headers).await { + Some(id) => id, + None => return unauthorized(), + }; + + let app_id = match check_push_access(&s, &user_id, &app_name).await { + Some(id) => id, + None => return forbidden(), + }; + + let repo_path = format!("{}/repos/{}.git", s.data_dir, app_id); + + let mut child = match tokio::process::Command::new("git") + .args(["receive-pack", "--stateless-rpc", &repo_path]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + tracing::error!("spawn git receive-pack: {}", e); + return Response::builder() + .status(500) + .body(Body::from("git error")) + .unwrap(); + } + }; + + // Write the request body to git's stdin. + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(&body).await; + } + + let output = match child.wait_with_output().await { + Ok(o) => o, + Err(e) => { + tracing::error!("wait git receive-pack: {}", e); + return Response::builder() + .status(500) + .body(Body::from("git error")) + .unwrap(); + } + }; + + // The post-receive hook queues the build automatically. + // Log any stderr for debugging. + if !output.stderr.is_empty() { + tracing::debug!( + "git receive-pack stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Response::builder() + .status(200) + .header("Content-Type", "application/x-git-receive-pack-result") + .header("Cache-Control", "no-cache, max-age=0, must-revalidate") + .body(Body::from(output.stdout)) + .unwrap() +} + +// ───────────────────────────────────────────────────────────────────────────── +// Internal routes (called by post-receive hook and hiy-git-shell) +// ───────────────────────────────────────────────────────────────────────────── #[derive(Deserialize)] pub struct AuthParams { @@ -43,50 +272,14 @@ pub async fn auth_check( return StatusCode::UNAUTHORIZED.into_response(); } - // Resolve app by name (the app ID is the lowercased name slug). - let app_id: Option = - sqlx::query_scalar("SELECT id FROM apps WHERE id = ? OR name = ?") - .bind(¶ms.app) - .bind(¶ms.app) - .fetch_optional(&s.db) - .await - .unwrap_or(None); - - let app_id = match app_id { + let app_id = match check_push_access(&s, ¶ms.user_id, ¶ms.app).await { Some(id) => id, - None => return StatusCode::NOT_FOUND.into_response(), + None => return StatusCode::FORBIDDEN.into_response(), }; - // Admins can push to any app. - let is_admin: i64 = sqlx::query_scalar("SELECT is_admin FROM users WHERE id = ?") - .bind(¶ms.user_id) - .fetch_optional(&s.db) - .await - .unwrap_or(None) - .unwrap_or(0); - - if is_admin != 0 { - return Json(AuthResponse { app_id }).into_response(); - } - - // Non-admin: check user_apps grant. - let granted: Option = - sqlx::query_scalar("SELECT 1 FROM user_apps WHERE user_id = ? AND app_id = ?") - .bind(¶ms.user_id) - .bind(&app_id) - .fetch_optional(&s.db) - .await - .unwrap_or(None); - - if granted.is_some() { - Json(AuthResponse { app_id }).into_response() - } else { - StatusCode::FORBIDDEN.into_response() - } + Json(AuthResponse { app_id }).into_response() } -// ── POST /internal/git/:app_id/push — post-receive hook triggers a build ───── - #[derive(Deserialize)] pub struct PushBody { pub sha: Option, diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index b1787a3..e4978f3 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -1,3 +1,4 @@ +pub mod api_keys; pub mod apps; pub mod deploys; pub mod envvars; diff --git a/server/src/routes/ui.rs b/server/src/routes/ui.rs index 35ed4e7..2cc932d 100644 --- a/server/src/routes/ui.rs +++ b/server/src/routes/ui.rs @@ -699,9 +699,32 @@ pub async fn users_page(State(state): State) -> impl IntoResponse { style="flex:1;font-size:0.82rem;padding:4px 8px"> +
+ API keys (git push over HTTP): + loading… +
+
+ + +
+ `).join(''); - // Load SSH keys for each user asynchronously. - for (const u of users) loadSshKeys(u.id); + document.querySelectorAll('.domain-hint').forEach(el => el.textContent = location.host); + // Load SSH and API keys for each user asynchronously. + for (const u of users) {{ loadSshKeys(u.id); loadApiKeys(u.id); }} }} async function addUser() {{ @@ -764,6 +787,47 @@ pub async fn users_page(State(state): State) -> impl IntoResponse { load(); }} + async function loadApiKeys(userId) {{ + const res = await fetch('/api/users/' + userId + '/api-keys'); + const keys = await res.json(); + const el = document.getElementById('api-keys-' + userId); + if (!el) return; + if (!keys.length) {{ + el.innerHTML = 'API keys (git push over HTTP): none'; + }} else {{ + el.innerHTML = 'API keys (git push over HTTP): ' + + keys.map(k => ` + + ${{k.label}} + + `).join(''); + }} + }} + + async function generateApiKey(userId) {{ + const label = document.getElementById('apikey-label-' + userId).value.trim(); + if (!label) {{ alert('Enter a label first.'); return; }} + const res = await fetch('/api/users/' + userId + '/api-keys', {{ + method: 'POST', + headers: {{'Content-Type': 'application/json'}}, + body: JSON.stringify({{label}}) + }}); + if (!res.ok) return; + const data = await res.json(); + document.getElementById('apikey-label-' + userId).value = ''; + document.getElementById('apikey-value-' + userId).textContent = data.key; + document.getElementById('apikey-hint-' + userId).textContent = data.key; + document.getElementById('apikey-reveal-' + userId).style.display = ''; + loadApiKeys(userId); + }} + + async function revokeApiKey(keyId, userId) {{ + await fetch('/api/api-keys/' + keyId, {{method: 'DELETE'}}); + document.getElementById('apikey-reveal-' + userId).style.display = 'none'; + loadApiKeys(userId); + }} + async function loadSshKeys(userId) {{ const res = await fetch('/api/users/' + userId + '/ssh-keys'); const keys = await res.json();