feat: private repo support via encrypted git token

- db.rs: add nullable git_token column (idempotent ALTER TABLE ADD COLUMN)
- models.rs: git_token on App (#[serde(skip_serializing)]), CreateApp, UpdateApp
- routes/apps.rs: encrypt token on create/update; empty string clears it
- builder.rs: decrypt token, pass as GIT_TOKEN env var to build script
- build.sh: GIT_TERMINAL_PROMPT=0 (fail fast, not hang); when GIT_TOKEN is
  set, inject it into the HTTPS clone URL as x-token-auth; strip credentials
  from .git/config after clone/fetch so the token is never persisted to disk

Token usage: PATCH /api/apps/:id with {"git_token": "ghp_..."}
Clear token:  PATCH /api/apps/:id with {"git_token": ""}

https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
This commit is contained in:
Claude 2026-03-26 08:24:55 +00:00
parent 73ea7320fd
commit 0b3cbf8734
No known key found for this signature in database
5 changed files with 65 additions and 6 deletions

View file

@ -5,6 +5,9 @@
# MEMORY_LIMIT (e.g. "512m"), CPU_LIMIT (e.g. "0.5")
set -euo pipefail
# Never prompt for git credentials — fail immediately if auth is missing.
export GIT_TERMINAL_PROMPT=0
# Defaults — overridden by per-app settings stored in the control plane.
MEMORY_LIMIT="${MEMORY_LIMIT:-512m}"
CPU_LIMIT="${CPU_LIMIT:-0.5}"
@ -18,17 +21,33 @@ log "Branch: $BRANCH"
log "Build dir: $BUILD_DIR"
# ── 1. Clone or pull ───────────────────────────────────────────────────────────
# Build an authenticated URL when a git token is set (private repos).
# GIT_TOKEN is passed by hiy-server and never echoed here.
CLONE_URL="$REPO_URL"
if [ -n "${GIT_TOKEN:-}" ]; then
case "$REPO_URL" in
https://*)
CLONE_URL="https://x-token-auth:${GIT_TOKEN}@${REPO_URL#https://}"
;;
esac
fi
mkdir -p "$BUILD_DIR"
cd "$BUILD_DIR"
if [ -d ".git" ]; then
log "Updating existing clone…"
git remote set-url origin "$CLONE_URL"
git fetch origin "$BRANCH" --depth=50
git checkout "$BRANCH"
git reset --hard "origin/$BRANCH"
# Strip credentials from the stored remote so they don't sit in .git/config.
git remote set-url origin "$REPO_URL"
else
log "Cloning repository…"
git clone --depth=50 --branch "$BRANCH" "$REPO_URL" .
git clone --depth=50 --branch "$BRANCH" "$CLONE_URL" .
# Strip credentials from the stored remote so they don't sit in .git/config.
git remote set-url origin "$REPO_URL"
fi
ACTUAL_SHA=$(git rev-parse HEAD)

View file

@ -133,11 +133,22 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> {
let domain_suffix = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into());
let caddy_api_url = std::env::var("CADDY_API_URL").unwrap_or_else(|_| "http://localhost:2019".into());
let mut child = Command::new("bash")
.arg(&build_script)
let mut cmd = Command::new("bash");
cmd.arg(&build_script)
.env("APP_ID", &app.id)
.env("APP_NAME", &app.name)
.env("REPO_URL", &repo_url)
.env("REPO_URL", &repo_url);
// Decrypt the git token (if any) and pass it separately so build.sh can
// inject it into the clone URL without it appearing in REPO_URL or logs.
if let Some(enc) = &app.git_token {
match crate::crypto::decrypt(enc) {
Ok(tok) => { cmd.env("GIT_TOKEN", tok); }
Err(e) => tracing::warn!("Could not decrypt git_token for {}: {}", app.id, e),
}
}
let mut child = cmd
.env("BRANCH", &app.branch)
.env("PORT", app.port.to_string())
.env("ENV_FILE", &env_file)

View file

@ -106,6 +106,8 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
.execute(pool).await;
let _ = sqlx::query("ALTER TABLE apps ADD COLUMN cpu_limit TEXT NOT NULL DEFAULT '0.5'")
.execute(pool).await;
let _ = sqlx::query("ALTER TABLE apps ADD COLUMN git_token TEXT")
.execute(pool).await;
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS databases (

View file

@ -12,6 +12,9 @@ pub struct App {
pub cpu_limit: String,
pub created_at: String,
pub updated_at: String,
/// Encrypted git token for cloning private repos. Never serialised to API responses.
#[serde(skip_serializing)]
pub git_token: Option<String>,
}
#[derive(Debug, Deserialize)]
@ -22,6 +25,7 @@ pub struct CreateApp {
pub port: i64,
pub memory_limit: Option<String>,
pub cpu_limit: Option<String>,
pub git_token: Option<String>,
}
#[derive(Debug, Deserialize)]
@ -31,6 +35,7 @@ pub struct UpdateApp {
pub port: Option<i64>,
pub memory_limit: Option<String>,
pub cpu_limit: Option<String>,
pub git_token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]

View file

@ -31,10 +31,16 @@ pub async fn create(
let secret = Uuid::new_v4().to_string().replace('-', "");
let memory_limit = payload.memory_limit.unwrap_or_else(|| "512m".into());
let cpu_limit = payload.cpu_limit.unwrap_or_else(|| "0.5".into());
let git_token_enc = payload.git_token
.as_deref()
.filter(|t| !t.is_empty())
.map(crate::crypto::encrypt)
.transpose()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
sqlx::query(
"INSERT INTO apps (id, name, repo_url, branch, port, webhook_secret, memory_limit, cpu_limit, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO apps (id, name, repo_url, branch, port, webhook_secret, memory_limit, cpu_limit, git_token, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&payload.name)
@ -44,6 +50,7 @@ pub async fn create(
.bind(&secret)
.bind(&memory_limit)
.bind(&cpu_limit)
.bind(&git_token_enc)
.bind(&now)
.bind(&now)
.execute(&s.db)
@ -105,6 +112,21 @@ pub async fn update(
.execute(&s.db).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
if let Some(v) = payload.git_token {
if v.is_empty() {
sqlx::query("UPDATE apps SET git_token = NULL, updated_at = ? WHERE id = ?")
.bind(&now).bind(&id)
.execute(&s.db).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
} else {
let enc = crate::crypto::encrypt(&v)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
sqlx::query("UPDATE apps SET git_token = ?, updated_at = ? WHERE id = ?")
.bind(enc).bind(&now).bind(&id)
.execute(&s.db).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
}
fetch_app(&s, &id).await.map(Json)
}