feat: git push deploy (roadmap step 2)

Full self-contained git push flow — no GitHub required:

  git remote add hiy ssh://hiy@myserver/myapp
  git push hiy main

What was added:

- Bare git repo per app (HIY_DATA_DIR/repos/<app-id>.git)
  Initialised automatically on app create; removed on app delete.
  post-receive hook is written into each repo and calls the internal
  API to queue a build using the same pipeline as webhook deploys.

- SSH key management
  New ssh_keys DB table. Admin UI (/admin/users) now shows SSH keys
  per user with add/remove. New API routes:
    GET/POST /api/users/:id/ssh-keys
    DELETE   /api/ssh-keys/:key_id
  On every change, HIY rewrites HIY_SSH_AUTHORIZED_KEYS with
  command= restricted entries pointing at hiy-git-shell.

- scripts/git-shell
  SSH command= override installed at HIY_GIT_SHELL (default
  /usr/local/bin/hiy-git-shell). Validates the push via
  GET /internal/git/auth, then exec's git-receive-pack on the
  correct bare repo.

- Internal API routes (authenticated by shared internal_token)
    GET  /internal/git/auth          -- git-shell permission check
    POST /internal/git/:app_id/push  -- post-receive build trigger

- Builder: git-push deploys use file:// path to the local bare repo
  instead of the app's remote repo_url.

- internal_token persists across restarts in HIY_DATA_DIR/internal-token.

New env vars:
  HIY_SSH_AUTHORIZED_KEYS  path to the authorized_keys file to manage
  HIY_GIT_SHELL            path to the git-shell script on the host

Both webhook and git-push deploys feed the same build queue.

https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
This commit is contained in:
Claude 2026-03-23 08:54:55 +00:00
parent 1671aaf8e8
commit cb0795617f
No known key found for this signature in database
10 changed files with 517 additions and 2 deletions

67
scripts/git-shell Executable file
View file

@ -0,0 +1,67 @@
#!/usr/bin/env bash
# HIY git-shell — SSH authorized_keys command= override
#
# Install at: /usr/local/bin/hiy-git-shell (or set HIY_GIT_SHELL in .env)
# Each authorized_keys entry is written by HIY in this form:
#
# command="/usr/local/bin/hiy-git-shell <user-id> <api-url> <token> <repos-dir>",
# no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty <public-key>
#
# OpenSSH sets SSH_ORIGINAL_COMMAND to what the developer actually ran, e.g.:
# git-receive-pack '/myapp'
# This script validates the push and exec's git-receive-pack on the bare repo.
set -euo pipefail
USER_ID="${1:-}"
API_URL="${2:-http://localhost:3000}"
TOKEN="${3:-}"
REPOS_DIR="${4:-/data/repos}"
if [ -z "$USER_ID" ] || [ -z "$TOKEN" ]; then
echo "hiy: internal configuration error — contact your administrator." >&2
exit 1
fi
ORIG="${SSH_ORIGINAL_COMMAND:-}"
# Only git-receive-pack (push) is supported; reject everything else.
if [[ "$ORIG" != git-receive-pack* ]]; then
echo "hiy: only 'git push' is supported on this host." >&2
exit 1
fi
# Parse the app name from: git-receive-pack '/myapp' or git-receive-pack 'myapp'
APP_NAME=$(echo "$ORIG" | sed "s/git-receive-pack '\\///;s/git-receive-pack '//;s/'.*//")
APP_NAME="${APP_NAME##/}" # strip leading slash if still present
APP_NAME="${APP_NAME%/}" # strip trailing slash
if [ -z "$APP_NAME" ]; then
echo "hiy: could not parse app name from: $ORIG" >&2
exit 1
fi
# Ask HIY whether this user may push to this app.
RESPONSE=$(curl -sf \
-H "X-Hiy-Token: $TOKEN" \
"${API_URL}/internal/git/auth?user_id=${USER_ID}&app=${APP_NAME}" \
2>/dev/null) || {
echo "hiy: push denied (cannot reach server or access denied for '${APP_NAME}')." >&2
exit 1
}
APP_ID=$(echo "$RESPONSE" | python3 -c \
"import sys, json; print(json.load(sys.stdin)['app_id'])" 2>/dev/null)
if [ -z "$APP_ID" ]; then
echo "hiy: push denied for '${APP_NAME}'." >&2
exit 1
fi
REPO_PATH="${REPOS_DIR}/${APP_ID}.git"
if [ ! -d "$REPO_PATH" ]; then
echo "hiy: repository not found for app '${APP_NAME}' (expected ${REPO_PATH})." >&2
exit 1
fi
exec git-receive-pack "$REPO_PATH"

