claude/heroku-clone-mvp-plan-NREhc #1

Merged
sander merged 42 commits from claude/heroku-clone-mvp-plan-NREhc into main 2026-03-29 07:24:40 +00:00
8 changed files with 142 additions and 7 deletions
Showing only changes of commit eb9a500987 - Show all commits

View file

@ -149,10 +149,19 @@ if curl --silent --fail "${CADDY_API}/config/" >/dev/null 2>&1; then
else else
ROUTES_URL="${CADDY_API}/config/apps/http/servers/${CADDY_SERVER}/routes" ROUTES_URL="${CADDY_API}/config/apps/http/servers/${CADDY_SERVER}/routes"
# Route JSON uses Caddy's forward_auth pattern: # Route JSON: public apps use plain reverse_proxy; private apps use forward_auth.
# 1. HIY server checks the session cookie and app-level permission at /auth/verify if [ "${IS_PUBLIC:-0}" = "1" ]; then
# 2. On 2xx → Caddy proxies to the app container ROUTE_JSON=$(python3 -c "
# 3. On anything else (e.g. 302 redirect to /login) → Caddy passes through to the client import json, sys
upstream = sys.argv[1]
app_host = sys.argv[2]
route = {
'match': [{'host': [app_host]}],
'handle': [{'handler': 'reverse_proxy', 'upstreams': [{'dial': upstream}]}]
}
print(json.dumps(route))
" "${UPSTREAM}" "${APP_ID}.${DOMAIN_SUFFIX}")
else
ROUTE_JSON=$(python3 -c " ROUTE_JSON=$(python3 -c "
import json, sys import json, sys
upstream = sys.argv[1] upstream = sys.argv[1]
@ -187,6 +196,7 @@ route = {
} }
print(json.dumps(route)) print(json.dumps(route))
" "${UPSTREAM}" "${APP_ID}.${DOMAIN_SUFFIX}") " "${UPSTREAM}" "${APP_ID}.${DOMAIN_SUFFIX}")
fi
# Upsert the route for this app. # Upsert the route for this app.
ROUTES=$(curl --silent --fail "${ROUTES_URL}" 2>/dev/null || echo "[]") ROUTES=$(curl --silent --fail "${ROUTES_URL}" 2>/dev/null || echo "[]")
# Remove existing route for the same host, rebuild list, keep dashboard as catch-all. # Remove existing route for the same host, rebuild list, keep dashboard as catch-all.

View file

@ -28,3 +28,4 @@ aes-gcm = "0.10"
anyhow = "1" anyhow = "1"
futures = "0.3" futures = "0.3"
base64 = "0.22" base64 = "0.22"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }

View file

@ -156,6 +156,7 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> {
.env("BUILD_DIR", &build_dir) .env("BUILD_DIR", &build_dir)
.env("MEMORY_LIMIT", &app.memory_limit) .env("MEMORY_LIMIT", &app.memory_limit)
.env("CPU_LIMIT", &app.cpu_limit) .env("CPU_LIMIT", &app.cpu_limit)
.env("IS_PUBLIC", if app.is_public != 0 { "1" } else { "0" })
.env("DOMAIN_SUFFIX", &domain_suffix) .env("DOMAIN_SUFFIX", &domain_suffix)
.env("CADDY_API_URL", &caddy_api_url) .env("CADDY_API_URL", &caddy_api_url)
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())

View file

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

View file

