v0.31.3
This commit is contained in:
1 parent
0facfe6f5c
commit
95c55da2ee
39 files changed
+715
-151
No files matched your search
@@ -244,6 +244,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md")),
|
||||
),
|
||||
reader_doc!("docs/IMPLEMENTATION_0.31.3.md", "technical"),
|
||||
reader_doc!("docs/IMPLEMENTATION_0.31.1.md", "technical"),
|
||||
reader_doc!("docs/IMPLEMENTATION_0.31.0.md", "technical"),
|
||||
(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.31.2",
|
||||
"version": "0.31.3",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
|
||||
},
|
||||
"servers": [
|
||||
|
||||
@@ -35,6 +35,7 @@ fn admin_app_javascript() -> &'static str {
|
||||
static ASSET: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
|
||||
[
|
||||
include_str!("web/app/core.js"),
|
||||
include_str!("web/app/session-resume.js"),
|
||||
include_str!("web/app/refresh.js"),
|
||||
include_str!("web/app/preferences.js"),
|
||||
include_str!("web/app/config-history.js"),
|
||||
|
||||
+112
-3
@@ -1,3 +1,108 @@
|
||||
// Only log browser origin metadata: never cookies, CSRF tokens, Authorization,
|
||||
// query strings, or untrusted forwarding data. Malformed origins are redacted.
|
||||
fn diagnostic_admin_authority(value: &str) -> &str {
|
||||
if value.len() <= 255 && !value.contains('@')
|
||||
&& value.parse::<http::uri::Authority>().is_ok()
|
||||
{
|
||||
value
|
||||
} else {
|
||||
"(invalid)"
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_admin_origin(headers: &HeaderMap) -> &str {
|
||||
if !headers.contains_key(ORIGIN) {
|
||||
return "(absent)";
|
||||
}
|
||||
let Some(value) = single_header(headers, ORIGIN.as_str()) else {
|
||||
return "(invalid)";
|
||||
};
|
||||
if value == "null" {
|
||||
return "null";
|
||||
}
|
||||
if value.len() > 255 || value.contains('@') || value.contains('#') {
|
||||
return "(invalid)";
|
||||
}
|
||||
let Ok(uri) = value.parse::<http::Uri>() else {
|
||||
return "(invalid)";
|
||||
};
|
||||
if !matches!(uri.scheme_str(), Some("http" | "https"))
|
||||
|| uri.query().is_some()
|
||||
|| !matches!(uri.path(), "" | "/")
|
||||
|| uri.authority().is_none()
|
||||
{
|
||||
return "(invalid)";
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn diagnostic_forwarded_header<'a>(headers: &'a HeaderMap, name: &str, trusted: bool) -> &'a str {
|
||||
if !headers.contains_key(name) {
|
||||
return "(absent)";
|
||||
}
|
||||
if !trusted {
|
||||
return "(untrusted)";
|
||||
}
|
||||
let Some(value) = single_header(headers, name) else {
|
||||
return "(invalid)";
|
||||
};
|
||||
if name == "x-forwarded-proto" {
|
||||
if matches!(value, "http" | "https") { value } else { "(invalid)" }
|
||||
} else {
|
||||
diagnostic_admin_authority(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_admin_origin(
|
||||
request: &Request,
|
||||
reason: &'static str,
|
||||
scheme: Option<&str>,
|
||||
authority: Option<&str>,
|
||||
trusted_host: bool,
|
||||
trusted_proto: bool,
|
||||
) -> Response {
|
||||
let headers = request.headers();
|
||||
let path = request.uri().path();
|
||||
let path = if path.len() <= 160 { path } else { "(long_path)" };
|
||||
let browser_host = single_header(headers, HOST.as_str())
|
||||
.map(diagnostic_admin_authority)
|
||||
.unwrap_or("(absent_or_duplicate)");
|
||||
let browser_fetch_site = match single_header(headers, "sec-fetch-site") {
|
||||
None => "(absent)",
|
||||
Some("same-origin" | "same-site" | "cross-site" | "none") =>
|
||||
single_header(headers, "sec-fetch-site").unwrap_or("(invalid)"),
|
||||
_ => "(invalid)",
|
||||
};
|
||||
tracing::warn!(
|
||||
reason,
|
||||
method = %request.method(),
|
||||
path,
|
||||
browser_origin = diagnostic_admin_origin(headers),
|
||||
browser_host,
|
||||
expected_scheme = scheme.unwrap_or("(unknown)"),
|
||||
expected_authority = authority.map(diagnostic_admin_authority).unwrap_or("(unknown)"),
|
||||
browser_fetch_site,
|
||||
trusted_forwarded_host = trusted_host,
|
||||
trusted_forwarded_proto = trusted_proto,
|
||||
forwarded_host = diagnostic_forwarded_header(headers, "x-forwarded-host", trusted_host),
|
||||
forwarded_proto = diagnostic_forwarded_header(headers, "x-forwarded-proto", trusted_proto),
|
||||
"administration origin rejected"
|
||||
);
|
||||
// When a browser navigates to the dashboard document, JSON here would
|
||||
// replace the entire page with a raw API error. Keep the 403 (no shell or
|
||||
// protected data) but present a standalone recovery message instead.
|
||||
let wants_html = request.headers().get(http::header::ACCEPT)
|
||||
.and_then(|header| header.to_str().ok())
|
||||
.is_some_and(|value| value.split(',').any(|entry| entry.trim_start().starts_with("text/html")));
|
||||
if request.method() == Method::GET && panel_document_path(request.uri().path()) && wants_html {
|
||||
return secure((
|
||||
StatusCode::FORBIDDEN,
|
||||
axum::response::Html("<!doctype html><html lang=\"en\"><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>RustProxy - Access blocked</title><h1>RustProxy - Could not open the panel</h1><p>The request origin was rejected. Check the proxy/URL and try again.</p><p><a href=\"\">Reload panel</a></p></html>"),
|
||||
).into_response());
|
||||
}
|
||||
secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin"))
|
||||
}
|
||||
|
||||
async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next) -> Response {
|
||||
let transport = request
|
||||
.extensions()
|
||||
@@ -10,9 +115,13 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
|
||||
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
|
||||
};
|
||||
let headers = request.headers();
|
||||
let trusted_host = trusted_admin_proxy(&state, peer);
|
||||
let trusted_proto = trusted_admin_forwarded_proto(&state, peer);
|
||||
let scheme = match external_admin_scheme(&state, headers, peer, transport) {
|
||||
Ok(value) => value,
|
||||
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")),
|
||||
Err(()) => {
|
||||
return invalid_admin_origin(&request, "external_scheme", None, None, trusted_host, trusted_proto);
|
||||
}
|
||||
};
|
||||
let external_authority =
|
||||
match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
|
||||
@@ -26,13 +135,13 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
|
||||
.is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority))
|
||||
})
|
||||
{
|
||||
return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin"));
|
||||
return invalid_admin_origin(&request, "origin_mismatch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto);
|
||||
}
|
||||
if headers
|
||||
.get("sec-fetch-site")
|
||||
.is_some_and(|h| h == "cross-site")
|
||||
{
|
||||
return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin"));
|
||||
return invalid_admin_origin(&request, "cross_site_fetch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto);
|
||||
}
|
||||
let path = request.uri().path().to_owned();
|
||||
let public = anonymous_allowed(request.method(), &path);
|
||||
|
||||
@@ -253,6 +253,69 @@
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_origin_diagnostics_redact_non_origin_values() {
|
||||
let mut headers = HeaderMap::new();
|
||||
assert_eq!(diagnostic_admin_origin(&headers), "(absent)");
|
||||
headers.insert(ORIGIN, HeaderValue::from_static("https://panel.example.test"));
|
||||
assert_eq!(diagnostic_admin_origin(&headers), "https://panel.example.test");
|
||||
headers.insert(ORIGIN, HeaderValue::from_static("https://user:password@panel.example.test"));
|
||||
assert_eq!(diagnostic_admin_origin(&headers), "(invalid)");
|
||||
headers.insert(ORIGIN, HeaderValue::from_static("https://panel.example.test/?token=secret"));
|
||||
assert_eq!(diagnostic_admin_origin(&headers), "(invalid)");
|
||||
headers.insert("x-forwarded-host", HeaderValue::from_static("other.example.test"));
|
||||
assert_eq!(diagnostic_forwarded_header(&headers, "x-forwarded-host", false), "(untrusted)");
|
||||
assert_eq!(diagnostic_forwarded_header(&headers, "x-forwarded-host", true), "other.example.test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthorized_browser_origins_remain_rejected_for_session_checks() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
for (origin, fetch_site) in [
|
||||
(Some("https://attacker.example"), None),
|
||||
(Some("null"), None),
|
||||
(None, Some("cross-site")),
|
||||
] {
|
||||
let mut request = Request::builder()
|
||||
.uri("/api/auth/session")
|
||||
.header("host", "127.0.0.1:9090")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME));
|
||||
if let Some(origin) = origin { request = request.header(ORIGIN, origin); }
|
||||
if let Some(site) = fetch_site { request = request.header("sec-fetch-site", site); }
|
||||
let response = router.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
// Origin on an ordinary same-origin GET may be absent, or may match.
|
||||
for origin in [None, Some("http://127.0.0.1:9090")] {
|
||||
let mut request = Request::builder()
|
||||
.uri("/api/auth/session")
|
||||
.header("host", "127.0.0.1:9090")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME));
|
||||
if let Some(origin) = origin { request = request.header(ORIGIN, origin); }
|
||||
assert_eq!(router.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap().status(), StatusCode::OK);
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_origin_on_document_is_visible_but_still_forbidden() {
|
||||
let (router, store, _dir, _secret, _) = fixture().await;
|
||||
let response = router.oneshot(
|
||||
Request::builder()
|
||||
.uri("/overview")
|
||||
.header("host", "127.0.0.1:9090")
|
||||
.header("origin", "null")
|
||||
.header("accept", "text/html,application/xhtml+xml")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert!(response.headers().get(http::header::CONTENT_TYPE).unwrap().to_str().unwrap().starts_with("text/html"));
|
||||
let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap();
|
||||
assert!(String::from_utf8_lossy(&body).contains("Could not open the panel"));
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trusted_https_reverse_proxy_accepts_external_origin_and_refreshes_secure_cookie() {
|
||||
let mut config = Config::default();
|
||||
|
||||
@@ -25,7 +25,7 @@ function panelFromLocation() { if (typeof location === "undefined") return "over
|
||||
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
|
||||
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], toastTimer, editingBackend = null, editingRoute = null;
|
||||
let liveEvents = null, liveConnected = false, liveFallback = false, liveInterval = null, authEpoch = 0, pendingAuthDestination = null;
|
||||
let lastApiLatencyMs = null;
|
||||
let lastApiLatencyMs = null, sessionCheckPromise = null;
|
||||
let adminAuthChannel = null; try { adminAuthChannel = new BroadcastChannel("rustproxy-admin-session"); adminAuthChannel.addEventListener("message", event => { if (event.data === "locked" && authenticated) lock(false); }); } catch { }
|
||||
// Language content is discovered by build.rs from langs/*.json, never compiled
|
||||
// into a two-language switch here. Locale metadata also drives Intl and RTL.
|
||||
@@ -388,7 +388,7 @@ async function activate(info) {
|
||||
document.documentElement.classList.remove("app-startup-failed");
|
||||
rustProxyRefresh.start();
|
||||
const preferences = await loadAdminPreferences();
|
||||
if (!authenticated || epoch !== authEpoch) return;
|
||||
if (!authenticated || epoch !== authEpoch) return false;
|
||||
window.rustProxyLogs?.applyPreferences(preferences);
|
||||
window.rustProxyFileLogs?.applyPreferences(preferences);
|
||||
window.rustProxyVhosts?.applyPreferences(preferences);
|
||||
@@ -399,17 +399,18 @@ async function activate(info) {
|
||||
for (let attempt = 0; attempt < 3 && authenticated && epoch === authEpoch; attempt++) {
|
||||
try { await loadConfig(); break; }
|
||||
catch (error) {
|
||||
if (!authenticated || epoch !== authEpoch || error?.status === 401) return;
|
||||
if (attempt === 2) { showStartupFailure(error); return; }
|
||||
if (!authenticated || epoch !== authEpoch || error?.status === 401) return false;
|
||||
if (attempt === 2) { showStartupFailure(error); return false; }
|
||||
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 400));
|
||||
}
|
||||
}
|
||||
if (!authenticated || epoch !== authEpoch) return;
|
||||
if (!authenticated || epoch !== authEpoch) return false;
|
||||
// A single full snapshot seeds non-live diagnostics; subsequent updates
|
||||
// continue over SSE rather than polling full status on a timer.
|
||||
void refresh(true).catch(error => {
|
||||
if (authenticated && epoch === authEpoch && error?.status !== 401) toast(error.message, true);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [formatDateTime(item.time * 1000), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
|
||||
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
|
||||
@@ -475,26 +476,3 @@ $("tunnels-dialog").addEventListener("close", stopTunnelRefresh);
|
||||
$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor"));
|
||||
$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } });
|
||||
$("export-config").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify(envelope.config, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = `RustProxy-config-r${envelope.revision}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
|
||||
async function revalidateSession() {
|
||||
const epoch = authEpoch;
|
||||
try {
|
||||
const info = await api("/api/auth/session");
|
||||
clearInvalidOriginRecovery();
|
||||
if (epoch !== authEpoch) return;
|
||||
if (!authenticated) { await activate(info); return; }
|
||||
sessionInfo = info; csrf = info.csrf_token; renderSession(); window.rustProxyRefresh?.sync();
|
||||
} catch (error) { if (recoverInvalidOrigin(error)) return; if (error.status !== 401) toast(error.message, true); }
|
||||
}
|
||||
window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); });
|
||||
function showStartupFailure(error) {
|
||||
// Never expose a blank page or an incomplete workspace after startup fails.
|
||||
// The authenticated shell keeps its own retry link; 401 still redirects to login.
|
||||
$("login").hidden = true;
|
||||
$("workspace").hidden = true;
|
||||
$("logout").hidden = !authenticated;
|
||||
document.documentElement.classList.add("app-loading", "app-startup-failed");
|
||||
$("app-startup-title").textContent = languagePacks.size ? t("startupFailed") : "Could not load RustProxy";
|
||||
const detail = $("app-startup-detail");
|
||||
detail.textContent = error?.message || String(error);
|
||||
detail.hidden = false;
|
||||
}
|
||||
@@ -21,9 +21,10 @@ const rustProxyRefresh = (() => {
|
||||
const tasks = new Map();
|
||||
let ready = false, options = { ...REFRESH_DEFAULTS }, panel = currentPanel;
|
||||
let settingsTab = "listeners", watchingVisibility = false;
|
||||
let sessionReady = true, visibleResumeHandled = false;
|
||||
const visible = () => !document.hidden;
|
||||
function allowed(task) {
|
||||
return ready && authenticated && visible() && options.live_enabled &&
|
||||
return ready && authenticated && sessionReady && visible() && options.live_enabled &&
|
||||
task.interval() > 0 && (!task.active || task.active());
|
||||
}
|
||||
function clear(task, abort = false) {
|
||||
@@ -39,7 +40,7 @@ const rustProxyRefresh = (() => {
|
||||
task.timer = setTimeout(() => { task.timer = null; void execute(task, false); }, delay);
|
||||
}
|
||||
function execute(task, manual) {
|
||||
if (!authenticated || (!manual && !allowed(task))) return Promise.resolve();
|
||||
if (!authenticated || !sessionReady || (!manual && !allowed(task))) return Promise.resolve();
|
||||
if (task.inFlight) {
|
||||
if (manual) task.queued = true;
|
||||
return task.inFlight;
|
||||
@@ -92,22 +93,56 @@ const rustProxyRefresh = (() => {
|
||||
}
|
||||
task.seconds = seconds;
|
||||
}
|
||||
if (ready && authenticated && visible() && options.live_enabled) startLive();
|
||||
if (ready && authenticated && sessionReady && visible() && options.live_enabled) startLive();
|
||||
else stopLive();
|
||||
}
|
||||
function onVisibility() { sync(); if (visible() && ready && authenticated) void revalidateSession(); }
|
||||
function start() {
|
||||
ready = true; panel = currentPanel;
|
||||
settingsTab = window.rustProxySettings?.current?.() || "listeners";
|
||||
if (!watchingVisibility) { document.addEventListener("visibilitychange", onVisibility); watchingVisibility = true; }
|
||||
sync();
|
||||
}
|
||||
function stop() {
|
||||
ready = false;
|
||||
function suspendForSessionValidation() {
|
||||
sessionReady = false;
|
||||
for (const task of tasks.values()) {
|
||||
clear(task, true); task.queued = false; task.enabled = false;
|
||||
}
|
||||
if (watchingVisibility) { document.removeEventListener("visibilitychange", onVisibility); watchingVisibility = false; }
|
||||
sync();
|
||||
}
|
||||
function completeSessionValidation(success) {
|
||||
sessionReady = !!success;
|
||||
sync();
|
||||
}
|
||||
function requestSessionResume() {
|
||||
if (!ready || !authenticated || !visible() || visibleResumeHandled) return;
|
||||
visibleResumeHandled = true;
|
||||
void revalidateSession();
|
||||
}
|
||||
function onVisibility() {
|
||||
if (visible()) requestSessionResume();
|
||||
else { visibleResumeHandled = false; sync(); }
|
||||
}
|
||||
function onPageHide() {
|
||||
visibleResumeHandled = false;
|
||||
suspendForSessionValidation();
|
||||
}
|
||||
function onPageShow(event) { if (event.persisted) requestSessionResume(); }
|
||||
function start() {
|
||||
ready = true; sessionReady = true; visibleResumeHandled = false; panel = currentPanel;
|
||||
settingsTab = window.rustProxySettings?.current?.() || "listeners";
|
||||
if (!watchingVisibility) {
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
watchingVisibility = true;
|
||||
}
|
||||
sync();
|
||||
}
|
||||
function stop() {
|
||||
ready = false; sessionReady = false; visibleResumeHandled = false;
|
||||
for (const task of tasks.values()) {
|
||||
clear(task, true); task.queued = false; task.enabled = false;
|
||||
}
|
||||
if (watchingVisibility) {
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
window.removeEventListener("pagehide", onPageHide);
|
||||
window.removeEventListener("pageshow", onPageShow);
|
||||
watchingVisibility = false;
|
||||
}
|
||||
stopLive();
|
||||
}
|
||||
function update(value) { options = normalizeRefreshPreferences(value); sync(); }
|
||||
@@ -151,6 +186,7 @@ const rustProxyRefresh = (() => {
|
||||
register("tunnels", { interval: () => 2, active: () => !!$("tunnels-dialog")?.open,
|
||||
run: async context => { await loadTunnels(context); } });
|
||||
return { register, manual, start, stop, sync, update, setPanel, setSettingsTab,
|
||||
suspendForSessionValidation, completeSessionValidation,
|
||||
current: () => ({ ...options }), diagnostics: () => [...tasks.values()].map(t => ({ id: t.id, enabled: t.enabled, inFlight: !!t.inFlight, scheduled: t.timer !== null })) };
|
||||
})();
|
||||
window.rustProxyRefresh = rustProxyRefresh;
|
||||
|
||||
@@ -21,12 +21,11 @@ async function startAdminPanel() {
|
||||
// app.js belongs to the authenticated shell and must not fall back to its
|
||||
// legacy embedded sign-in form when another startup step fails.
|
||||
const info = await api("/api/auth/session");
|
||||
clearInvalidOriginRecovery();
|
||||
await loadLanguages();
|
||||
setupRequired = false;
|
||||
translate();
|
||||
syncAuthForm();
|
||||
await activate(info);
|
||||
if (await activate(info)) clearInvalidOriginRecovery();
|
||||
} catch (e) {
|
||||
if (recoverInvalidOrigin(e)) return;
|
||||
if (e.status === 401) {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// One in-flight validation per authenticated document. Neither BFCache restore nor
|
||||
// visibility resume may trigger a reload: a 403 is a recoverable, visible error.
|
||||
function revalidateSession() {
|
||||
if (!authenticated) return Promise.resolve(false);
|
||||
if (sessionCheckPromise) return sessionCheckPromise;
|
||||
const epoch = authEpoch;
|
||||
window.rustProxyRefresh?.suspendForSessionValidation();
|
||||
const check = (async () => {
|
||||
try {
|
||||
const info = await api("/api/auth/session");
|
||||
if (!authenticated || epoch !== authEpoch) return false;
|
||||
sessionInfo = info;
|
||||
csrf = info.csrf_token;
|
||||
renderSession();
|
||||
hideSessionValidationFailure();
|
||||
window.rustProxyRefresh?.completeSessionValidation(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// api() locks and navigates to the login document on a 401.
|
||||
if (!authenticated || epoch !== authEpoch || error?.status === 401) return false;
|
||||
window.rustProxyRefresh?.completeSessionValidation(false);
|
||||
showSessionValidationFailure(error);
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
sessionCheckPromise = check;
|
||||
void check.then(
|
||||
() => { if (sessionCheckPromise === check) sessionCheckPromise = null; },
|
||||
() => { if (sessionCheckPromise === check) sessionCheckPromise = null; }
|
||||
);
|
||||
return check;
|
||||
}
|
||||
function displayStartupError(error, sessionResume = false) {
|
||||
$("login").hidden = true;
|
||||
$("workspace").hidden = true;
|
||||
$("logout").hidden = !authenticated;
|
||||
document.documentElement.classList.add("app-loading", "app-startup-failed");
|
||||
document.documentElement.classList.toggle("app-session-failed", sessionResume);
|
||||
$("app-startup-title").textContent = sessionResume
|
||||
? t("sessionValidationFailed")
|
||||
: (languagePacks.size ? t("startupFailed") : "Could not load RustProxy");
|
||||
const detail = $("app-startup-detail");
|
||||
detail.textContent = error?.message || String(error);
|
||||
detail.hidden = false;
|
||||
$("app-startup-reload").hidden = sessionResume;
|
||||
$("app-session-retry").hidden = !sessionResume;
|
||||
}
|
||||
function showSessionValidationFailure(error) {
|
||||
// Keep the workspace inaccessible while its session cannot be verified.
|
||||
displayStartupError(error, true);
|
||||
}
|
||||
function hideSessionValidationFailure() {
|
||||
if (!document.documentElement.classList.contains("app-session-failed")) return;
|
||||
document.documentElement.classList.remove("app-session-failed", "app-loading", "app-startup-failed");
|
||||
$("app-startup-detail").hidden = true;
|
||||
$("app-startup-detail").textContent = "";
|
||||
$("app-startup-reload").hidden = false;
|
||||
$("app-session-retry").hidden = true;
|
||||
$("workspace").hidden = false;
|
||||
$("logout").hidden = false;
|
||||
}
|
||||
$("app-session-retry").addEventListener("click", () => {
|
||||
const button = $("app-session-retry");
|
||||
button.disabled = true;
|
||||
void revalidateSession().then(
|
||||
() => { button.disabled = false; },
|
||||
() => { button.disabled = false; }
|
||||
);
|
||||
});
|
||||
function showStartupFailure(error) {
|
||||
// All startup failure paths stop background tasks and show visible recovery.
|
||||
window.rustProxyRefresh?.stop();
|
||||
displayStartupError(error);
|
||||
}
|
||||
@@ -23,7 +23,8 @@
|
||||
<div class="app-startup-recovery">
|
||||
<p data-i18n="startupSlow">The panel is taking longer than expected. Check your connection and try again.</p>
|
||||
<p id="app-startup-detail" class="app-startup-detail" hidden></p>
|
||||
<a class="outline" href="" data-i18n="startupRetry">Reload panel</a>
|
||||
<a class="outline" id="app-startup-reload" href="" data-i18n="startupRetry">Reload panel</a>
|
||||
<button class="outline" type="button" id="app-session-retry" data-i18n="sessionRetry" hidden>Retry connection</button>
|
||||
</div>
|
||||
<noscript>JavaScript is required to use the administration panel.</noscript>
|
||||
</div>
|
||||
|
||||
@@ -232,7 +232,7 @@ html:not(.app-loading) .app-startup {
|
||||
animation: app-startup-reveal 0s linear 12s forwards
|
||||
}
|
||||
|
||||
.app-startup-recovery .outline {
|
||||
.app-startup-recovery .outline:not([hidden]) {
|
||||
margin-top: 10px;
|
||||
display: inline-flex
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user