View file

@ -98,6 +98,13 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> {
let build_script = std::env::var("HIY_BUILD_SCRIPT")
.unwrap_or_else(|_| "./builder/build.sh".into());
// For git-push deploys, use the local bare repo instead of the remote URL.
let repo_url = if deploy.triggered_by == "git-push" {
format!("file://{}/repos/{}.git", state.data_dir, app.id)
} else {
app.repo_url.clone()
};
let build_dir = format!("{}/builds/{}", state.data_dir, app.id);
// Log diagnostics before spawning so even a spawn failure leaves a breadcrumb.
@ -125,7 +132,7 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> {
.arg(&build_script)
.env("APP_ID", &app.id)
.env("APP_NAME", &app.name)
.env("REPO_URL", &app.repo_url)
.env("REPO_URL", &repo_url)
.env("BRANCH", &app.branch)
.env("PORT", app.port.to_string())
.env("ENV_FILE", &env_file)

View file

@ -77,5 +77,17 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
.execute(pool)
.await?;
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS ssh_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label TEXT NOT NULL,
public_key TEXT NOT NULL,
created_at TEXT NOT NULL
)"#,
)
.execute(pool)
.await?;
Ok(())
}

View file

@ -31,6 +31,13 @@ pub struct AppState {
pub admin_pass: Option<String>,
/// e.g. "yourdomain.com" or "localhost" — used for cookie Domain + redirect URLs.
pub domain_suffix: String,
/// Shared secret used by the git-shell and post-receive hook to call internal API routes.
pub internal_token: String,
/// Path to the SSH authorized_keys file managed by HIY (e.g. /home/hiy/.ssh/authorized_keys).
/// Empty string means SSH key management is disabled.
pub ssh_authorized_keys_file: String,
/// Path to the hiy-git-shell binary on the host (embedded in authorized_keys command= lines).
pub git_shell_path: String,
}
#[tokio::main]
@ -97,6 +104,21 @@ async fn main() -> anyhow::Result<()> {
let build_queue = Arc::new(Mutex::new(VecDeque::<String>::new()));
// Internal token: load from file (persists across restarts) or generate fresh.
let token_file = format!("{}/internal-token", data_dir);
let internal_token = if let Ok(t) = std::fs::read_to_string(&token_file) {
t.trim().to_string()
} else {
let t = uuid::Uuid::new_v4().to_string();
let _ = std::fs::write(&token_file, &t);
t
};
let ssh_authorized_keys_file = std::env::var("HIY_SSH_AUTHORIZED_KEYS")
.unwrap_or_default();
let git_shell_path = std::env::var("HIY_GIT_SHELL")
.unwrap_or_else(|_| "/usr/local/bin/hiy-git-shell".into());
let state = AppState {
db,
build_queue,
@ -106,6 +128,9 @@ async fn main() -> anyhow::Result<()> {
admin_user,
admin_pass,
domain_suffix,
internal_token,
ssh_authorized_keys_file,
git_shell_path,
};
// Single background worker — sequential builds to avoid saturating the Pi.
@ -139,6 +164,9 @@ async fn main() -> anyhow::Result<()> {
.route("/api/users", get(routes::users::list).post(routes::users::create))
.route("/api/users/:id", put(routes::users::update).delete(routes::users::delete))
.route("/api/users/:id/apps/:app_id", post(routes::users::grant_app).delete(routes::users::revoke_app))
// 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))
.route_layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware));
// ── Public routes ─────────────────────────────────────────────────────────
@ -149,7 +177,10 @@ async fn main() -> anyhow::Result<()> {
// Called by Caddy forward_auth for every deployed-app request.
.route("/auth/verify", get(auth::verify))
// GitHub webhooks use HMAC-SHA256 — no session needed.
.route("/webhook/:app_id", post(routes::webhooks::github));
.route("/webhook/:app_id", post(routes::webhooks::github))
// 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));
let app = Router::new()
.merge(protected)

View file

@ -74,3 +74,18 @@ pub struct UpdateUser {
pub password: Option<String>,
pub is_admin: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SshKey {
pub id: String,
pub user_id: String,
pub label: String,
pub public_key: String,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateSshKey {
pub label: String,
pub public_key: String,
}

View file

@ -4,6 +4,7 @@ use axum::{
Json,
};
use chrono::Utc;
use std::os::unix::fs::PermissionsExt;
use uuid::Uuid;
use crate::{
@ -48,6 +49,11 @@ pub async fn create(
StatusCode::UNPROCESSABLE_ENTITY
})?;
// Initialise a bare git repo for git-push deploys.
if let Err(e) = init_bare_repo(&s, &id).await {
tracing::warn!("Failed to init bare repo for {}: {}", id, e);
}
fetch_app(&s, &id).await.map(|a| (StatusCode::CREATED, Json(a)))
}
@ -100,6 +106,13 @@ pub async fn delete(
if res.rows_affected() == 0 {
return Err(StatusCode::NOT_FOUND);
}
// Clean up the bare repo (best-effort).
let repo_path = format!("{}/repos/{}.git", s.data_dir, id);
if let Err(e) = std::fs::remove_dir_all(&repo_path) {
tracing::warn!("Failed to remove bare repo {}: {}", repo_path, e);
}
Ok(StatusCode::NO_CONTENT)
}
@ -147,3 +160,52 @@ async fn fetch_app(s: &AppState, id: &str) -> Result<App, StatusCode> {
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)
}
/// Initialise a bare git repo and write the post-receive hook for git-push deploys.
async fn init_bare_repo(s: &AppState, app_id: &str) -> anyhow::Result<()> {
let repos_dir = format!("{}/repos", s.data_dir);
std::fs::create_dir_all(&repos_dir)?;
let repo_path = format!("{}/{}.git", repos_dir, app_id);
let out = tokio::process::Command::new("git")
.args(["init", "--bare", &repo_path])
.output()
.await?;
if !out.status.success() {
anyhow::bail!(
"git init --bare failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
// Write the post-receive hook that calls HIY's internal push endpoint.
let hook_path = format!("{}/hooks/post-receive", repo_path);
let hook = format!(
r#"#!/usr/bin/env bash
# HIY post-receive hook queues a build after git push.
set -euo pipefail
HIY_API_URL="http://localhost:3000"
HIY_INTERNAL_TOKEN="{token}"
APP_ID="{app_id}"
while read OLD_SHA NEW_SHA REF; do
BRANCH="${{REF##refs/heads/}}"
curl -sf -X POST \
-H "X-Hiy-Token: $HIY_INTERNAL_TOKEN" \
-H "Content-Type: application/json" \
-d "{{\"sha\":\"$NEW_SHA\",\"branch\":\"$BRANCH\"}}" \
"$HIY_API_URL/internal/git/$APP_ID/push" || true
done
echo "[hiy] Build queued. Watch progress in the dashboard."
"#,
token = s.internal_token,
app_id = app_id,
);
std::fs::write(&hook_path, hook)?;
std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
tracing::info!("Initialised bare repo at {}", repo_path);
Ok(())
}

115
server/src/routes/git.rs Normal file
View file

@ -0,0 +1,115 @@
/// Internal routes called by hiy-git-shell and post-receive hooks.
/// Authenticated by the shared `internal_token` (X-Hiy-Token header).
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{builder, AppState};
// ── Token check ───────────────────────────────────────────────────────────────
fn check_token(state: &AppState, headers: &HeaderMap) -> bool {
headers
.get("x-hiy-token")
.and_then(|v| v.to_str().ok())
.map(|t| t == state.internal_token)
.unwrap_or(false)
}
// ── GET /internal/git/auth — can this user push to this app? ─────────────────
#[derive(Deserialize)]
pub struct AuthParams {
pub user_id: String,
pub app: String,
}
#[derive(Serialize)]
pub struct AuthResponse {
pub app_id: String,
}
pub async fn auth_check(
State(s): State<AppState>,
headers: HeaderMap,
Query(params): Query<AuthParams>,
) -> impl IntoResponse {
if !check_token(&s, &headers) {
return StatusCode::UNAUTHORIZED.into_response();
}
// Resolve app by name (the app ID is the lowercased name slug).
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,
None => return StatusCode::NOT_FOUND.into_response(),
};
// Admins can push to any app.
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)]
pub struct PushBody {
pub sha: Option<String>,
pub branch: Option<String>,
}
pub async fn push_trigger(
State(s): State<AppState>,
headers: HeaderMap,
Path(app_id): Path<String>,
body: Option<Json<PushBody>>,
) -> impl IntoResponse {
if !check_token(&s, &headers) {
return StatusCode::UNAUTHORIZED.into_response();
}
let sha = body.and_then(|b| b.sha.clone());
match builder::enqueue_deploy(&s, &app_id, "git-push", sha).await {
Ok(deploy) => (StatusCode::OK, Json(json!({"deploy_id": deploy.id}))).into_response(),
Err(e) => {
tracing::error!("git push trigger for {}: {}", app_id, e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response()
}
}
}

View file

@ -1,6 +1,8 @@
pub mod apps;
pub mod deploys;
pub mod envvars;
pub mod git;
pub mod ssh_keys;
pub mod ui;
pub mod users;
pub mod webhooks;

View file

@ -0,0 +1,154 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use chrono::Utc;
use serde_json::json;
use uuid::Uuid;
use crate::{models::{CreateSshKey, SshKey}, AppState};
// ── List SSH keys for a user ──────────────────────────────────────────────────
pub async fn list(
State(s): State<AppState>,
Path(user_id): Path<String>,
) -> impl IntoResponse {
let keys: Vec<SshKey> =
sqlx::query_as("SELECT * FROM ssh_keys WHERE user_id = ? ORDER BY created_at")
.bind(&user_id)
.fetch_all(&s.db)
.await
.unwrap_or_default();
Json(keys)
}
// ── Add SSH key ───────────────────────────────────────────────────────────────
pub async fn add(
State(s): State<AppState>,
Path(user_id): Path<String>,
Json(body): Json<CreateSshKey>,
) -> impl IntoResponse {
let public_key = body.public_key.trim().to_string();
let label = body.label.trim().to_string();
if label.is_empty() || public_key.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "label and public_key required"})),
)
.into_response();
}
// Basic sanity check: must start with a known key type.
let valid_prefix = ["ssh-rsa", "ssh-ed25519", "ssh-ecdsa", "ecdsa-sha2-", "sk-"]
.iter()
.any(|p| public_key.starts_with(p));
if !valid_prefix {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "public_key does not look like an SSH public key"})),
)
.into_response();
}
// Reject keys that would break the authorized_keys command= line.
if public_key.contains('\n') || public_key.contains('"') {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "public_key contains invalid characters"})),
)
.into_response();
}
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let res = sqlx::query(
"INSERT INTO ssh_keys (id, user_id, label, public_key, created_at) VALUES (?,?,?,?,?)",
)
.bind(&id)
.bind(&user_id)
.bind(&label)
.bind(&public_key)
.bind(&now)
.execute(&s.db)
.await;
match res {
Ok(_) => {
if let Err(e) = regenerate_authorized_keys(&s).await {
tracing::warn!("authorized_keys regen failed: {}", e);
}
(StatusCode::CREATED, Json(json!({"id": id}))).into_response()
}
Err(e) => {
tracing::error!("add ssh key: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "db error"}))).into_response()
}
}
}
// ── Remove SSH key ────────────────────────────────────────────────────────────
pub async fn remove(
State(s): State<AppState>,
Path(key_id): Path<String>,
) -> impl IntoResponse {
sqlx::query("DELETE FROM ssh_keys WHERE id = ?")
.bind(&key_id)
.execute(&s.db)
.await
.ok();
if let Err(e) = regenerate_authorized_keys(&s).await {
tracing::warn!("authorized_keys regen failed: {}", e);
}
StatusCode::NO_CONTENT
}
// ── Regenerate authorized_keys ────────────────────────────────────────────────
/// Rewrites the authorized_keys file from all SSH keys in the database.
/// Each entry uses a `command=` override so pushes are routed through hiy-git-shell.
/// No-ops silently if HIY_SSH_AUTHORIZED_KEYS is not configured.
pub async fn regenerate_authorized_keys(s: &AppState) -> anyhow::Result<()> {
if s.ssh_authorized_keys_file.is_empty() {
return Ok(());
}
let keys: Vec<SshKey> = sqlx::query_as("SELECT * FROM ssh_keys ORDER BY user_id, created_at")
.fetch_all(&s.db)
.await?;
let mut lines = Vec::new();
for key in &keys {
// Embed user_id, api url (always localhost:3000), token, and repos dir into the
// command= so hiy-git-shell needs no separate config file.
let api_url = format!("http://localhost:3000");
let repos_dir = format!("{}/repos", s.data_dir);
let cmd = format!(
"command=\"{shell} {uid} {api} {token} {repos}\",\
no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {pk}",
shell = s.git_shell_path,
uid = key.user_id,
api = api_url,
token = s.internal_token,
repos = repos_dir,
pk = key.public_key,
);
lines.push(cmd);
}
let content = lines.join("\n") + if lines.is_empty() { "" } else { "\n" };
// Create parent directory if needed.
if let Some(parent) = std::path::Path::new(&s.ssh_authorized_keys_file).parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&s.ssh_authorized_keys_file, content)?;
tracing::info!("Wrote {} SSH key(s) to {}", lines.len(), s.ssh_authorized_keys_file);
Ok(())
}

