v0.32.6_fix1
This commit is contained in:
1 parent
a3d183bd4b
commit
e3de084149
34 files changed
+559
-85
No files matched your search
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.32.5",
|
||||
"version": "0.32.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": [
|
||||
|
||||
@@ -96,7 +96,12 @@ function applyDashboardLayout(order, hidden) {
|
||||
const normalizedOrder = normalizeDashboardWidgets(order), hiddenSet = new Set(normalizeHiddenDashboardWidgets(hidden));
|
||||
const grid = document.querySelector("#panel-overview .stats-grid"); if (!grid) return { order: normalizedOrder, hidden: [...hiddenSet] };
|
||||
const nodes = new Map([...grid.querySelectorAll("[data-dashboard-widget]")].map(node => [node.dataset.dashboardWidget, node]));
|
||||
for (const id of normalizedOrder) { const node = nodes.get(id); if (!node) continue; node.hidden = hiddenSet.has(id); grid.append(node); }
|
||||
// Re-appending an already ordered live grid can break touch-scroll
|
||||
// anchoring in mobile browsers. Move the DOM only on a real order change.
|
||||
const orderedNodes = normalizedOrder.map(id => nodes.get(id)).filter(Boolean);
|
||||
for (const node of orderedNodes) node.hidden = hiddenSet.has(node.dataset.dashboardWidget);
|
||||
const currentNodes = [...grid.querySelectorAll("[data-dashboard-widget]")];
|
||||
if (orderedNodes.some((node, index) => node !== currentNodes[index])) grid.append(...orderedNodes);
|
||||
window.rustProxyStreamDashboard?.syncVisibility?.([...hiddenSet]);
|
||||
window.rustProxyBackendDashboard?.syncVisibility?.([...hiddenSet]);
|
||||
window.rustProxyMasonry?.refresh?.();
|
||||
|
||||
+60
-9
@@ -10399,9 +10399,11 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
#panel-overview .dashboard-overview-grid > .stat { grid-column: span 1; }
|
||||
#panel-overview .dashboard-overview-grid > .card:not(.pool-card) { grid-column: 1 / -1; }
|
||||
}
|
||||
/* Even before the aligned layout script starts, phone-width Overview tiles
|
||||
stay in two columns. A late 1-to-2-column switch can shift scroll position. */
|
||||
@media (max-width: 550px) {
|
||||
#panel-overview .dashboard-overview-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
#panel-overview .dashboard-overview-grid > .stat { grid-column: 1; }
|
||||
#panel-overview .dashboard-overview-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
#panel-overview .dashboard-overview-grid > .stat { grid-column: auto / span 1; }
|
||||
}
|
||||
.overview-layout-tile.is-panel { grid-column: span 2; }
|
||||
.overview-layout-grid .overview-layout-panel-preview {
|
||||
@@ -10583,6 +10585,9 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
#panel-overview .dashboard-overview-grid.is-aligned.stats-grid {
|
||||
gap: var(--overview-gap);
|
||||
}
|
||||
/* Live metric text can change while the user scrolls; do not allow the
|
||||
browser to re-anchor an entire dashboard against these changing values. */
|
||||
#panel-overview .dashboard-overview-grid.is-aligned { overflow-anchor: none; }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget] {
|
||||
grid-column: auto / span 2;
|
||||
grid-row: auto;
|
||||
@@ -10609,13 +10614,28 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
}
|
||||
@container overview-canvas (max-width: 700px) {
|
||||
#panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: auto / span 1; }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat {
|
||||
grid-column: auto / span 1;
|
||||
touch-action: pan-y pinch-zoom;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* The WebSocket tunnel action is absolutely positioned on desktop. In a
|
||||
half-width phone tile it overlaps the label and value unless it flows. */
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > .stat.stat-with-action { padding-right: 11px; }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned .stat-with-action .stat-corner-action {
|
||||
position: static;
|
||||
display: block;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
margin: 6px 0 0;
|
||||
white-space: normal;
|
||||
}
|
||||
html[data-dashboard-density="normal"] #panel-overview .dashboard-overview-grid.is-aligned > .stat strong,
|
||||
html[data-dashboard-density="compact"] #panel-overview .dashboard-overview-grid.is-aligned > .stat strong {
|
||||
font-size: clamp(18px, 5.5vw, 25px);
|
||||
}
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > .card:not(.pool-card) { grid-column: 1 / -1; }
|
||||
}
|
||||
@container overview-canvas (max-width: 440px) {
|
||||
#panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: minmax(0, 1fr); }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: 1; }
|
||||
}
|
||||
/* Keep category boundaries intact at every responsive breakpoint. */
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].overview-row-start { grid-column-start: 1; }
|
||||
|
||||
@@ -10691,8 +10711,10 @@ html[data-corner-style="extra"] { --rp-radius-surface: 18px; --rp-radius-control
|
||||
html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner-style="rounded"], [data-corner-style="extra"]) :is(
|
||||
.card, .stat, dialog, .login-card, .appearance-section, .settings-tabs,
|
||||
.dashboard-widget-setting, .overview-layout-tile, .overview-layout-hidden-item,
|
||||
.overview-inline-hidden-item, .notice, .toast,
|
||||
.chart-style-choice, .stream-dashboard-card, .pool-card,
|
||||
.overview-inline-hidden-item, .overview-inline-hidden, .notice, .toast,
|
||||
.chart-style-choice, .stream-dashboard-card, .pool-card, .vhost-card,
|
||||
.vhost-backend-protocol-badge, .vh-backend-card, .entity, .settings-filter-row,
|
||||
.stream-backend-row,
|
||||
.file-log-advanced-parser, .table-container, .metric-info-popover
|
||||
) { border-radius: var(--rp-radius-surface) !important; }
|
||||
html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner-style="rounded"], [data-corner-style="extra"]) :is(
|
||||
@@ -10708,3 +10730,32 @@ html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner
|
||||
/* ACL DDNS input: keep the rule type separate from its hostname. */
|
||||
.acl-profile-rule { grid-template-columns: minmax(100px, .6fr) minmax(105px, .6fr) minmax(175px, 1.5fr) auto; }
|
||||
@media (max-width: 640px) { .acl-profile-rule { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Latest HTTP version observed on a real request to each configured origin.
|
||||
A badge without an observation must not claim that Auto selected H1 or H2. */
|
||||
.vhost-backend-protocols {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.vhost-backend-protocol-badge {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: baseline;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--pill-line);
|
||||
background: var(--panel2);
|
||||
color: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 3px 7px;
|
||||
font: 10px var(--mono);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.vhost-backend-protocol-badge[data-observed="true"] {
|
||||
color: var(--green);
|
||||
border-color: var(--pill-live-line);
|
||||
}
|
||||
@@ -101,6 +101,30 @@ async function toggleVhostEnabled(vhost) {
|
||||
if (await saveSection("vhosts", next.vhosts || [])) render();
|
||||
}
|
||||
|
||||
const VHOST_PROTOCOL_FRESH_MS = 5 * 60 * 1000;
|
||||
function upstreamProtocolBadgeText(backend, member, now = Date.now()) {
|
||||
const mode = { http1: "H1", auto: "AUTO", http2: "H2 compatible", h2: "H2 only" }[backend.protocol || "http1"] || "H1";
|
||||
const seen = Number(member?.upstream_protocol_last_seen_ms);
|
||||
const recent = member && (member.upstream_protocol_observed === "h1" || member.upstream_protocol_observed === "h2") &&
|
||||
Number.isFinite(seen) && seen > 0 && now >= seen && now - seen <= VHOST_PROTOCOL_FRESH_MS;
|
||||
return { label: `${backend.id} · ${mode} → ${recent ? member.upstream_protocol_observed.toUpperCase() : "?"}`, recent };
|
||||
}
|
||||
function renderUpstreamProtocolBadges(status) {
|
||||
const members = new Map((status.backend_pools || []).flatMap(pool => (pool.members || []).map(member =>
|
||||
[`${pool.id}\u0000${member.id}`, member])));
|
||||
const now = Date.now();
|
||||
document.querySelectorAll("[data-upstream-protocol-vhost]").forEach(node => {
|
||||
const vhost = node.dataset.upstreamProtocolVhost, id = node.dataset.upstreamProtocolBackend;
|
||||
const backend = (envelope?.config.vhosts || []).find(host => host.id === vhost)?.backends?.find(item => item.id === id);
|
||||
if (!backend) return;
|
||||
const member = members.get(`${vhost}\u0000${id}`);
|
||||
const observed = upstreamProtocolBadgeText(backend, member, now);
|
||||
node.textContent = observed.label;
|
||||
node.dataset.observed = String(!!observed.recent);
|
||||
const counts = member && observed.recent ? ` H1: ${member.upstream_protocol_http1_total || 0}; H2: ${member.upstream_protocol_http2_total || 0}.` : "";
|
||||
node.title = `${t(observed.recent ? "vhBackendProtocolRecent" : "vhBackendProtocolUnobserved")}${counts}`;
|
||||
});
|
||||
}
|
||||
function render() {
|
||||
if (!envelope) return;
|
||||
if (!initialized) load(defaults(), [], false);
|
||||
@@ -187,6 +211,19 @@ function render() {
|
||||
];
|
||||
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
|
||||
box.append(details);
|
||||
if ((v.backends || []).length && (v.source?.type !== "web_server" || (v.routes || []).some(route =>
|
||||
!route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"))) {
|
||||
const badges = el("div", undefined, "vhost-backend-protocols");
|
||||
for (const backend of v.backends) {
|
||||
const badge = el("span", undefined, "vhost-backend-protocol-badge");
|
||||
badge.dataset.upstreamProtocolVhost = v.id;
|
||||
badge.dataset.upstreamProtocolBackend = backend.id;
|
||||
badge.textContent = upstreamProtocolBadgeText(backend, null).label;
|
||||
badge.title = t("vhBackendProtocolUnobserved");
|
||||
badges.append(badge);
|
||||
}
|
||||
box.append(badges);
|
||||
}
|
||||
|
||||
const live = el("div", undefined, "vhost-live-grid");
|
||||
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); };
|
||||
@@ -246,6 +283,7 @@ function vhostTrafficRate(id, metrics, snapshot, now, seen) {
|
||||
return rate;
|
||||
}
|
||||
function renderStatus(status) {
|
||||
renderUpstreamProtocolBadges(status);
|
||||
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
|
||||
const rows = [], summaries = new Map(), aggregate = new Map();
|
||||
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
|
||||
|
||||
@@ -85,6 +85,13 @@ impl Pool {
|
||||
"upstream_client_rotations_total".into(),
|
||||
json!(client_rotation.rotations),
|
||||
);
|
||||
// Negotiated HTTP version from real backend response headers,
|
||||
// never from the configured Auto/HTTP2 policy or health probes.
|
||||
let observed = m.backend.client.protocol_stats();
|
||||
item.insert("upstream_protocol_observed".into(), json!(observed.last_version));
|
||||
item.insert("upstream_protocol_last_seen_ms".into(), json!(observed.observed_at_ms));
|
||||
item.insert("upstream_protocol_http1_total".into(), json!(observed.http1_responses));
|
||||
item.insert("upstream_protocol_http2_total".into(), json!(observed.http2_responses));
|
||||
item.insert("latency_ms".into(), json!(h.latency_us as f64 / 1000.0));
|
||||
item.insert("ewma_latency_ms".into(), json!(h.ewma_us as f64 / 1000.0));
|
||||
item.insert("last_probe_ms".into(), json!(h.last_probe_ms));
|
||||
|
||||
+55
-2
@@ -6,7 +6,7 @@ use crate::config::{Backend, UpstreamProtocol};
|
||||
use anyhow::{Context, Result};
|
||||
use arc_swap::ArcSwap;
|
||||
use axum::body::Body;
|
||||
use http::{Request, Response};
|
||||
use http::{Request, Response, Version};
|
||||
use hyper::body::Incoming;
|
||||
use hyper_rustls::HttpsConnectorBuilder;
|
||||
use hyper_util::{
|
||||
@@ -50,6 +50,10 @@ struct RotatingClient {
|
||||
generation_started_ms: AtomicU64,
|
||||
rotations: AtomicU64,
|
||||
rotating: AtomicBool,
|
||||
observed_version: AtomicU64,
|
||||
observed_at_ms: AtomicU64,
|
||||
observed_h1: AtomicU64,
|
||||
observed_h2: AtomicU64,
|
||||
}
|
||||
|
||||
/// Backend HTTP client with bounded generation lifetime.
|
||||
@@ -71,6 +75,14 @@ pub struct ClientRotationStats {
|
||||
pub rotations: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UpstreamProtocolStats {
|
||||
pub last_version: Option<&'static str>,
|
||||
pub observed_at_ms: u64,
|
||||
pub http1_responses: u64,
|
||||
pub http2_responses: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProxyClient {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
@@ -91,13 +103,25 @@ impl ProxyClient {
|
||||
generation_started_ms: AtomicU64::new(monotonic_millis()),
|
||||
rotations: AtomicU64::new(0),
|
||||
rotating: AtomicBool::new(false),
|
||||
observed_version: AtomicU64::new(0),
|
||||
observed_at_ms: AtomicU64::new(0),
|
||||
observed_h1: AtomicU64::new(0),
|
||||
observed_h2: AtomicU64::new(0),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request(&self, request: Request<Body>) -> Result<Response<Incoming>, ClientError> {
|
||||
let client = self.inner.client_for_request();
|
||||
client.request(request).await
|
||||
let response = client.request(request).await;
|
||||
if let Ok(value) = &response {
|
||||
self.inner.observe_protocol(value.version());
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
pub fn protocol_stats(&self) -> UpstreamProtocolStats {
|
||||
self.inner.protocol_stats()
|
||||
}
|
||||
|
||||
pub fn rotation_stats(&self) -> ClientRotationStats {
|
||||
@@ -119,6 +143,35 @@ impl ProxyClient {
|
||||
}
|
||||
|
||||
impl RotatingClient {
|
||||
fn observe_protocol(&self, version: Version) {
|
||||
let code = match version {
|
||||
Version::HTTP_2 => 2,
|
||||
Version::HTTP_10 | Version::HTTP_11 => 1,
|
||||
_ => return,
|
||||
};
|
||||
if code == 2 {
|
||||
self.observed_h2.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.observed_h1.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
self.observed_version.store(code, Ordering::Relaxed);
|
||||
self.observed_at_ms
|
||||
.store(crate::logging::now_ms(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn protocol_stats(&self) -> UpstreamProtocolStats {
|
||||
UpstreamProtocolStats {
|
||||
last_version: match self.observed_version.load(Ordering::Relaxed) {
|
||||
1 => Some("h1"),
|
||||
2 => Some("h2"),
|
||||
_ => None,
|
||||
},
|
||||
observed_at_ms: self.observed_at_ms.load(Ordering::Relaxed),
|
||||
http1_responses: self.observed_h1.load(Ordering::Relaxed),
|
||||
http2_responses: self.observed_h2.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
fn stats(&self) -> ClientRotationStats {
|
||||
let now = monotonic_millis();
|
||||
ClientRotationStats {
|
||||
|
||||
@@ -11,6 +11,23 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_observation_requires_a_real_http_response_version() {
|
||||
let backend = backend("protocol-observed", 18090);
|
||||
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
|
||||
assert_eq!(client.protocol_stats().last_version, None);
|
||||
let shared = client.clone();
|
||||
// Exercise the response-version recorder independently of the network;
|
||||
// a live TLS ALPN acceptance test must still run on staging.
|
||||
shared.inner.observe_protocol(Version::HTTP_11);
|
||||
shared.inner.observe_protocol(Version::HTTP_2);
|
||||
let observed = client.protocol_stats();
|
||||
assert_eq!(observed.last_version, Some("h2"));
|
||||
assert_eq!(observed.http1_responses, 1);
|
||||
assert_eq!(observed.http2_responses, 1);
|
||||
assert!(observed.observed_at_ms > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_recycles_after_request_budget() {
|
||||
let backend = backend("rotation-test", 18081);
|
||||
|
||||
Reference in new issue
Block a user