`). It creates an H3 document GET while older SSE/API requests can be cancelled.
+- **Fix candidates implemented:** The H3 GET/HEAD cache and 404 preflight avoids unbounded waiting on a delayed request FIN; on an H3->HTTP/1.1 vhost, a truly empty GET or HEAD stream is normalized before forwarding (bounded 35 ms first-frame probe; nonempty data replayed; pending client keeps streaming). On an H3 accept error, no longer `abort_all` active responses; bounded drain still applies.
+- **Diagnostics:** Persist `http3_peer_cancelled_before_headers` events with status 0 (not 400). For interrupted responses write `http3_peer_cancelled_after_headers` or `http3_response_write_error`, including sanitized path without query, stream ID, response phase/status, bytes sent and redacted error. Normal process INFO/WARN logs also record the start, completion or interruption of root-document H3 GET `/` using its stream ID; no cookies or query strings. These records remain in vhost file logs and are subject to configured log sinks. They are distinct from application response access logs.
+- **Rust regression cases added:** GET/HEAD empty-body normalization, nonempty GET payload replay, bounded cache preflight and stalled GET; retained 0.31.5 H3 cancellation classification tests. Static release and documentation contract check extended.
+- **NOT VERIFIED:** Rust compilation, a live QUIC listener, reverse-proxy-to-panel traffic, iOS Safari/Chrome, frame captures, HTTP/3 stream integrity, TLS/QUIC delivery, persisted data upgrades. Do not describe root cause or fix as proven until captured on device. No WS-over-H3 is assumed; H1 websocket origins do not activate RFC 9220 by themselves.
+- **No changes:** Sidebar logo link, H3 on/off configuration, SQLite schema 9, cache format, frontend routes, authentication/session/CSRF or WebSocket settings.
+- **PASS — executed for 0.31.6:** `./dev.sh test` completed all pre-Cargo Python/SQLite/schema/upgrade/security/source checks, 300,000 simulated memory-model operations, 2,000 requests to a Python test peer, all Node UI regressions (including the 0.31.5 mobile/H3 suite updated for the new error path), documents and both language packs. `tools/checks/h3_navigation_0316.py` also passed. The script then exited **127** solely because Rust/Cargo is unavailable. `GOPROXY=off go test ./...` in `docker-agent`, `bash -n dev.sh` and OpenAPI JSON parsing passed. Source guards are not execution of Rust tests.
+
+## 0.31.5 follow-up - mobile Stored Logs, H3 peer aborts, cache preservation (2026-10-11)
+
+- **Problem 1, mobile `/logs`:** narrow pages grew horizontally under dynamically selected log columns and large JSON/error fields. CSS now scopes width to the Logs workspace, presents entries as two-column, keyboard-expandable cards at widths `<=700px`, preserves every user-selected field in its card, stacks dense filters, wraps long values, and retains independently horizontally scrollable code/aggregate data. Details and Raw JSON remain accessible. Existing row interaction and per-user column choices are unchanged.
+- **Problem 2, H3 synthetic status:** `resolver.resolve_request()` returned `StreamError::RemoteTerminate(H3_REQUEST_CANCELLED)` for a browser-cancelled stream before request headers; RustProxy previously called `log_protocol_error(..., 400, ...)`, making a *transport cancellation* appear to be an application HTTP 400 with no method/path. Now peer cancellations (`H3_REQUEST_CANCELLED` or peer `H3_NO_ERROR`) are low-level debug observations, while malformed messages still retain an attributed HTTP 400. Response send errors directly classified as peer cancellations no longer provoke a redundant `H3_INTERNAL_ERROR` reset. We deliberately retain the old `ReceiveBody::Drop` safeguards against h3/quinn pending-read panic.
+- **Important limit:** the supplied log cannot establish why the new navigation rendered a blank page; an old request may simply have been cancelled by F5. Missing HTTP method, path, authority, User-Agent, and 0 sent bytes prevent correlation to the failed top-level document. The *root cause of the blank page has not been reproduced or verified*. Validate Safari and Chrome with actual RustProxy QUIC/HTTPS, the reverse-proxy Host/Origin/`X-Forwarded-Proto` chain, browser Network waterfall (especially HTML and fingerprinted JS/CSS), and connection-level debug/qlog before accepting the fix as complete.
+- **Problem 3, cache:** `cache_revision()` used to hash complete vhost configuration, including `http2_enabled` and `http3_enabled`; an unrelated frontend protocol toggle rotated the global compatibility identity and triggered `CacheInvalidation::All`, flushing HOT and COLD. Normalize these two flags to the default `true` only for the cache-identity digest; live routing and serialized configuration remain unmodified. Rust tests cover H2, H3, combined toggles and a true cache-policy edit.
+- **Problem 4, public origin vs allowlist:** public HTTPS origin fixes scheme + host + port for security checks behind a *trusted* proxy (and implicitly allows its DNS host); extra allowed DNS hosts only expand allowed names. Retain both; improve Polish and English help text, no API/SQLite format changes.
+- **Verification, 2026-10-11:** `./dev.sh test` passed its entire available Python/SQL/schema/upgrade/security/security-dependency/Node/docs/language pre-Cargo suite, including new `h3_cache_mobile_0315_test.mjs`, then exited **127** because `cargo` is absent. `./dev.sh check` similarly exited **127** for missing Cargo. `GOPROXY=off go test ./...` in `docker-agent` passed. `bash -n dev.sh`, Node syntax, JSON syntax and focused source gates also passed. Rust unit tests were **not compiled or executed**.
+- **Browser layout smoke:** headless Chromium/Python Playwright rendered a synthetic fixture with the actual HTML/CSS log workspace at widths **320, 375, 430, 700, 768, 1024px**. In every case `body.scrollWidth === innerWidth`; cards were grid rows at `<=700px`, with desktop table rows at 768px+. Parsed fields, long unbroken values and raw JSON were included. This was a *static layout simulation*, **not** authenticated panel/live H3 acceptance.
## 0.31.5 follow-up - Stored Logs navigation polish
diff --git a/install-debian13.sh b/install-debian13.sh
old mode 100644
new mode 100755
diff --git a/install.sh b/install.sh
old mode 100644
new mode 100755
diff --git a/integrations/opnsense-acme/opnsense-rustproxy-cert-deploy.sh b/integrations/opnsense-acme/opnsense-rustproxy-cert-deploy.sh
old mode 100644
new mode 100755
diff --git a/integrations/routeros-oci-build/build-routeros-oci.sh b/integrations/routeros-oci-build/build-routeros-oci.sh
old mode 100644
new mode 100755
diff --git a/integrations/routeros-oci-build/docker-entrypoint.sh b/integrations/routeros-oci-build/docker-entrypoint.sh
old mode 100644
new mode 100755
diff --git a/langs/en_US.json b/langs/en_US.json
index 1478a03..c3ced38 100644
--- a/langs/en_US.json
+++ b/langs/en_US.json
@@ -2336,7 +2336,7 @@
"administrationPublicOrigin": "Public panel HTTPS origin (optional)",
"administrationHostsAdvanced": "Advanced: additional allowed DNS hostnames",
"administrationHostsHelp": "Enter only additional hostnames. The public panel URL is included automatically. Without that URL, this remains an independent strict allowlist.",
- "administrationPublicOriginHelp": "Behind a trusted reverse proxy, enter the external HTTPS panel URL once. Its hostname is automatically allowed. Restart required; public proxy routing is unchanged.",
+ "administrationPublicOriginHelp": "Pins the browser-facing HTTPS scheme, hostname and optional port for Origin/CSRF checks behind a trusted reverse proxy; this is not just a DNS allowlist. The hostname is automatically allowed. Restart required.",
"userAgentBlocking": "User-Agent blocking",
"globalUaBlockingTitle": "Global User-Agent blocklist",
"globalUaBlockingHelp": "Blocks matching User-Agent values on public proxy vhosts before an upstream is opened. Per-vhost lists are configured in Vhosts → Security.",
diff --git a/langs/pl_pl.json b/langs/pl_pl.json
index 6e688a5..3e548a5 100644
--- a/langs/pl_pl.json
+++ b/langs/pl_pl.json
@@ -2336,7 +2336,7 @@
"administrationPublicOrigin": "Publiczny adres HTTPS panelu (opcjonalnie)",
"administrationHostsAdvanced": "Zaawansowane: dodatkowe dozwolone hosty DNS",
"administrationHostsHelp": "Wpisz wyłącznie dodatkowe domeny. Domena z publicznego adresu panelu jest dozwolona automatycznie. Bez tego adresu lista pozostaje niezależnym ścisłym ograniczeniem.",
- "administrationPublicOriginHelp": "Za zaufanym reverse proxy wpisz tylko publiczny adres HTTPS panelu. Jego domena zostanie dozwolona automatycznie. Wymagany restart; ruch proxy bez zmian.",
+ "administrationPublicOriginHelp": "Za zaufanym reverse proxy ustala publiczny schemat HTTPS, domenę i opcjonalny port używane do kontroli Origin/CSRF. To nie tylko lista dozwolonych domen. Domena jest dopuszczana automatycznie. Wymagany restart.",
"userAgentBlocking": "Blokowanie User-Agent",
"globalUaBlockingTitle": "Globalna lista blokowanych User-Agent",
"globalUaBlockingHelp": "Blokuje pasujące wartości User-Agent na publicznych vhostach proxy przed otwarciem połączenia do upstreamu. Listy per vhost są w Vhosts → Security.",
diff --git a/make_zip.py b/make_zip.py
old mode 100644
new mode 100755
diff --git a/nagios/check_rustproxy.py b/nagios/check_rustproxy.py
old mode 100644
new mode 100755
diff --git a/nagios/check_rustproxy_api b/nagios/check_rustproxy_api
old mode 100644
new mode 100755
diff --git a/nagios/check_rustproxy_backends b/nagios/check_rustproxy_backends
old mode 100644
new mode 100755
diff --git a/nagios/check_rustproxy_latency b/nagios/check_rustproxy_latency
old mode 100644
new mode 100755
diff --git a/nagios/check_rustproxy_queues b/nagios/check_rustproxy_queues
old mode 100644
new mode 100755
diff --git a/scripts/build/swagger_assets.py b/scripts/build/swagger_assets.py
old mode 100644
new mode 100755
diff --git a/scripts/deploy/deploy.py b/scripts/deploy/deploy.py
old mode 100644
new mode 100755
diff --git a/scripts/deploy/deploy_core.py b/scripts/deploy/deploy_core.py
old mode 100644
new mode 100755
diff --git a/scripts/deploy/deploy_runtime.py b/scripts/deploy/deploy_runtime.py
old mode 100644
new mode 100755
diff --git a/scripts/deploy/upgrade.py b/scripts/deploy/upgrade.py
old mode 100644
new mode 100755
diff --git a/scripts/migration/migrate_npm.py b/scripts/migration/migrate_npm.py
old mode 100644
new mode 100755
diff --git a/scripts/migration/migrate_npm_api.py b/scripts/migration/migrate_npm_api.py
old mode 100644
new mode 100755
diff --git a/scripts/migration/migrate_npm_core.py b/scripts/migration/migrate_npm_core.py
old mode 100644
new mode 100755
diff --git a/scripts/resolve_dependencies_0312.sh b/scripts/resolve_dependencies_0312.sh
old mode 100644
new mode 100755
index 5ba9edc..1c2fe65
--- a/scripts/resolve_dependencies_0312.sh
+++ b/scripts/resolve_dependencies_0312.sh
@@ -8,8 +8,8 @@ if ! command -v cargo >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; the
exit 127
fi
python3 tools/checks/preflight.py
-if [[ "$(python3 scripts/version.py)" != '0.31.5' ]]; then
- printf 'Expected RustProxy 0.31.5 sources.\n' >&2
+if [[ "$(python3 scripts/version.py)" != '0.31.6' ]]; then
+ printf 'Expected RustProxy 0.31.6 sources.\n' >&2
exit 1
fi
printf 'Toolchain: '; rustc --version
diff --git a/scripts/runtime/acme_provider.py b/scripts/runtime/acme_provider.py
old mode 100644
new mode 100755
diff --git a/scripts/runtime/acme_rfc2136.py b/scripts/runtime/acme_rfc2136.py
old mode 100644
new mode 100755
diff --git a/scripts/version.py b/scripts/version.py
old mode 100644
new mode 100755
diff --git a/src/admin/documentation/documents.rs b/src/admin/documentation/documents.rs
index e641ba3..61334d6 100644
--- a/src/admin/documentation/documents.rs
+++ b/src/admin/documentation/documents.rs
@@ -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.6.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.5.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.4.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.3.md", "technical"),
diff --git a/src/admin/openapi.json b/src/admin/openapi.json
index 3fba555..96a1b4b 100644
--- a/src/admin/openapi.json
+++ b/src/admin/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
- "version": "0.31.5",
+ "version": "0.31.6",
"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": [
diff --git a/src/admin/web/app/core.js b/src/admin/web/app/core.js
index c45b279..d6e6808 100644
--- a/src/admin/web/app/core.js
+++ b/src/admin/web/app/core.js
@@ -463,6 +463,15 @@ function setMobileMenu(open) {
}
mobileMenuToggle?.addEventListener("click", event => { event.stopPropagation(); setMobileMenu(!dashboardSidebar.classList.contains("mobile-nav-open")); });
document.querySelectorAll(".nav").forEach(button => button.addEventListener("click", () => { setPanel(button.dataset.panel); setMobileMenu(false); }));
+// The brand is a Home navigation action, not a hard reload of the HTTP/3
+// document. Preserve href="/" as the no-JS and modified-click fallback.
+document.querySelector('.brand[href="/"]')?.addEventListener("click", event => {
+ if (!authenticated || event.defaultPrevented || event.button !== 0 ||
+ event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
+ event.preventDefault();
+ setPanel("overview");
+ setMobileMenu(false);
+});
document.addEventListener("click", event => { if (dashboardSidebar?.classList.contains("mobile-nav-open") && !dashboardSidebar.contains(event.target)) setMobileMenu(false); });
document.addEventListener("keydown", event => { if (event.key === "Escape" && dashboardSidebar?.classList.contains("mobile-nav-open")) { setMobileMenu(false); mobileMenuToggle?.focus(); } });
window.addEventListener("popstate", () => { setPanel(panelFromLocation(), false); setMobileMenu(false); });
diff --git a/src/admin/web/file-logs.js b/src/admin/web/file-logs.js
index 4559d09..e14dabe 100644
--- a/src/admin/web/file-logs.js
+++ b/src/admin/web/file-logs.js
@@ -238,8 +238,15 @@
entries.forEach((entry, index) => {
const row = el("tr"); row.tabIndex = 0; row.classList.toggle("selected", selectedIndex === index);
row.setAttribute("aria-expanded", selectedIndex === index ? "true" : "false");
+ row.setAttribute("aria-label", entrySummary(entry));
for (const id of ids) {
- const value = columnValue(entry, id), cell = el("td", value); cell.title = value; row.append(cell);
+ const value = columnValue(entry, id), cell = el("td", value); cell.title = value;
+ // Mobile uses compact, expandable cards instead of a width-growing
+ // table. All parsed fields remain accessible in the detail row.
+ cell.dataset.mobileColumn = id;
+ const label = columnLabel(id);
+ cell.dataset.mobileLabel = label;
+ row.append(cell);
}
const select = () => { selectedIndex = selectedIndex === index ? null : index; renderRows(); };
row.addEventListener("click", select);
diff --git a/src/admin/web/index.html b/src/admin/web/index.html
index e4529c4..a09d231 100644
--- a/src/admin/web/index.html
+++ b/src/admin/web/index.html
@@ -2758,7 +2758,7 @@
- When behind a trusted reverse proxy, enter the panel's external HTTPS address once. Its DNS name is automatically allowed. A restart is required.
+ Pins the browser-facing HTTPS scheme, hostname and optional port for Origin/CSRF checks behind a trusted reverse proxy; it is not only a DNS allowlist. The hostname is automatically allowed. Restart required.