fix(appgate): dead upstream serves a named, self-retrying page
Demo images / Build & push demo images (push) Successful in 3m45s
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:
co-authored by
Claude Fable 5
parent
2cee14dd47
commit
ee15626127
@@ -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::<hyper::Uri>() {
|
||||
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<Body> {
|
||||
.expect("static response builds")
|
||||
}
|
||||
|
||||
fn bad_gateway() -> Response<Body> {
|
||||
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<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> {
|
||||
@@ -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("<html"), "must be a page, not a bare string");
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// a black page rather than the dashboard's art.
|
||||
|
||||
Reference in New Issue
Block a user