This commit is contained in:
Mateusz Gruszczyński committed 2026-11-07 13:13:25 +01:00
1 parent 3a384baa6b
commit 672c84bc13
21 files changed
+450 -70

No files matched your search

+52 -18
View File
@@ -237,11 +237,22 @@ async fn events(
#[serde(default, deny_unknown_fields)]
struct TrafficQuery {
vhost: Option<String>,
view: Option<String>,
}
async fn traffic_status(
State(state): State<Arc<App>>,
Query(query): Query<TrafficQuery>,
) -> Result<Json<Value>, ApiError> {
let live = match query.view.as_deref() {
None => false,
Some("live") => true,
_ => {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"invalid traffic view".into(),
));
}
};
let snapshot = state.config.load_full();
if query.vhost.as_ref().is_some_and(|id| {
id != "*"
@@ -258,14 +269,8 @@ async fn traffic_status(
));
}
let scope = query.vhost.as_deref().unwrap_or("*");
let vhosts = snapshot
.envelope
.config
.vhosts
.iter()
.map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts}))
.collect::<Vec<_>>();
Ok(Json(json!({
let mut payload = json!({
"revision": snapshot.envelope.revision,
"scope": scope,
"since": "process_start",
"settings": {
@@ -273,9 +278,19 @@ async fn traffic_status(
"capture_country": snapshot.envelope.config.logging.capture_country
},
"geoip": state.geoip.load().view(),
"analytics": state.traffic.view(Some(scope)),
"vhosts": vhosts
})))
"analytics": state.traffic.view(Some(scope))
});
if live {
return Ok(Json(payload));
}
payload["vhosts"] = json!(snapshot
.envelope
.config
.vhosts
.iter()
.map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts}))
.collect::<Vec<_>>());
Ok(Json(payload))
}
#[derive(Default, Deserialize)]
@@ -284,11 +299,22 @@ struct CacheQuery {
vhost: Option<String>,
offset: Option<usize>,
limit: Option<usize>,
view: Option<String>,
}
async fn cache_status(
State(state): State<Arc<App>>,
Query(query): Query<CacheQuery>,
) -> Result<Json<Value>, ApiError> {
let live = match query.view.as_deref() {
None => false,
Some("live") => true,
_ => {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"invalid cache view".into(),
));
}
};
let snapshot = state.config.load_full();
if query
.vhost
@@ -300,16 +326,24 @@ async fn cache_status(
"unknown vhost cache scope".into(),
));
}
let scopes: Vec<_> = snapshot.envelope.config.vhosts.iter().map(|vhost| {
json!({"id":vhost.id, "hosts":vhost.frontend.hosts, "metrics":state.cache.scope_view(&vhost.id),
"settings":vhost.cache.resolve(&snapshot.envelope.config.cache).ok()})
}).collect();
let dashboard = state.cache.dashboard(
query.vhost.as_deref(),
query.offset.unwrap_or(0).min(16384),
query.limit.unwrap_or(50),
);
Ok(Json(
json!({"settings":snapshot.envelope.config.cache, "metrics":state.cache.view(), "vhosts":scopes, "dashboard":dashboard, "bans":state.cache.bans()}),
))
let mut payload = json!({
"revision": snapshot.envelope.revision,
"metrics": state.cache.view(),
"dashboard": dashboard,
"bans": state.cache.bans()
});
if live {
return Ok(Json(payload));
}
payload["settings"] = json!(snapshot.envelope.config.cache);
payload["vhosts"] = json!(snapshot.envelope.config.vhosts.iter().map(|vhost| {
json!({"id":vhost.id, "hosts":vhost.frontend.hosts, "metrics":state.cache.scope_view(&vhost.id),
"settings":vhost.cache.resolve(&snapshot.envelope.config.cache).ok()})
}).collect::<Vec<_>>());
Ok(Json(payload))
}
+60 -7
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.35.5",
"version": "0.36.0",
"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. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
},
"servers": [
@@ -1618,6 +1618,11 @@
"Cache": {
"type": "object",
"properties": {
"revision": {
"type": "integer",
"format": "int64",
"description": "Configuration revision used for this snapshot."
},
"settings": {
"type": "object",
"properties": {},
@@ -4790,6 +4795,11 @@
"type": "object",
"additionalProperties": false,
"properties": {
"revision": {
"type": "integer",
"format": "int64",
"description": "Configuration revision used for this snapshot."
},
"settings": {
"$ref": "#/components/schemas/TransformSettings"
},
@@ -6194,6 +6204,11 @@
"type": "object",
"additionalProperties": false,
"properties": {
"revision": {
"type": "integer",
"format": "int64",
"description": "Configuration revision used for this snapshot."
},
"scope": {
"type": "string"
},
@@ -9872,14 +9887,14 @@
},
"/api/cache": {
"get": {
"summary": "Cache metrics, domains and paginated objects",
"summary": "Cache metrics, domains and paginated objects; optional lightweight live view",
"tags": [
"Cache"
],
"operationId": "get_api_cache",
"responses": {
"200": {
"description": "Successful response",
"description": "Full response by default. view=live omits static settings/vhost configuration while retaining revision, live metrics, dashboard objects/domains and BAN rules.",
"content": {
"application/json": {
"schema": {
@@ -9960,6 +9975,18 @@
"minimum": 1,
"default": 50
}
},
{
"name": "view",
"in": "query",
"required": false,
"description": "Set to live for polling. Omits static settings and vhost configuration; refetch the full view when revision changes.",
"schema": {
"type": "string",
"enum": [
"live"
]
}
}
],
"security": [
@@ -9974,14 +10001,14 @@
},
"/api/transform": {
"get": {
"summary": "Transformation runtime metrics and per-vhost effectiveness",
"summary": "Transformation runtime metrics and per-vhost effectiveness; optional lightweight live view",
"tags": [
"Transform"
],
"operationId": "get_api_transform",
"responses": {
"200": {
"description": "Successful response",
"description": "Full response by default. view=live omits static settings/vhost configuration while retaining revision, resources and live metrics.",
"content": {
"application/json": {
"schema": {
@@ -10031,6 +10058,20 @@
}
}
},
"parameters": [
{
"name": "view",
"in": "query",
"required": false,
"description": "Set to live for polling. Omits static settings and vhost configuration; refetch the full view when revision changes.",
"schema": {
"type": "string",
"enum": [
"live"
]
}
}
],
"security": [
{
"AdminSession": []
@@ -12884,7 +12925,7 @@
},
"/api/traffic": {
"get": {
"summary": "Bounded per-vhost traffic analytics",
"summary": "Bounded per-vhost traffic analytics; optional lightweight live view",
"tags": [
"Traffic"
],
@@ -12899,11 +12940,23 @@
"type": "string",
"default": "*"
}
},
{
"name": "view",
"in": "query",
"required": false,
"description": "Set to live for polling. Omits the static vhost list; refetch the full view when revision changes.",
"schema": {
"type": "string",
"enum": [
"live"
]
}
}
],
"responses": {
"200": {
"description": "Successful response",
"description": "Full response by default. view=live omits the static vhost list while retaining revision, current settings, GeoIP state and analytics.",
"content": {
"application/json": {
"schema": {
+34 -5
View File
@@ -1,6 +1,34 @@
async fn transform_status(State(state): State<Arc<App>>) -> Json<Value> {
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct TransformQuery {
view: Option<String>,
}
async fn transform_status(
State(state): State<Arc<App>>,
Query(query): Query<TransformQuery>,
) -> Result<Json<Value>, ApiError> {
let live = match query.view.as_deref() {
None => false,
Some("live") => true,
_ => {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"invalid transform view".into(),
));
}
};
let snapshot = state.config.load_full();
let global = &snapshot.envelope.config.transform;
let resources = crate::transform::resource_usage(global);
let metrics = state.transform_metrics.view();
if live {
return Ok(Json(json!({
"revision": snapshot.envelope.revision,
"resources": resources,
"metrics": metrics
})));
}
let vhosts: Vec<_> = snapshot
.envelope
.config
@@ -14,12 +42,13 @@ async fn transform_status(State(state): State<Arc<App>>) -> Json<Value> {
})
})
.collect();
Json(json!({
Ok(Json(json!({
"revision": snapshot.envelope.revision,
"settings": global,
"resources": crate::transform::resource_usage(global),
"metrics": state.transform_metrics.view(),
"resources": resources,
"metrics": metrics,
"vhosts": vhosts
}))
})))
}
#[derive(Default, Deserialize)]
+85
View File
@@ -3527,6 +3527,91 @@ mod tests {
store.close().await;
}
#[tokio::test]
async fn polling_views_omit_static_configuration_and_reject_unknown_modes() {
let (router, store, _dir, secret, _) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
for (path, omitted, retained) in [
(
"/api/cache?view=live",
&["settings", "vhosts"][..],
&["revision", "metrics", "dashboard", "bans"][..],
),
(
"/api/transform?view=live",
&["settings", "vhosts"][..],
&["revision", "resources", "metrics"][..],
),
(
"/api/traffic?view=live",
&["vhosts"][..],
&["revision", "settings", "geoip", "analytics"][..],
),
] {
let response = router
.clone()
.oneshot(
request(path)
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "GET {path}");
let value = json_body(response).await;
for &field in omitted {
assert!(value.get(field).is_none(), "{path} unexpectedly contains {field}");
}
for &field in retained {
assert!(value.get(field).is_some(), "{path} is missing {field}");
}
}
for (path, required) in [
("/api/cache", &["settings", "vhosts"][..]),
("/api/transform", &["settings", "vhosts"][..]),
("/api/traffic", &["vhosts"][..]),
] {
let response = router
.clone()
.oneshot(
request(path)
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "GET {path}");
let value = json_body(response).await;
assert!(value.get("revision").is_some());
for &field in required {
assert!(value.get(field).is_some(), "{path} is missing {field}");
}
}
for path in [
"/api/cache?view=unknown",
"/api/transform?view=unknown",
"/api/traffic?view=unknown",
] {
let response = router
.clone()
.oneshot(
request(path)
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "GET {path}");
}
store.close().await;
}
#[test]
fn defaults_and_partial_legacy_refresh_documents() {
let previous = serde_json::to_value(AdminPreferences::default()).unwrap();
+7 -3
View File
@@ -379,9 +379,13 @@ function renderFooter(s) {
}
let lastLogs = null;
function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); }
async function refresh(force = false) {
async function refresh(force = false, includeLogs = false) {
if (!authenticated) return;
if (force) return Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback")]);
if (force) {
const tasks = [rustProxyRefresh.manual("status-full")];
if (includeLogs) tasks.push(rustProxyRefresh.manual("logs-fallback"));
return Promise.all(tasks);
}
return rustProxyRefresh.manual("status-fallback");
}
function renderLiveStatus(value) { renderStatus(value); }
@@ -525,7 +529,7 @@ async function activate(info) {
void window.rustProxyApiTokens?.refresh?.();
window.rustProxySettings?.scrollToApiTokens?.();
}
void refresh(true).catch(error => {
void refresh(true, true).catch(error => {
if (authenticated && epoch === authEpoch && error?.status !== 401) toast(error.message, true);
});
return true;
+12 -3
View File
@@ -1,6 +1,6 @@
"use strict";
(() => {
let loading = false, offset = 0, generation = 0;
let loading = false, offset = 0, generation = 0, staticData = null, staticRevision = null;
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null;
const liveChartSamples = { hits: [], misses: [], bypasses: [] };
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
@@ -229,7 +229,16 @@
const query = new URLSearchParams({ offset: String(offset), limit: "50" });
if (scope() !== "*") query.set("vhost", scope());
try {
const data = await api(`/api/cache?${query}`, { signal });
const base = `/api/cache?${query}`;
if (staticData) query.set("view", "live");
let data = await api(`/api/cache?${query}`, { signal });
if (staticData && data.revision !== staticRevision) data = await api(base, { signal });
if (data.settings && Array.isArray(data.vhosts)) {
staticData = { settings: data.settings, vhosts: data.vhosts };
staticRevision = data.revision;
} else if (staticData) {
data = { ...staticData, ...data };
}
if (isCurrent() && token === generation) render(data);
} catch (error) {
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
@@ -343,7 +352,7 @@
$("cache-live")?.addEventListener("change", () => rustProxyRefresh.sync());
window.rustProxyCachePanel = {
refresh, configure, reason, reset() {
generation++; offset = 0; resetLiveChart(); closeInvalidation(); closeBans();
generation++; offset = 0; staticData = null; staticRevision = null; resetLiveChart(); closeInvalidation(); closeBans();
$("cache-domain-rows").replaceChildren(); $("cache-object-rows").replaceChildren(); $("cache-ban-rows").replaceChildren();
$("cache-panel-state").textContent = ""; operationMessage("cache-invalidation-status"); operationMessage("cache-bans-status"); operationMessage("cache-toolbar-operation-status");
}
-1
View File
@@ -243,5 +243,4 @@ curl --fail --show-error --request POST \
}
};
renderCertificateApi();
loadCertificateApi();
})();
+3 -1
View File
@@ -50,6 +50,7 @@
window.rustProxyRefresh?.setSettingsTab(name);
if (authenticated && name === "security") void window.rustProxyApiTokens?.refresh?.();
if (authenticated && name === "prometheus") void window.rustProxyPrometheusToken?.refresh?.();
if (authenticated && name === "certificate-api") void window.rustProxyCertificateApi?.load?.();
if (name !== "prometheus") window.rustProxyPrometheusToken?.hideSecret?.();
if (name !== "security") window.rustProxyApiTokens?.hideSecret?.();
}
@@ -251,7 +252,8 @@
$("tls-policy-key-exchange").textContent = kxGroups.length ? `${t("tlsPqcKeyExchangeGroups")}: ${kxGroups.join(", ")}` : "";
$("tls-policy-effective").textContent = effective.length ? `${t("tlsEffectiveCiphers")}: ${effective.join(", ")}` : "";
$("tls-policy-available").textContent = available.length ? `${t("tlsAvailableCiphers")}: ${available.join(", ")}` : "";
window.rustProxyCertificateApi?.render(); window.rustProxyCertificateApi?.load();
window.rustProxyCertificateApi?.render();
if (authenticated && active === "certificate-api") void window.rustProxyCertificateApi?.load?.();
}
$("geoip-format").addEventListener("change", syncGeoipFormat); $("geoip-enabled").addEventListener("change", syncGeoipFormat);
bindSettingsForm("geoip-form", next => {
+13 -3
View File
@@ -1,6 +1,6 @@
"use strict";
(() => {
let loading = false, lastData = null, activeTab = "overview";
let loading = false, lastData = null, activeTab = "overview", staticData = null, staticRevision = null;
const chartPalette = ["var(--chart-accent)", "var(--chart-secondary)", "var(--chart-tertiary)", "var(--chart-quaternary)", "var(--chart-quinary)", "var(--chart-senary)", "var(--chart-septenary)", "var(--chart-octonary)"];
const scope = () => $("traffic-scope")?.value || "*";
const number = value => fmt(Number(value) || 0);
@@ -116,7 +116,17 @@
if (!authenticated || currentPanel !== "traffic" || loading) return;
loading = true;
try {
const data = await api(`/api/traffic?vhost=${encodeURIComponent(scope())}`, { signal });
const base = `/api/traffic?vhost=${encodeURIComponent(scope())}`;
let data = await api(staticData ? `${base}&view=live` : base, { signal });
if (staticData && data.revision !== staticRevision) {
data = await api(base, { signal });
}
if (Array.isArray(data.vhosts)) {
staticData = { vhosts: data.vhosts };
staticRevision = data.revision;
} else if (staticData) {
data = { ...staticData, ...data };
}
if (isCurrent()) render(data);
} catch (error) {
if (error?.name !== "AbortError" && isCurrent()) $("traffic-panel-state").textContent = error.message;
@@ -125,7 +135,7 @@
function refresh() { return rustProxyRefresh.manual("traffic"); }
function reset() {
lastData = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
lastData = null; staticData = null; staticRevision = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
document.querySelectorAll(".traffic-bar-chart").forEach(chart => chart.replaceChildren());
for (const id of ["countries", "methods", "methods-detail", "statuses", "statuses-detail", "cache", "cache-detail"]) {
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), total = $(`traffic-chart-${id}-total`);
+10 -3
View File
@@ -1,6 +1,6 @@
"use strict";
(() => {
let loading = false, generation = 0, lastData = null;
let loading = false, generation = 0, lastData = null, staticData = null, staticRevision = null;
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null;
const liveChartSamples = { applied: [], cache_hits: [], skipped: [], errors: [] };
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
@@ -155,7 +155,14 @@
loading = true;
const epoch = authEpoch, token = generation;
try {
const data = await api("/api/transform", { signal });
let data = await api(staticData ? "/api/transform?view=live" : "/api/transform", { signal });
if (staticData && data.revision !== staticRevision) data = await api("/api/transform", { signal });
if (data.settings && Array.isArray(data.vhosts)) {
staticData = { settings: data.settings, vhosts: data.vhosts };
staticRevision = data.revision;
} else if (staticData) {
data = { ...staticData, ...data };
}
if (isCurrent() && token === generation) render(data);
} catch (error) {
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
@@ -179,7 +186,7 @@
window.rustProxyTransformPanel = {
refresh,
reset() {
generation++; lastData = null; resetLiveChart();
generation++; lastData = null; staticData = null; staticRevision = null; resetLiveChart();
$("transform-type-rows")?.replaceChildren();
$("transform-vhost-rows")?.replaceChildren();
$("transform-reason-list")?.replaceChildren();