diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index a744798d..3df500d4 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -446,7 +446,7 @@ async fn proxy_to_app( .to_string(); let uri = match format!("http://127.0.0.1:{port}{path_and_query}").parse::() { Ok(uri) => uri, - Err(_) => return bad_gateway(), + Err(_) => return app_down_page(app), }; let (mut parts, body) = req.into_parts(); @@ -501,7 +501,7 @@ async fn proxy_to_app( let client = hyper::Client::new(); let mut upstream_resp = match client.request(upstream_req).await { Ok(resp) => resp, - Err(_) => return bad_gateway(), + Err(_) => return app_down_page(app), }; if upstream_resp.status() == StatusCode::SWITCHING_PROTOCOLS { if let Some(client_upgrade) = client_upgrade { @@ -521,7 +521,7 @@ async fn proxy_to_app( let client = hyper::Client::new(); match client.request(Request::from_parts(parts, body)).await { Ok(resp) => resp, - Err(_) => bad_gateway(), + Err(_) => app_down_page(app), } } @@ -583,11 +583,32 @@ fn redirect_to_app() -> Response { .expect("static response builds") } -fn bad_gateway() -> Response { - Response::builder() - .status(StatusCode::BAD_GATEWAY) - .body(Body::from("app is not responding")) - .expect("static response builds") +/// Served when the app behind the gate does not answer on loopback. +/// +/// A real page rather than the bare string `app is not responding`: the gate +/// answers on the app's own port, so this text IS the app as far as the +/// operator can tell, and the raw string read as the node itself being broken +/// (reported against Gitea on a fleet node, 2026-08-10 — the actual fault was +/// a ghost container crash-looping the app). Name the app, say the node is +/// fine, and retry on our own: an app that is restarting comes back without +/// the user knowing to reload. Status stays 502 so machine clients still see +/// an upstream failure rather than a success with HTML in it. +fn app_down_page(app: &GatedPort) -> Response { + let body = format!( + r#"{icon} +

{name} is not responding

+

The app is not answering right now — it may be stopped or still +starting. This page retries automatically. If it does not recover, open the +dashboard and check {name} under My Apps.

"#, + icon = icon_markup(app), + name = esc(&app.app_name), + ); + let mut resp = page("App not responding", app, &body, StatusCode::BAD_GATEWAY); + // Header-based refresh, not or script: page()'s CSP allows no + // script, and the header keeps the retry out of the document entirely. + resp.headers_mut() + .insert("Refresh", header::HeaderValue::from_static("5")); + resp } fn not_found() -> Response { @@ -1065,6 +1086,23 @@ mod tests { assert!(csp.contains("form-action 'self'")); } + /// A dead upstream must render as a page that names the app and retries, + /// not the bare string "app is not responding" — that string standing + /// alone on the app's own port read as the node being broken (Gitea on a + /// fleet node, 2026-08-10). The 502 status must survive so machine + /// clients still see an upstream failure. + #[tokio::test] + async fn a_dead_app_gets_a_named_retrying_page_not_a_bare_string() { + let resp = app_down_page(&app()); + assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); + assert_eq!(resp.headers()["Refresh"], "5"); + assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store"); + let body = hyper::body::to_bytes(resp.into_body()).await.unwrap(); + let html = String::from_utf8_lossy(&body); + assert!(html.contains("Strfry Relay is not responding")); + assert!(html.contains("