View file

@ -688,7 +688,20 @@ pub async fn users_page(State(state): State<AppState>) -> impl IntoResponse {
<button onclick="grantApp('${{u.id}}')" style="margin-left:4px">Grant</button>
</span>` : ''}}
</div>
<div id="ssh-keys-${{u.id}}" style="margin-top:12px">
<span class="muted" style="font-size:0.78rem">SSH keys:</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="key-label-${{u.id}}" placeholder="label (e.g. laptop)"
style="max-width:140px;font-size:0.82rem;padding:4px 8px">
<input type="text" id="key-value-${{u.id}}" placeholder="ssh-ed25519 AAAA…"
style="flex:1;font-size:0.82rem;padding:4px 8px">
<button onclick="addSshKey('${{u.id}}')" style="font-size:0.82rem">Add key</button>
</div>
</div>`).join('');
// Load SSH keys for each user asynchronously.
for (const u of users) loadSshKeys(u.id);
}}
async function addUser() {{
@ -751,6 +764,43 @@ pub async fn users_page(State(state): State<AppState>) -> impl IntoResponse {
load();
}}
async function loadSshKeys(userId) {{
const res = await fetch('/api/users/' + userId + '/ssh-keys');
const keys = await res.json();
const el = document.getElementById('ssh-keys-' + userId);
if (!el) return;
if (!keys.length) {{
el.innerHTML = '<span class="muted" style="font-size:0.78rem">SSH keys:</span> <span class="muted" style="font-size:0.82rem">none</span>';
}} else {{
el.innerHTML = '<span class="muted" style="font-size:0.78rem">SSH keys:</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="removeSshKey('${{k.id}}','${{userId}}')"></a>
</span>`).join('');
}}
}}
async function addSshKey(userId) {{
const label = document.getElementById('key-label-' + userId).value.trim();
const publicKey = document.getElementById('key-value-' + userId).value.trim();
if (!label || !publicKey) return;
await fetch('/api/users/' + userId + '/ssh-keys', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{label, public_key: publicKey}})
}});
document.getElementById('key-label-' + userId).value = '';
document.getElementById('key-value-' + userId).value = '';
loadSshKeys(userId);
}}
async function removeSshKey(keyId, userId) {{
await fetch('/api/ssh-keys/' + keyId, {{method: 'DELETE'}});
loadSshKeys(userId);
}}
load();
</script>"#
);