feat: HTTP git push with API key auth

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
This commit is contained in:
Claude 2026-03-23 12:59:02 +00:00
parent cb0795617f
commit 0c995f9a0a
No known key found for this signature in database
8 changed files with 446 additions and 47 deletions

View file

@ -26,3 +26,4 @@ async-stream = "0.3"
bcrypt = "0.15" bcrypt = "0.15"
anyhow = "1" anyhow = "1"
futures = "0.3" futures = "0.3"
base64 = "0.22"

View file

@ -89,5 +89,17 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
.execute(pool) .execute(pool)
.await?; .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(()) Ok(())
} }

View file

@ -167,6 +167,9 @@ async fn main() -> anyhow::Result<()> {
// SSH key management (admin only) // SSH key management (admin only)
.route("/api/users/:id/ssh-keys", get(routes::ssh_keys::list).post(routes::ssh_keys::add)) .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)) .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)); .route_layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware));
// ── Public routes ───────────────────────────────────────────────────────── // ── Public routes ─────────────────────────────────────────────────────────
@ -178,6 +181,9 @@ async fn main() -> anyhow::Result<()> {
.route("/auth/verify", get(auth::verify)) .route("/auth/verify", get(auth::verify))
// GitHub webhooks use HMAC-SHA256 — no session needed. // GitHub webhooks use HMAC-SHA256 — no session needed.
.route("/webhook/:app_id", post(routes::webhooks::github)) .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. // Internal routes: called by hiy-git-shell and post-receive hooks.
.route("/internal/git/auth", get(routes::git::auth_check)) .route("/internal/git/auth", get(routes::git::auth_check))
.route("/internal/git/:app_id/push", post(routes::git::push_trigger)); .route("/internal/git/:app_id/push", post(routes::git::push_trigger));

View file

@ -89,3 +89,17 @@ pub struct CreateSshKey {
pub label: String, pub label: String,
pub public_key: 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,
}

View file

@ -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<AppState>,
Path(user_id): Path<String>,
) -> impl IntoResponse {
let keys: Vec<ApiKey> =
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<AppState>,
Path(user_id): Path<String>,
Json(body): Json<CreateApiKey>,
) -> 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<u8> = (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<AppState>,
Path(key_id): Path<String>,
) -> 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<String> {
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]
}

View file

