claude/heroku-clone-mvp-plan-NREhc #1
8 changed files with 142 additions and 7 deletions
|
|
@ -149,10 +149,19 @@ if curl --silent --fail "${CADDY_API}/config/" >/dev/null 2>&1; then
|
|||
else
|
||||
ROUTES_URL="${CADDY_API}/config/apps/http/servers/${CADDY_SERVER}/routes"
|
||||
|
||||
# Route JSON uses Caddy's forward_auth pattern:
|
||||
# 1. HIY server checks the session cookie and app-level permission at /auth/verify
|
||||
# 2. On 2xx → Caddy proxies to the app container
|
||||
# 3. On anything else (e.g. 302 redirect to /login) → Caddy passes through to the client
|
||||
# Route JSON: public apps use plain reverse_proxy; private apps use forward_auth.
|
||||
if [ "${IS_PUBLIC:-0}" = "1" ]; then
|
||||
ROUTE_JSON=$(python3 -c "
|
||||
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 "
|
||||
import json, sys
|
||||
upstream = sys.argv[1]
|
||||
|
|
@ -187,6 +196,7 @@ route = {
|
|||
}
|
||||
print(json.dumps(route))
|
||||
" "${UPSTREAM}" "${APP_ID}.${DOMAIN_SUFFIX}")
|
||||
fi
|
||||
# Upsert the route for this app.
|
||||
ROUTES=$(curl --silent --fail "${ROUTES_URL}" 2>/dev/null || echo "[]")
|
||||
# Remove existing route for the same host, rebuild list, keep dashboard as catch-all.
|
||||
|
|
|
|||
|
|
@ -28,3 +28,4 @@ aes-gcm = "0.10"
|
|||
anyhow = "1"
|
||||
futures = "0.3"
|
||||
base64 = "0.22"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> {
|
|||
.env("BUILD_DIR", &build_dir)
|
||||
.env("MEMORY_LIMIT", &app.memory_limit)
|
||||
.env("CPU_LIMIT", &app.cpu_limit)
|
||||
.env("IS_PUBLIC", if app.is_public != 0 { "1" } else { "0" })
|
||||
.env("DOMAIN_SUFFIX", &domain_suffix)
|
||||
.env("CADDY_API_URL", &caddy_api_url)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
|
|||
.execute(pool).await;
|
||||
let _ = sqlx::query("ALTER TABLE apps ADD COLUMN git_token TEXT")
|
||||
.execute(pool).await;
|
||||
let _ = sqlx::query("ALTER TABLE apps ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0")
|
||||
.execute(pool).await;
|
||||
|
||||
sqlx::query(
|
||||
r#"CREATE TABLE IF NOT EXISTS databases (
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub struct App {
|
|||
/// Encrypted git token for cloning private repos. Never serialised to API responses.
|
||||
#[serde(skip_serializing)]
|
||||
pub git_token: Option<String>,
|
||||
pub is_public: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -36,6 +37,7 @@ pub struct UpdateApp {
|
|||
pub memory_limit: Option<String>,
|
||||
pub cpu_limit: Option<String>,
|
||||
pub git_token: Option<String>,
|
||||
pub is_public: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
|
|
|
|||
|
|
@ -12,6 +12,91 @@ use crate::{
|
|||
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)
|
||||
|
|
@ -112,6 +197,17 @@ pub async fn update(
|
|||
.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 = ?")
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
(
|
||||
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("{{db_card}}", &db_card)
|
||||
.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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
· branch <code>{{branch}}</code>
|
||||
· port <code>{{port}}</code>
|
||||
· <a href="http://{{name}}.{{host}}" target="_blank">{{name}}.{{host}}</a>
|
||||
· {{visibility_badge}}
|
||||
<button style="font-size:0.78rem;padding:2px 10px;margin-left:4px" onclick="toggleVisibility()">{{visibility_toggle_label}}</button>
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
|
|
@ -165,6 +167,16 @@ async function deprovisionDb() {
|
|||
if (r.ok) window.location.reload();
|
||||
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() {
|
||||
const tok = document.getElementById('git-token-input').value;
|
||||
if (!tok) { alert('Enter a token first'); return; }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue