diff --git a/builder/build.sh b/builder/build.sh index 06570f5..b3696e1 100755 --- a/builder/build.sh +++ b/builder/build.sh @@ -149,11 +149,20 @@ 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=$(python3 -c " + # 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] app_host = sys.argv[2] @@ -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. diff --git a/server/Cargo.toml b/server/Cargo.toml index cc8d19f..4e76770 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -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 } diff --git a/server/src/builder.rs b/server/src/builder.rs index 32a95a6..b194054 100644 --- a/server/src/builder.rs +++ b/server/src/builder.rs @@ -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()) diff --git a/server/src/db.rs b/server/src/db.rs index 0366a6a..fb34d02 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -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 ( diff --git a/server/src/models.rs b/server/src/models.rs index c6de907..994f5a4 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -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, + pub is_public: i64, } #[derive(Debug, Deserialize)] @@ -36,6 +37,7 @@ pub struct UpdateApp { pub memory_limit: Option, pub cpu_limit: Option, pub git_token: Option, + pub is_public: Option, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] diff --git a/server/src/routes/apps.rs b/server/src/routes/apps.rs index f354498..cce0e68 100644 --- a/server/src/routes/apps.rs +++ b/server/src/routes/apps.rs @@ -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 = 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 = 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) -> Result>, 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 = ?") diff --git a/server/src/routes/ui.rs b/server/src/routes/ui.rs index cf0f844..10e25a1 100644 --- a/server/src/routes/ui.rs +++ b/server/src/routes/ui.rs @@ -297,6 +297,14 @@ pub async fn app_detail( } }; + let is_public = app.is_public != 0; + let visibility_badge = if is_public { + r#"public"# + } else { + r#"private"# + }; + 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#"Token configured"#.to_string(), @@ -321,8 +329,11 @@ pub async fn app_detail( .replace("{{env_rows}}", &env_rows) .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_status}}", &git_token_status) + .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() } diff --git a/server/templates/app_detail.html b/server/templates/app_detail.html index af06614..c4e2380 100644 --- a/server/templates/app_detail.html +++ b/server/templates/app_detail.html @@ -14,6 +14,8 @@  ·  branch {{branch}}  ·  port {{port}}  ·  {{name}}.{{host}} +  ·  {{visibility_badge}} +

@@ -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; }