@ -1,17 +1,23 @@
/// Internal routes called by hiy-git-shell and post-receive hooks. /// Git-related routes:
/// Authenticated by the shared `internal_token` (X-Hiy-Token header). /// - 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::{ use axum::{
body::Body,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, Response, StatusCode},
response::IntoResponse, response::IntoResponse,
Json, Json,
}; };
use base64::Engine as _;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; 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 { fn check_token(state: &AppState, headers: &HeaderMap) -> bool {
headers headers
@ -21,7 +27,230 @@ fn check_token(state: &AppState, headers: &HeaderMap) -> bool {
.unwrap_or(false) .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<String> {
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<Body> {
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<Body> {
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<String> {
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<i64> =
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<String>,
}
pub async fn http_info_refs(
State(s): State<AppState>,
Path(app_name): Path<String>,
Query(params): Query<InfoRefsParams>,
headers: HeaderMap,
) -> Response<Body> {
// 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<AppState>,
Path(app_name): Path<String>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Response<Body> {
// 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)] #[derive(Deserialize)]
pub struct AuthParams { pub struct AuthParams {
@ -43,50 +272,14 @@ pub async fn auth_check(
return StatusCode::UNAUTHORIZED.into_response(); return StatusCode::UNAUTHORIZED.into_response();
} }
// Resolve app by name (the app ID is the lowercased name slug). let app_id = match check_push_access(&s, &params.user_id, &params.app).await {
let app_id: Option<String> =
sqlx::query_scalar("SELECT id FROM apps WHERE id = ? OR name = ?")
.bind(&params.app)
.bind(&params.app)
.fetch_optional(&s.db)
.await
.unwrap_or(None);
let app_id = match app_id {
Some(id) => id, Some(id) => id,
None => return StatusCode::NOT_FOUND.into_response(), None => return StatusCode::FORBIDDEN.into_response(),
}; };
// Admins can push to any app. Json(AuthResponse { app_id }).into_response()
let is_admin: i64 = sqlx::query_scalar("SELECT is_admin FROM users WHERE id = ?")
.bind(&params.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<i64> =
sqlx::query_scalar("SELECT 1 FROM user_apps WHERE user_id = ? AND app_id = ?")
.bind(&params.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()
}
} }
// ── POST /internal/git/:app_id/push — post-receive hook triggers a build ─────
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PushBody { pub struct PushBody {
pub sha: Option<String>, pub sha: Option<String>,

View file

@ -1,3 +1,4 @@
pub mod api_keys;
pub mod apps; pub mod apps;
pub mod deploys; pub mod deploys;
pub mod envvars; pub mod envvars;

View file

@ -699,9 +699,32 @@ pub async fn users_page(State(state): State<AppState>) -> impl IntoResponse {
style="flex:1;font-size:0.82rem;padding:4px 8px"> style="flex:1;font-size:0.82rem;padding:4px 8px">
<button onclick="addSshKey('${{u.id}}')" style="font-size:0.82rem">Add key</button> <button onclick="addSshKey('${{u.id}}')" style="font-size:0.82rem">Add key</button>
</div> </div>
<div id="api-keys-${{u.id}}" style="margin-top:12px">
<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span>
<span class="muted" style="font-size:0.82rem"> loading</span>
</div>
<div class="row" style="margin-top:8px;gap:6px">
<input type="text" id="apikey-label-${{u.id}}" placeholder="label (e.g. laptop)"
style="max-width:140px;font-size:0.82rem;padding:4px 8px">
<button onclick="generateApiKey('${{u.id}}')" style="font-size:0.82rem">Generate key</button>
</div>
<div id="apikey-reveal-${{u.id}}" style="display:none;margin-top:8px;padding:10px;
background:#0f172a;border:1px solid #334155;border-radius:6px">
<div style="font-size:0.78rem;color:#94a3b8;margin-bottom:4px">
Copy this key now it will not be shown again.
</div>
<code id="apikey-value-${{u.id}}" style="font-size:0.82rem;color:#a3e635;word-break:break-all"></code>
<br><br>
<div style="font-size:0.78rem;color:#94a3b8">Use it to push:</div>
<code style="font-size:0.78rem;color:#e2e8f0">
git remote add hiy http://hiy:<span id="apikey-hint-${{u.id}}"></span>@<span class="domain-hint"></span>/git/YOUR_APP<br>
git push hiy main
</code>
</div>
</div>`).join(''); </div>`).join('');
// Load SSH keys for each user asynchronously. document.querySelectorAll('.domain-hint').forEach(el => el.textContent = location.host);
for (const u of users) loadSshKeys(u.id); // Load SSH and API keys for each user asynchronously.
for (const u of users) {{ loadSshKeys(u.id); loadApiKeys(u.id); }}
}} }}
async function addUser() {{ async function addUser() {{
@ -764,6 +787,47 @@ pub async fn users_page(State(state): State<AppState>) -> impl IntoResponse {
load(); 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 = '<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span> <span class="muted" style="font-size:0.82rem">none</span>';
}} else {{
el.innerHTML = '<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span> ' +
keys.map(k => `
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
${{k.label}}
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px"
onclick="revokeApiKey('${{k.id}}','${{userId}}')"></a>
</span>`).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) {{ async function loadSshKeys(userId) {{
const res = await fetch('/api/users/' + userId + '/ssh-keys'); const res = await fetch('/api/users/' + userId + '/ssh-keys');
const keys = await res.json(); const keys = await res.json();