Apps default to private (require login). Marking an app public bypasses the forward_auth check so anyone can access it without logging in. Changes: - db.rs: is_public INTEGER NOT NULL DEFAULT 0 column (idempotent) - models.rs: is_public: i64 on App; is_public: Option<bool> on UpdateApp - Cargo.toml: add reqwest for Caddy admin API calls from Rust - routes/apps.rs: PATCH is_public → save flag + immediately push updated Caddy route (no redeploy needed); caddy_route() builds correct JSON for both public (plain reverse_proxy) and private (forward_auth) cases - builder.rs: pass IS_PUBLIC env var to build.sh - build.sh: use IS_PUBLIC to select route type on deploy - ui.rs + app_detail.html: private/public badge + toggle button in subtitle https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
345 lines
12 KiB
Rust
345 lines
12 KiB
Rust
use axum::{
|
|
extract::{Path, State},
|
|
http::StatusCode,
|
|
Json,
|
|
};
|
|
use chrono::Utc;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
models::{App, CreateApp, UpdateApp},
|
|
AppState,
|
|
};
|
|
|
|
/// Build the Caddy route JSON for an app.
|
|
/// Public apps get a plain reverse_proxy; private apps get forward_auth via HIY.
|
|
fn caddy_route(app_host: &str, upstream: &str, is_public: bool) -> serde_json::Value {
|
|
if is_public {
|
|
serde_json::json!({
|
|
"match": [{"host": [app_host]}],
|
|
"handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": upstream}]}]
|
|
})
|
|
} else {
|
|
serde_json::json!({
|
|
"match": [{"host": [app_host]}],
|
|
"handle": [{
|
|
"handler": "subroute",
|
|
"routes": [{
|
|
"handle": [{
|
|
"handler": "reverse_proxy",
|
|
"rewrite": {"method": "GET", "uri": "/auth/verify"},
|
|
"headers": {"request": {"set": {
|
|
"X-Forwarded-Method": ["{http.request.method}"],
|
|
"X-Forwarded-Uri": ["{http.request.uri}"],
|
|
"X-Forwarded-Host": ["{http.request.host}"],
|
|
"X-Forwarded-Proto": ["{http.request.scheme}"]
|
|
}}},
|
|
"upstreams": [{"dial": "server:3000"}],
|
|
"handle_response": [{
|
|
"match": {"status_code": [2]},
|
|
"routes": [{"handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": upstream}]}]}]
|
|
}]
|
|
}]
|
|
}]
|
|
}]
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Push a visibility change to Caddy without requiring a full redeploy.
|
|
/// Best-effort: logs a warning on failure but does not surface an error to the caller.
|
|
async fn push_visibility_to_caddy(app_id: &str, port: i64, is_public: bool) {
|
|
if let Err(e) = try_push_visibility_to_caddy(app_id, port, is_public).await {
|
|
tracing::warn!("caddy visibility update for {}: {}", app_id, e);
|
|
}
|
|
}
|
|
|
|
async fn try_push_visibility_to_caddy(app_id: &str, port: i64, is_public: bool) -> anyhow::Result<()> {
|
|
let caddy_api = std::env::var("CADDY_API_URL").unwrap_or_else(|_| "http://caddy:2019".into());
|
|
let domain = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into());
|
|
let app_host = format!("{}.{}", app_id, domain);
|
|
let upstream = format!("hiy-{}:{}", app_id, port);
|
|
let client = reqwest::Client::new();
|
|
|
|
// Discover the Caddy server name (Caddyfile adapter names it "srv0").
|
|
let servers: serde_json::Value = client
|
|
.get(format!("{}/config/apps/http/servers/", caddy_api))
|
|
.send().await?
|
|
.json().await?;
|
|
let server_name = servers.as_object()
|
|
.and_then(|m| m.keys().next().cloned())
|
|
.ok_or_else(|| anyhow::anyhow!("no servers in Caddy config"))?;
|
|
|
|
let routes_url = format!("{}/config/apps/http/servers/{}/routes", caddy_api, server_name);
|
|
|
|
let routes: Vec<serde_json::Value> = client.get(&routes_url).send().await?.json().await?;
|
|
|
|
let dashboard = serde_json::json!({
|
|
"handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "server:3000"}]}]
|
|
});
|
|
|
|
let mut updated: Vec<serde_json::Value> = routes.into_iter()
|
|
.filter(|r| {
|
|
let is_this_app = r.pointer("/match/0/host")
|
|
.and_then(|h| h.as_array())
|
|
.map(|hosts| hosts.iter().any(|h| h.as_str() == Some(app_host.as_str())))
|
|
.unwrap_or(false);
|
|
let is_catchall = r.get("match").is_none();
|
|
!is_this_app && !is_catchall
|
|
})
|
|
.collect();
|
|
|
|
updated.insert(0, caddy_route(&app_host, &upstream, is_public));
|
|
updated.push(dashboard);
|
|
|
|
client.patch(&routes_url).json(&updated).send().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list(State(s): State<AppState>) -> Result<Json<Vec<App>>, StatusCode> {
|
|
let apps = sqlx::query_as::<_, App>("SELECT * FROM apps ORDER BY created_at DESC")
|
|
.fetch_all(&s.db)
|
|
.await
|
|
.map_err(|e| { tracing::error!("list apps: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?;
|
|
Ok(Json(apps))
|
|
}
|
|
|
|
pub async fn create(
|
|
State(s): State<AppState>,
|
|
Json(payload): Json<CreateApp>,
|
|
) -> Result<(StatusCode, Json<App>), StatusCode> {
|
|
// Use the name as the slug/id (must be URL-safe).
|
|
let id = payload.name.to_lowercase().replace(' ', "-");
|
|
let now = Utc::now().to_rfc3339();
|
|
let branch = payload.branch.unwrap_or_else(|| "main".into());
|
|
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, git_token, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(&id)
|
|
.bind(&payload.name)
|
|
.bind(payload.repo_url.unwrap_or_default())
|
|
.bind(&branch)
|
|
.bind(payload.port)
|
|
.bind(&secret)
|
|
.bind(&memory_limit)
|
|
.bind(&cpu_limit)
|
|
.bind(&git_token_enc)
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.execute(&s.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("create app: {}", e);
|
|
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)))
|
|
}
|
|
|
|
pub async fn get_one(
|
|
State(s): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<App>, StatusCode> {
|
|
fetch_app(&s, &id).await.map(Json)
|
|
}
|
|
|
|
pub async fn update(
|
|
State(s): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(payload): Json<UpdateApp>,
|
|
) -> Result<Json<App>, StatusCode> {
|
|
let now = Utc::now().to_rfc3339();
|
|
|
|
if let Some(v) = payload.repo_url {
|
|
sqlx::query("UPDATE apps SET repo_url = ?, updated_at = ? WHERE id = ?")
|
|
.bind(v).bind(&now).bind(&id)
|
|
.execute(&s.db).await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
}
|
|
if let Some(v) = payload.branch {
|
|
sqlx::query("UPDATE apps SET branch = ?, updated_at = ? WHERE id = ?")
|
|
.bind(v).bind(&now).bind(&id)
|
|
.execute(&s.db).await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
}
|
|
if let Some(v) = payload.port {
|
|
sqlx::query("UPDATE apps SET port = ?, updated_at = ? WHERE id = ?")
|
|
.bind(v).bind(&now).bind(&id)
|
|
.execute(&s.db).await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
}
|
|
if let Some(v) = payload.memory_limit {
|
|
sqlx::query("UPDATE apps SET memory_limit = ?, updated_at = ? WHERE id = ?")
|
|
.bind(v).bind(&now).bind(&id)
|
|
.execute(&s.db).await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
}
|
|
if let Some(v) = payload.cpu_limit {
|
|
sqlx::query("UPDATE apps SET cpu_limit = ?, updated_at = ? WHERE id = ?")
|
|
.bind(v).bind(&now).bind(&id)
|
|
.execute(&s.db).await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
}
|
|
if let Some(v) = payload.is_public {
|
|
let flag: i64 = if v { 1 } else { 0 };
|
|
sqlx::query("UPDATE apps SET is_public = ?, updated_at = ? WHERE id = ?")
|
|
.bind(flag).bind(&now).bind(&id)
|
|
.execute(&s.db).await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
// Immediately reconfigure the Caddy route so the change takes effect
|
|
// without a full redeploy.
|
|
let app = fetch_app(&s, &id).await?;
|
|
push_visibility_to_caddy(&id, app.port, v).await;
|
|
}
|
|
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)
|
|
}
|
|
|
|
pub async fn delete(
|
|
State(s): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<StatusCode, StatusCode> {
|
|
let res = sqlx::query("DELETE FROM apps WHERE id = ?")
|
|
.bind(&id)
|
|
.execute(&s.db)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
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)
|
|
}
|
|
|
|
pub async fn stop(
|
|
State(_s): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<StatusCode, StatusCode> {
|
|
let name = format!("hiy-{}", id);
|
|
let out = tokio::process::Command::new("podman")
|
|
.args(["stop", &name])
|
|
.output()
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
if out.status.success() {
|
|
Ok(StatusCode::NO_CONTENT)
|
|
} else {
|
|
tracing::warn!("podman stop {}: {}", name, String::from_utf8_lossy(&out.stderr));
|
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
|
}
|
|
}
|
|
|
|
pub async fn restart(
|
|
State(_s): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<StatusCode, StatusCode> {
|
|
let name = format!("hiy-{}", id);
|
|
let out = tokio::process::Command::new("podman")
|
|
.args(["restart", &name])
|
|
.output()
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
if out.status.success() {
|
|
Ok(StatusCode::NO_CONTENT)
|
|
} else {
|
|
tracing::warn!("podman restart {}: {}", name, String::from_utf8_lossy(&out.stderr));
|
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
|
}
|
|
}
|
|
|
|
async fn fetch_app(s: &AppState, id: &str) -> Result<App, StatusCode> {
|
|
sqlx::query_as::<_, App>("SELECT * FROM apps WHERE id = ?")
|
|
.bind(id)
|
|
.fetch_optional(&s.db)
|
|
.await
|
|
.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(())
|
|
}
|