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();
|
.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.
|
||||||
|
|||||||
Reference in New Issue
Block a user