fix(appgate): dead upstream serves a named, self-retrying page
Demo images / Build & push demo images (push) Successful in 3m45s

The gate answered a dead app with the bare string "app is not responding",
which on the app's own port reads as the node itself being broken — reported
against Gitea on a fleet node (the actual fault was a ghost container holding
Gitea's LevelDB lock, crash-looping the managed container). Serve the same
styled page as the login challenge instead: names the app, says it may be
restarting, and retries via a Refresh header (page CSP allows no script).
Status stays 502 so machine clients still see an upstream failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-10 15:29:19 -04:00
co-authored by Claude Fable 5
parent 2cee14dd47
commit ee15626127
+46 -8
View File
@@ -446,7 +446,7 @@ async fn proxy_to_app(
.to_string(); .to_string();
let uri = match format!("http://127.0.0.1:{port}{path_and_query}").parse::<hyper::Uri>() { let uri = match format!("http://127.0.0.1:{port}{path_and_query}").parse::<hyper::Uri>() {
Ok(uri) => uri, Ok(uri) => uri,
Err(_) => return bad_gateway(), Err(_) => return app_down_page(app),
}; };
let (mut parts, body) = req.into_parts(); let (mut parts, body) = req.into_parts();
@@ -501,7 +501,7 @@ async fn proxy_to_app(
let client = hyper::Client::new(); let client = hyper::Client::new();
let mut upstream_resp = match client.request(upstream_req).await { let mut upstream_resp = match client.request(upstream_req).await {
Ok(resp) => resp, Ok(resp) => resp,
Err(_) => return bad_gateway(), Err(_) => return app_down_page(app),
}; };
if upstream_resp.status() == StatusCode::SWITCHING_PROTOCOLS { if upstream_resp.status() == StatusCode::SWITCHING_PROTOCOLS {
if let Some(client_upgrade) = client_upgrade { if let Some(client_upgrade) = client_upgrade {
@@ -521,7 +521,7 @@ async fn proxy_to_app(
let client = hyper::Client::new(); let client = hyper::Client::new();
match client.request(Request::from_parts(parts, body)).await { match client.request(Request::from_parts(parts, body)).await {
Ok(resp) => resp, Ok(resp) => resp,
Err(_) => bad_gateway(), Err(_) => app_down_page(app),
} }
} }
@@ -583,11 +583,32 @@ fn redirect_to_app() -> Response<Body> {
.expect("static response builds") .expect("static response builds")
} }
fn bad_gateway() -> Response<Body> { /// Served when the app behind the gate does not answer on loopback.
Response::builder() ///
.status(StatusCode::BAD_GATEWAY) /// A real page rather than the bare string `app is not responding`: the gate
.body(Body::from("app is not responding")) /// answers on the app's own port, so this text IS the app as far as the
.expect("static response builds") /// 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<Body> {
let body = format!(
r#"{icon}
<h1>{name} is not responding</h1>
<p class="sub">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.</p>"#,
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 <meta> 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<Body> { fn not_found() -> Response<Body> {
@@ -1065,6 +1086,23 @@ mod tests {
assert!(csp.contains("form-action 'self'")); 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("<html"), "must be a page, not a bare string");
}
/// The login page must render entirely from the gate's own origin: the /// The login page must render entirely from the gate's own origin: the
/// CSP allows no external host, so a background or logo that 404s leaves /// CSP allows no external host, so a background or logo that 404s leaves
/// a black page rather than the dashboard's art. /// a black page rather than the dashboard's art.