This commit is contained in:
Mateusz Gruszczyński committed 2026-10-19 14:05:13 +02:00
1 parent 28e39f7ce4
commit b57beb83c7
34 files changed
+1037 -314

No files matched your search

+5 -1
View File
@@ -26,7 +26,11 @@ fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> b
&& ((method == Method::PUT
&& (path == "/api/config"
|| path.strip_prefix("/api/config/").is_some_and(|part| {
!part.is_empty() && part != "history" && part != "reload" && !part.contains('/')
!part.is_empty()
&& part != "history"
&& part != "reload"
&& part != "storage"
&& !part.contains('/')
})))
|| (method == Method::POST
&& matches!(
+57 -5
View File
@@ -2,8 +2,8 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.1",
"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. Version 0.34.1 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."
"version": "0.34.2",
"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. Version 0.34.2 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": [
{
@@ -954,6 +954,28 @@
"config"
]
},
"ConfigurationRead": {
"type": "object",
"description": "Authenticated GET /api/config response; storage is read-only runtime metadata, excluded from configuration updates and revisioned snapshots.",
"properties": {
"revision": {
"type": "integer",
"format": "int64"
},
"config": {
"$ref": "#/components/schemas/Config"
},
"storage": {
"$ref": "#/components/schemas/ConfigStorage"
}
},
"additionalProperties": false,
"required": [
"revision",
"config",
"storage"
]
},
"Update": {
"type": "object",
"properties": {
@@ -6750,6 +6772,35 @@
]
}
]
},
"ConfigStorage": {
"type": "object",
"description": "Read-only metadata in the GET /api/config response. Null paths indicate that a path is not applicable or unavailable.",
"properties": {
"mode": {
"type": "string",
"enum": [
"sqlite",
"file"
]
},
"config_path": {
"type": "string",
"nullable": true,
"description": "Absolute JSON path only in file mode."
},
"database_path": {
"type": "string",
"nullable": true,
"description": "Private SQLite config/history database path."
}
},
"required": [
"mode",
"config_path",
"database_path"
],
"additionalProperties": false
}
}
},
@@ -7761,7 +7812,8 @@
},
"/api/config": {
"get": {
"summary": "Read the revisioned configuration",
"summary": "Read the revisioned configuration and storage source",
"description": "The storage member reports runtime SQLite/JSON paths and is not part of the writable config or revisioned snapshots.",
"tags": [
"Configuration"
],
@@ -7772,7 +7824,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Envelope"
"$ref": "#/components/schemas/ConfigurationRead"
}
}
}
@@ -12792,4 +12844,4 @@
}
},
"tags": []
}
}
+41 -2
View File
@@ -312,8 +312,47 @@ async fn certificate_inventory(State(state): State<Arc<App>>) -> Json<Value> {
json!({"revision":snapshot.envelope.revision,"certificates":entries,"acme":true, "providers":["http01", "cloudflare", "ovh", "rfc2136"]}),
)
}
async fn configuration(State(state): State<Arc<App>>) -> Json<crate::config::Envelope> {
Json(state.config.load().envelope.clone())
// Storage metadata belongs to GET /api/config, not to the editable `config`
// document or the revisioned envelopes returned by update/restore endpoints.
#[derive(Serialize)]
struct ConfigurationRead {
#[serde(flatten)]
envelope: crate::config::Envelope,
storage: ConfigStorage,
}
#[derive(Serialize)]
struct ConfigStorage {
mode: &'static str,
config_path: Option<String>,
database_path: Option<String>,
}
async fn configuration(State(state): State<Arc<App>>) -> Json<ConfigurationRead> {
let envelope = state.config.load().envelope.clone();
let config_path = state
.config_file
.lock()
.await
.as_ref()
.map(|(path, _)| path.to_string_lossy().into_owned());
let database_path = state
.policy
.data_dir
.as_ref()
.map(|dir| dir.join("config.sqlite3").to_string_lossy().into_owned());
Json(ConfigurationRead {
envelope,
storage: ConfigStorage {
mode: if config_path.is_some() {
"file"
} else {
"sqlite"
},
config_path,
database_path,
},
})
}
#[derive(Deserialize)]
+258 -56
View File
@@ -1664,72 +1664,133 @@ mod tests {
overview_samples: 600,
};
let body = serde_json::to_string(&preset).unwrap();
let anon = router.clone().oneshot(
request("/api/dashboard-presets").body(Body::empty()).unwrap()
).await.unwrap();
let anon = router
.clone()
.oneshot(
request("/api/dashboard-presets")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(anon.status(), StatusCode::UNAUTHORIZED);
let forbidden = router.clone().oneshot(
request("/api/dashboard-presets/Daily").method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("content-type", "application/json")
.body(Body::from(body.clone())).unwrap()
).await.unwrap();
let forbidden = router
.clone()
.oneshot(
request("/api/dashboard-presets/Daily")
.method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("content-type", "application/json")
.body(Body::from(body.clone()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
let saved = router.clone().oneshot(
request("/api/dashboard-presets/Daily").method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.clone())).unwrap()
).await.unwrap();
let saved = router
.clone()
.oneshot(
request("/api/dashboard-presets/Daily")
.method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.clone()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(saved.status(), StatusCode::NO_CONTENT);
let listing = router.clone().oneshot(
request("/api/dashboard-presets")
.header("cookie", &cookie)
.body(Body::empty()).unwrap()
).await.unwrap();
let listing = router
.clone()
.oneshot(
request("/api/dashboard-presets")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(listing.status(), StatusCode::OK);
let entries = json_body(listing).await;
assert_eq!(entries[0]["name"], "Daily");
assert_eq!(entries[0]["overview_samples"], 600);
assert_eq!(entries[0]["dashboard_widget_sizes"], Value::Null);
assert_eq!(store.list_admin_dashboard_presets("owner").await.unwrap().len(), 1);
assert!(store.list_admin_dashboard_presets("someone-else").await.unwrap().is_empty());
let rejected = router.clone().oneshot(
request("/api/dashboard-presets/Invalid").method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.replace("\"overview_samples\":600", "\"overview_samples\":999"))).unwrap()
).await.unwrap();
assert_eq!(
store
.list_admin_dashboard_presets("owner")
.await
.unwrap()
.len(),
1
);
assert!(store
.list_admin_dashboard_presets("someone-else")
.await
.unwrap()
.is_empty());
let rejected = router
.clone()
.oneshot(
request("/api/dashboard-presets/Invalid")
.method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.replace(
"\"overview_samples\":600",
"\"overview_samples\":999",
)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
let renamed = router.clone().oneshot(
request("/api/dashboard-presets/Daily").method("PATCH")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"Nightly"}"#)).unwrap()
).await.unwrap();
let renamed = router
.clone()
.oneshot(
request("/api/dashboard-presets/Daily")
.method("PATCH")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"Nightly"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(renamed.status(), StatusCode::NO_CONTENT);
assert_eq!(store.list_admin_dashboard_presets("owner").await.unwrap()[0].0, "Nightly");
let deleted = router.oneshot(
request("/api/dashboard-presets/Nightly").method("DELETE")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(
store.list_admin_dashboard_presets("owner").await.unwrap()[0].0,
"Nightly"
);
let deleted = router
.oneshot(
request("/api/dashboard-presets/Nightly")
.method("DELETE")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
assert!(store.list_admin_dashboard_presets("owner").await.unwrap().is_empty());
assert!(store
.list_admin_dashboard_presets("owner")
.await
.unwrap()
.is_empty());
store.close().await;
}
@@ -1896,7 +1957,11 @@ mod tests {
let mut upgraded = normalized.clone();
for widget in DASHBOARD_WIDGETS.iter() {
let id = json!(widget.id);
if selected["dashboard_widgets"].as_array().unwrap().contains(&id) {
if selected["dashboard_widgets"]
.as_array()
.unwrap()
.contains(&id)
{
continue;
}
upgraded["dashboard_widgets"]
@@ -2346,6 +2411,12 @@ mod tests {
"cookie"
);
assert!(spec["paths"].get("/api/config").is_some());
assert!(spec["paths"].get("/api/config/storage").is_none());
assert_eq!(
spec["paths"]["/api/config"]["get"]["responses"]["200"]["content"]["application/json"]
["schema"]["$ref"],
"#/components/schemas/ConfigurationRead"
);
assert!(spec["paths"].get("/api/config/history/restore").is_some());
let response = router
.clone()
@@ -3483,5 +3554,136 @@ mod tests {
assert_eq!(result.status(), StatusCode::FORBIDDEN);
store.close().await;
}
#[tokio::test]
async fn configuration_get_requires_authentication_and_reports_sqlite_storage() {
let (router, store, _dir, secret, _) = fixture().await;
let unauthenticated = router
.clone()
.oneshot(request("/api/config").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
let response = router
.clone()
.oneshot(
request("/api/config")
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let payload = json_body(response).await;
assert!(payload["revision"].is_number());
assert!(payload["config"].is_object());
assert!(payload["config"].get("storage").is_none());
assert_eq!(payload["storage"]["mode"], "sqlite");
assert!(payload["storage"]["config_path"].is_null());
assert!(payload["storage"]["database_path"].is_null()); // fixture omits data_dir
// The old GET endpoint is gone. A dynamic PUT-only section route may
// match the path, but it must not respond to GET.
let old_route = router
.oneshot(
request("/api/config/storage")
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(old_route.status(), StatusCode::METHOD_NOT_ALLOWED);
store.close().await;
}
#[tokio::test]
async fn configuration_get_reports_explicit_json_and_private_sqlite_paths() {
let dir = tempfile::tempdir().unwrap();
let config = Config::default();
let policy = ValidationPolicy {
tls_addr: None,
cert_dir: None,
data_dir: Some(dir.path().to_path_buf()),
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
admin_addr: "127.0.0.1:9090".parse().unwrap(),
};
let store = Store::open(&dir.path().join("config.sqlite3"), &config)
.await
.unwrap();
store
.initialize_admin("owner", "unused-test-hash", "test")
.await
.unwrap();
let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf())
.await
.unwrap();
let issued = store
.create_session(1, "browser", None)
.await
.unwrap()
.unwrap();
let snapshot = config::compile(config, 0, &policy).unwrap();
let state = App::new(
store.clone(),
snapshot,
policy,
auth,
10,
Duration::from_secs(30),
Duration::from_secs(30),
Duration::from_secs(300),
);
let file = dir.path().join("config.json");
state.set_config_file(file.clone(), [0u8; 32]).await;
let response = router(state)
.oneshot(
request("/api/config")
.header("cookie", format!("{}={}", auth::COOKIE_NAME, issued.secret))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let payload = json_body(response).await;
assert!(payload["revision"].is_number());
assert!(payload["config"].is_object());
assert!(payload["config"].get("storage").is_none());
assert_eq!(payload["storage"]["mode"], "file");
assert_eq!(
payload["storage"]["config_path"],
file.to_string_lossy().as_ref()
);
assert_eq!(
payload["storage"]["database_path"],
dir.path().join("config.sqlite3").to_string_lossy().as_ref()
);
store.close().await;
}
#[test]
fn storage_metadata_is_exposed_only_through_configuration_get_to_api_tokens() {
assert!(general_api_token_allowed(&Method::GET, "/api/config", "ro"));
assert!(general_api_token_allowed(&Method::GET, "/api/config", "rw"));
assert!(!general_api_token_allowed(
&Method::GET,
"/api/config/storage",
"ro"
));
assert!(!general_api_token_allowed(
&Method::PUT,
"/api/config/storage",
"rw"
));
assert!(!general_api_token_allowed(
&Method::POST,
"/api/config/storage",
"rw"
));
}
include!("preferences_tests.rs");
}
+25 -4
View File
@@ -22,7 +22,7 @@ function clearInvalidOriginRecovery() {
}
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", filelogs: "/logs", settings: "/settings" };
function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; }
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
let envelope = null, configStorage = 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, sessionCheckPromise = null;
@@ -57,7 +57,7 @@ function translate() {
window.rustProxyDrafts?.refresh();
window.rustProxyPreferences?.refreshAppearanceUi?.();
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
syncSettingsDirtyIndicators();
}
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
@@ -335,7 +335,28 @@ async function refreshCertificateInventory() {
if (e.status !== 401 && authenticated && epoch === authEpoch) certificateInventory = new Map();
}
}
async function loadConfig() { const epoch = authEpoch; const value = await api("/api/config"); if (!authenticated || epoch !== authEpoch) return; envelope = value; await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch) return; renderConfig(); }
function renderConfigStorage() {
const mode = $("config-storage-mode"), location = $("config-storage-location");
const description = $("config-storage-description"), history = $("config-storage-history");
if (!configStorage) {
mode.textContent = t("configStorageLoading");
location.textContent = ""; description.textContent = ""; history.textContent = "";
return;
}
if (configStorage.error) {
mode.textContent = t("configStorageUnavailable");
location.textContent = ""; description.textContent = ""; history.textContent = "";
return;
}
const file = configStorage.mode === "file";
mode.textContent = t(file ? "configStorageFile" : "configStorageSqlite");
const source = file ? configStorage.config_path : configStorage.database_path;
location.textContent = source ? `${t("configStoragePath")}: ${source}` : "";
description.textContent = t(file ? "configStorageFileHelp" : "configStorageSqliteHelp");
history.textContent = file && configStorage.database_path
? `${t("configStorageHistory")}: ${configStorage.database_path}` : "";
}
async function loadConfig() { const epoch = authEpoch; const value = await api("/api/config"); if (!authenticated || epoch !== authEpoch) return; envelope = value; configStorage = value.storage || { error: true }; renderConfigStorage(); await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch) return; renderConfig(); }
function rulesText(acl) { return (acl?.rules || []).map(r => `${r.action} ${r.network}`).join("\n"); }
function normalizeAclRuleText(value) {
return String(value ?? "").normalize("NFKC")
@@ -376,7 +397,7 @@ function lock(notify = true) {
window.rustProxyInlineLayout?.cancel?.();
window.rustProxyRefresh?.stop();
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
$("workspace").querySelectorAll("form").forEach(form => form.reset());
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
+60 -24
View File
@@ -2769,14 +2769,23 @@
id="prom-path" maxlength="128" required="" value="/metrics" /></label></div>
<label><span data-i18n="prometheusCidrs"></span><textarea id="prom-cidrs" required=""
rows="3" spellcheck="false"></textarea></label>
<div class="card-heading"><h3 data-i18n="promTokenTitle">Prometheus bearer token</h3></div>
<p class="fine" data-i18n="promTokenHelp">Generate a token directly in the panel. Only its SHA-256 hash is retained; rotating invalidates the previous token immediately.</p>
<div class="card-heading">
<h3 data-i18n="promTokenTitle">Prometheus bearer token</h3>
</div>
<p class="fine" data-i18n="promTokenHelp">Generate a token directly in the panel. Only
its SHA-256 hash is retained; rotating invalidates the previous token immediately.
</p>
<p class="fine" id="prom-token-status" role="status"></p>
<div class="form-actions">
<button class="outline" id="prom-token-generate" type="button" data-i18n="promTokenGenerate">Generate / rotate token</button>
<button class="quiet danger" id="prom-token-revoke" type="button" data-i18n="promTokenRevoke">Revoke token</button>
<button class="outline" id="prom-token-generate" type="button"
data-i18n="promTokenGenerate">Generate / rotate token</button>
<button class="quiet danger" id="prom-token-revoke" type="button"
data-i18n="promTokenRevoke">Revoke token</button>
</div>
<div hidden id="prom-token-secret-wrap"><label data-i18n="apiTokenCopyNow">Copy the
token now — it will not be displayed again</label>
<pre class="metrics-example" id="prom-token-secret"></pre>
</div>
<div hidden id="prom-token-secret-wrap"><label data-i18n="apiTokenCopyNow">Copy the token now — it will not be displayed again</label><pre class="metrics-example" id="prom-token-secret"></pre></div>
<label class="check"><input id="prom-trusted-no-token" type="checkbox" /><span
data-i18n="prometheusTrustedNoToken"></span></label>
<p class="fine" data-i18n="prometheusTrustedNoTokenHelp"></p>
@@ -2869,9 +2878,14 @@
type="submit">Validate, save and apply</button></div>
</form>
<article class="card edit-form" id="api-tokens-shortcut">
<div class="card-heading"><h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2></div>
<p class="fine" data-i18n="apiTokensMovedHelp">API token management has moved to Settings → Security.</p>
<div class="form-actions"><a class="outline" id="api-tokens-security-link" href="/settings?tab=security#api-tokens-card" data-i18n="apiTokensOpenSecurity">Open API tokens in Security</a></div>
<div class="card-heading">
<h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2>
</div>
<p class="fine" data-i18n="apiTokensMovedHelp">API token management has moved to
Settings → Security.</p>
<div class="form-actions"><a class="outline" id="api-tokens-security-link"
href="/settings?tab=security#api-tokens-card"
data-i18n="apiTokensOpenSecurity">Open API tokens in Security</a></div>
</article>
</div>
<div aria-labelledby="settings-tab-system-info" class="settings-page"
@@ -3039,7 +3053,7 @@
role="tabpanel">
<article class="card edit-form">
<div class="card-heading">
<div><span class="small-label">DESIRED STATE / SQLITE</span>
<div><span class="small-label" data-i18n="configStorageLabel">CONFIGURATION SOURCE</span>
<h2 data-i18n="manualConfig">Manual config</h2>
</div>
<div class="manual-config-tools"><button class="quiet" data-i18n="configHistory"
@@ -3047,6 +3061,12 @@
class="outline" data-i18n="export" id="export-config" type="button">Export
JSON</button></div>
</div>
<div class="manual-config-source" role="status" aria-live="polite">
<strong id="config-storage-mode" data-i18n="configStorageLoading">Checking storage...</strong>
<p id="config-storage-location" class="manual-config-path"></p>
<p id="config-storage-description" class="fine"></p>
<p id="config-storage-history" class="fine"></p>
</div>
<p class="fine" data-i18n="configHelp">A valid save creates one new revision. Invalid or
stale changes leave the running configuration untouched.</p>
<label class="sr-only" for="config-editor">JSON</label><textarea id="config-editor"
@@ -3055,8 +3075,6 @@
id="save-json">Validate, save and apply</button><span class="fine"
data-i18n="noRestart">Listener changes require restart; other settings apply
live.</span></div>
<p class="fine" data-i18n="roadmapHelp">Release details and planned features are
documented in ROADMAP.md.</p>
</article>
</div>
<section aria-labelledby="settings-tab-security" class="settings-page"
@@ -3071,19 +3089,37 @@
type="button">Administration audit</button>
</div>
<div class="security-stack">
<article class="card edit-form" id="api-tokens-card" tabindex="-1">
<div class="card-heading"><h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2></div>
<p class="fine" data-i18n="generalApiTokensHelp">Generate independent API tokens with read-only or read-write access, restricted to your client IP/CIDR. Existing browser login remains available. Non-loopback requests require HTTPS.</p>
<form id="api-tokens-form">
<label><span data-i18n="apiTokenComment">Description</span><input id="api-token-comment" maxlength="128" placeholder="external-automation" /></label>
<label><span data-i18n="apiTokenPermission">Permissions</span><select id="api-token-permission"><option value="ro" data-i18n="apiTokenReadOnly">Read only (RO)</option><option value="rw" data-i18n="apiTokenReadWrite">Read and write (RW)</option></select></label>
<label><span data-i18n="apiTokenIpAllow">Allowed source IP/CIDR (one per line)</span><textarea id="api-token-cidrs" rows="3" required placeholder="192.0.2.10/32"></textarea></label>
<div class="form-actions"><button class="primary" type="submit" data-i18n="apiTokenCreate">Generate API token</button></div>
</form>
<div id="api-token-secret-wrap" hidden><label data-i18n="apiTokenCopyNow">Copy the token now — it will not be displayed again</label><pre id="api-token-secret" class="metrics-example"></pre></div>
<div id="api-token-list" class="certificate-api-credentials"></div>
<pre class="metrics-example">curl -H 'Authorization: Bearer $RUSTPROXY_API_TOKEN' https://panel.example.com/api/config</pre>
</article>
<article class="card edit-form" id="api-tokens-card" tabindex="-1">
<div class="card-heading">
<h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2>
</div>
<p class="fine" data-i18n="generalApiTokensHelp">Generate independent API tokens
with read-only or read-write access, restricted to your client IP/CIDR. Existing
browser login remains available. Non-loopback requests require HTTPS.</p>
<form id="api-tokens-form">
<label><span data-i18n="apiTokenComment">Description</span><input
id="api-token-comment" maxlength="128"
placeholder="external-automation" /></label>
<label><span data-i18n="apiTokenPermission">Permissions</span><select
id="api-token-permission">
<option value="ro" data-i18n="apiTokenReadOnly">Read only (RO)</option>
<option value="rw" data-i18n="apiTokenReadWrite">Read and write (RW)
</option>
</select></label>
<label><span data-i18n="apiTokenIpAllow">Allowed source IP/CIDR (one per
line)</span><textarea id="api-token-cidrs" rows="3" required
placeholder="192.0.2.10/32"></textarea></label>
<div class="form-actions"><button class="primary" type="submit"
data-i18n="apiTokenCreate">Generate API token</button></div>
</form>
<div id="api-token-secret-wrap" hidden><label data-i18n="apiTokenCopyNow">Copy the
token now — it will not be displayed again</label>
<pre id="api-token-secret" class="metrics-example"></pre>
</div>
<div id="api-token-list" class="certificate-api-credentials"></div>
<pre
class="metrics-example">curl -H 'Authorization: Bearer $RUSTPROXY_API_TOKEN' https://panel.example.com/api/config</pre>
</article>
<article class="card security-account-card">
<div class="card-heading">
<div><span class="small-label" data-i18n="currentSession">CURRENT SESSION</span>
+17
View File
@@ -1033,6 +1033,23 @@ button.is-saving {
margin-top: 20px
}
.manual-config-source {
padding: 12px 16px;
margin-top: 14px;
border: 1px solid var(--line);
border-radius: var(--radius, 8px);
overflow-wrap: anywhere;
}
.manual-config-source p {
margin: 6px 0 0;
}
.manual-config-path {
font: 12px/1.6 var(--mono);
user-select: text;
}
.page-footer {
display: flex;
justify-content: space-between;