@ -15,6 +15,7 @@ pub struct App {
/// Encrypted git token for cloning private repos. Never serialised to API responses. /// Encrypted git token for cloning private repos. Never serialised to API responses.
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub git_token: Option<String>, pub git_token: Option<String>,
pub is_public: i64,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -36,6 +37,7 @@ pub struct UpdateApp {
pub memory_limit: Option<String>, pub memory_limit: Option<String>,
pub cpu_limit: Option<String>, pub cpu_limit: Option<String>,
pub git_token: Option<String>, pub git_token: Option<String>,
pub is_public: Option<bool>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]

View file

@ -12,6 +12,91 @@ use crate::{
AppState, 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> { 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") let apps = sqlx::query_as::<_, App>("SELECT * FROM apps ORDER BY created_at DESC")
.fetch_all(&s.db) .fetch_all(&s.db)
@ -112,6 +197,17 @@ pub async fn update(
.execute(&s.db).await .execute(&s.db).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .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 let Some(v) = payload.git_token {
if v.is_empty() { if v.is_empty() {
sqlx::query("UPDATE apps SET git_token = NULL, updated_at = ? WHERE id = ?") sqlx::query("UPDATE apps SET git_token = NULL, updated_at = ? WHERE id = ?")

View file

@ -297,6 +297,14 @@ pub async fn app_detail(
} }
}; };
let is_public = app.is_public != 0;
let visibility_badge = if is_public {
r#"<span class="badge badge-success">public</span>"#
} else {
r#"<span class="badge badge-unknown">private</span>"#
};
let visibility_toggle_label = if is_public { "Make private" } else { "Make public" };
let (git_token_status, git_token_clear_btn) = if app.git_token.is_some() { let (git_token_status, git_token_clear_btn) = if app.git_token.is_some() {
( (
r#"<span class="badge badge-success">Token configured</span>"#.to_string(), r#"<span class="badge badge-success">Token configured</span>"#.to_string(),
@ -322,7 +330,10 @@ pub async fn app_detail(
.replace("{{c_badge}}", &container_badge(&container_state)) .replace("{{c_badge}}", &container_badge(&container_state))
.replace("{{db_card}}", &db_card) .replace("{{db_card}}", &db_card)
.replace("{{git_token_status}}", &git_token_status) .replace("{{git_token_status}}", &git_token_status)
.replace("{{git_token_clear_btn}}", &git_token_clear_btn); .replace("{{git_token_clear_btn}}", &git_token_clear_btn)
.replace("{{visibility_badge}}", visibility_badge)
.replace("{{visibility_toggle_label}}", visibility_toggle_label)
.replace("{{is_public_js}}", if is_public { "true" } else { "false" });
Html(page(&app.name, &body)).into_response() Html(page(&app.name, &body)).into_response()
} }

View file

@ -14,6 +14,8 @@
&nbsp;·&nbsp; branch <code>{{branch}}</code> &nbsp;·&nbsp; branch <code>{{branch}}</code>
&nbsp;·&nbsp; port <code>{{port}}</code> &nbsp;·&nbsp; port <code>{{port}}</code>
&nbsp;·&nbsp; <a href="http://{{name}}.{{host}}" target="_blank">{{name}}.{{host}}</a> &nbsp;·&nbsp; <a href="http://{{name}}.{{host}}" target="_blank">{{name}}.{{host}}</a>
&nbsp;·&nbsp; {{visibility_badge}}
<button style="font-size:0.78rem;padding:2px 10px;margin-left:4px" onclick="toggleVisibility()">{{visibility_toggle_label}}</button>
</p> </p>
<div class="card"> <div class="card">
@ -165,6 +167,16 @@ async function deprovisionDb() {
if (r.ok) window.location.reload(); if (r.ok) window.location.reload();
else alert('Error: ' + await r.text()); else alert('Error: ' + await r.text());
} }
const IS_PUBLIC = {{is_public_js}};
async function toggleVisibility() {
const r = await fetch('/api/apps/' + APP_ID, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({is_public: !IS_PUBLIC}),
});
if (r.ok) window.location.reload();
else alert('Error updating visibility: ' + await r.text());
}
async function saveGitToken() { async function saveGitToken() {
const tok = document.getElementById('git-token-input').value; const tok = document.getElementById('git-token-input').value;
if (!tok) { alert('Enter a token first'); return; } if (!tok) { alert('Enter a token first'); return; }