This commit is contained in:
Mateusz Gruszczyński committed 2026-10-19 11:09:48 +02:00
1 parent 4afa88a65e
commit 2ab44c4c62
49 files changed
+1523 -176

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
// Narrow method/path allowlist: tokens never inherit browser-only administration
// routes (password/session, credentials, backups, docs, preferences or SSE).
fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> bool {
let read = method == Method::GET
&& matches!(
path,
"/api/status"
| "/api/system-info"
| "/api/backends"
| "/api/docker"
| "/api/certificates"
| "/api/acme"
| "/api/traffic"
| "/api/cache"
| "/api/transform"
| "/api/tunnels"
| "/api/config"
| "/api/config/history"
| "/api/logs"
| "/api/file-logs"
| "/api/file-logs/query"
| "/api/metrics"
| "/api/cache/bans"
);
let write = permission == "rw"
&& ((method == Method::PUT
&& (path == "/api/config"
|| path.strip_prefix("/api/config/").is_some_and(|part| {
!part.is_empty() && part != "history" && part != "reload" && !part.contains('/')
})))
|| (method == Method::POST
&& matches!(
path,
"/api/config/reload"
| "/api/config/history/restore"
| "/api/cache/purge"
| "/api/cache/invalidate"
| "/api/geoip/update"
| "/api/docker/refresh"
| "/api/certificates/reload"
))
|| (method == Method::POST
&& path
.strip_prefix("/api/certificates/")
.and_then(|part| part.strip_suffix("/reload"))
.is_some_and(crate::config::valid_id)));
read || write
}
+119
View File
@@ -0,0 +1,119 @@
// General-purpose API credentials are independent of browser sessions,
// certificate deployment credentials, and the Prometheus scrape credential.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CreateApiToken {
permission: String,
#[serde(default)]
comment: String,
allowed_cidrs: Vec<String>,
}
async fn list_api_tokens(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
let tokens = state.store.api_tokens().await.map_err(auth::internal)?;
Ok(Json(
json!({"tokens": tokens, "max_tokens": crate::db::API_TOKEN_CAPACITY}),
))
}
async fn create_api_token(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Json(input): Json<CreateApiToken>,
) -> Result<Json<Value>, ApiError> {
if !matches!(input.permission.as_str(), "ro" | "rw")
|| input.comment.len() > 128
|| input.comment.chars().any(char::is_control)
|| input.allowed_cidrs.is_empty()
|| input.allowed_cidrs.len() > 32
|| input.allowed_cidrs.iter().any(|v| {
v.parse::<ipnet::IpNet>()
.ok()
.is_none_or(|net| net.prefix_len() == 0)
})
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid API permission, comment or CIDR allowlist".into(),
));
}
let secret = auth::random_secret();
let id = format!("api-{}", &secret[..12]);
let inserted = state
.store
.create_api_token(
&id,
&auth::secret_hash(&secret),
&input.permission,
&input.comment,
&input.allowed_cidrs,
&session.username,
)
.await
.map_err(auth::internal)?;
if !inserted {
return Err(ApiError(
StatusCode::CONFLICT,
"API token already exists".into(),
));
}
Ok(Json(
json!({"id": id, "token": secret, "permission": input.permission, "allowed_cidrs": input.allowed_cidrs}),
))
}
async fn revoke_api_token(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !crate::config::valid_id(&id) {
return Err(ApiError(StatusCode::NOT_FOUND, "unknown API token".into()));
}
if !state
.store
.revoke_api_token(&id, &session.username)
.await
.map_err(auth::internal)?
{
return Err(ApiError(StatusCode::NOT_FOUND, "unknown API token".into()));
}
Ok(Json(json!({"revoked": id})))
}
async fn prometheus_token_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
let created_at = state
.store
.prometheus_token_status()
.await
.map_err(auth::internal)?;
Ok(Json(
json!({"configured": created_at.is_some(), "created_at": created_at}),
))
}
async fn rotate_prometheus_token(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<Value>, ApiError> {
let secret = auth::random_secret();
let hash = auth::secret_hash(&secret);
state
.store
.rotate_prometheus_token(&hash, &session.username)
.await
.map_err(auth::internal)?;
state.prometheus_token_hash.store(Some(Arc::new(hash)));
Ok(Json(json!({"configured": true, "token": secret})))
}
async fn revoke_prometheus_token(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<Value>, ApiError> {
state
.store
.revoke_prometheus_token(&session.username)
.await
.map_err(auth::internal)?;
state
.prometheus_token_hash
.store(Some(Arc::new("revoked".into())));
Ok(Json(json!({"configured": false})))
}
+21
View File
@@ -0,0 +1,21 @@
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ReloadRequest {
expected_revision: u64,
}
async fn reload_configuration(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Json(request): Json<ReloadRequest>,
) -> Result<Json<crate::config::Envelope>, ApiError> {
state
.store
.audit(&session.username, "config.reload", "reload requested")
.await
.map_err(auth::internal)?;
state
.reload_from_file(request.expected_revision)
.await
.map(Json)
}
+3
View File
@@ -2,11 +2,14 @@
include!("router.rs");
include!("access.rs");
include!("api_token_access.rs");
include!("session.rs");
include!("preferences.rs");
include!("dashboard_presets.rs");
include!("status.rs");
include!("logging_api.rs");
include!("config_reload.rs");
include!("api_tokens.rs");
include!("file_logs.rs");
include!("file_log_search.rs");
include!("operations.rs");
+505 -32
View File
@@ -2,8 +2,8 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.33.7",
"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",
"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."
},
"servers": [
{
@@ -41,6 +41,12 @@
"in": "header",
"name": "X-RustProxy-Certificate-Key",
"description": "Non-secret Key ID paired with the scoped certificate Bearer token."
},
"GeneralApiBearer": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "opaque",
"description": "Generated in Settings > API tokens; ro/rw permission, mandatory source CIDR allowlist and a narrow method/path allowlist. HTTPS required outside loopback. Not usable for token management, login, docs or backups."
}
},
"schemas": {
@@ -5417,7 +5423,10 @@
},
"sendfile": {
"type": "string",
"enum": ["off", "auto"],
"enum": [
"off",
"auto"
],
"default": "off",
"description": "Linux TCP HTTP/1.1 kernel sendfile for eligible unmodified static GET responses. Transport-only; off by default."
},
@@ -7670,7 +7679,15 @@
}
}
],
"description": "Without view, returns the historical full status including runtime and system diagnostics. view=live returns compact live-only telemetry; the browser fetches system diagnostics separately."
"description": "Without view, returns the historical full status including runtime and system diagnostics. view=live returns compact live-only telemetry; the browser fetches system diagnostics separately.",
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/api/backends": {
@@ -7731,7 +7748,15 @@
}
}
}
}
},
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/api/config": {
@@ -7792,7 +7817,15 @@
}
}
}
}
},
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
},
"put": {
"summary": "Replace the entire configuration atomically",
@@ -7917,8 +7950,10 @@
],
"security": [
{
"AdminSession": [],
"CsrfToken": []
"AdminSession": []
},
{
"GeneralApiBearer": []
}
],
"requestBody": {
@@ -7992,7 +8027,15 @@
}
}
},
"description": "Returns active SNI bindings and covered_hosts metadata. Public certificate PEM files are inspected on a bounded blocking worker so current DNS SAN coverage is shown even for older/manual entries or in-place renewals; private keys are never read for inventory."
"description": "Returns active SNI bindings and covered_hosts metadata. Public certificate PEM files are inspected on a bounded blocking worker so current DNS SAN coverage is shown even for older/manual entries or in-place renewals; private keys are never read for inventory.",
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/api/certificates/upload": {
@@ -8193,7 +8236,15 @@
}
}
}
}
},
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/api/acme/issue": {
@@ -8706,6 +8757,14 @@
"default": 50
}
}
],
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
@@ -8767,7 +8826,15 @@
}
}
}
}
},
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/api/geoip/update": {
@@ -8822,8 +8889,10 @@
},
"security": [
{
"AdminSession": [],
"CsrfToken": []
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
@@ -8961,8 +9030,10 @@
],
"security": [
{
"AdminSession": [],
"CsrfToken": []
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
@@ -9084,6 +9155,14 @@
"default": 256
}
}
],
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
@@ -9350,7 +9429,15 @@
}
}
},
"description": "Requires the admin session on THIS listener. The optional dedicated exporter uses independent CIDR/bearer policy; it is disabled by default."
"description": "Requires the admin session on THIS listener. The optional dedicated exporter uses independent CIDR/bearer policy; it is disabled by default.",
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/docs/openapi.json": {
@@ -9638,6 +9725,9 @@
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
@@ -9725,8 +9815,10 @@
],
"security": [
{
"AdminSession": [],
"CsrfToken": []
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
@@ -10108,8 +10200,10 @@
],
"security": [
{
"AdminSession": [],
"CsrfToken": []
"AdminSession": []
},
{
"GeneralApiBearer": []
}
],
"requestBody": {
@@ -10264,7 +10358,15 @@
}
}
}
}
},
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/api/cache/bans/{id}": {
@@ -10433,7 +10535,15 @@
}
}
}
}
},
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/api/tunnels/{id}/kill": {
@@ -10626,8 +10736,10 @@
},
"security": [
{
"AdminSession": [],
"CsrfToken": []
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
@@ -11379,7 +11491,15 @@
}
}
}
}
},
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/api/config/history": {
@@ -11477,7 +11597,15 @@
}
}
}
}
},
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/api/config/history/restore": {
@@ -11512,8 +11640,10 @@
],
"security": [
{
"AdminSession": [],
"CsrfToken": []
"AdminSession": []
},
{
"GeneralApiBearer": []
}
],
"requestBody": {
@@ -11708,7 +11838,15 @@
}
}
}
}
},
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/api/file-logs/query": {
@@ -11942,7 +12080,15 @@
}
}
}
}
},
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/api/system-info": {
@@ -11969,7 +12115,15 @@
"403": {
"description": "Invalid origin"
}
}
},
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/api/certificate-api/{key_id}/regenerate": {
@@ -12317,6 +12471,325 @@
}
}
}
},
"/api/config/reload": {
"post": {
"tags": [
"Configuration"
],
"summary": "Import and hot-apply current authoritative JSON (or revalidate SQLite-backed config) with a revision guard",
"operationId": "reloadConfiguration",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"required": [
"expected_revision"
],
"properties": {
"expected_revision": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
}
}
}
},
"responses": {
"200": {
"description": "Successful request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Envelope"
}
}
}
},
"401": {
"description": "Authentication required"
},
"403": {
"description": "Permission or origin restriction"
},
"422": {
"description": "Invalid input"
},
"500": {
"description": "Internal failure"
}
},
"security": [
{
"AdminSession": []
},
{
"GeneralApiBearer": []
}
]
}
},
"/api/api-tokens": {
"get": {
"tags": [
"Authentication"
],
"summary": "List general API credential metadata, never secrets",
"operationId": "listApiTokens",
"responses": {
"200": {
"description": "Successful request",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"401": {
"description": "Authentication required"
},
"403": {
"description": "Permission or origin restriction"
},
"422": {
"description": "Invalid input"
},
"500": {
"description": "Internal failure"
}
}
},
"post": {
"tags": [
"Authentication"
],
"summary": "Generate a new one-time-display general API bearer token",
"operationId": "createApiToken",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"required": [
"permission",
"allowed_cidrs"
],
"properties": {
"permission": {
"type": "string",
"enum": [
"ro",
"rw"
]
},
"comment": {
"type": "string",
"maxLength": 128
},
"allowed_cidrs": {
"type": "array",
"minItems": 1,
"maxItems": 32,
"items": {
"type": "string",
"description": "IP/CIDR, /0 rejected"
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "Successful request",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"401": {
"description": "Authentication required"
},
"403": {
"description": "Permission or origin restriction"
},
"422": {
"description": "Invalid input"
},
"500": {
"description": "Internal failure"
}
}
}
},
"/api/api-tokens/{id}": {
"delete": {
"tags": [
"Authentication"
],
"summary": "Revoke a general API token immediately",
"operationId": "revokeApiToken",
"responses": {
"200": {
"description": "Successful request",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"401": {
"description": "Authentication required"
},
"403": {
"description": "Permission or origin restriction"
},
"422": {
"description": "Invalid input"
},
"500": {
"description": "Internal failure"
}
},
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/prometheus-token": {
"get": {
"tags": [
"Authentication"
],
"summary": "Get Prometheus credential presence, without the token",
"operationId": "getPrometheusToken",
"responses": {
"200": {
"description": "Successful request",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"401": {
"description": "Authentication required"
},
"403": {
"description": "Permission or origin restriction"
},
"422": {
"description": "Invalid input"
},
"500": {
"description": "Internal failure"
}
}
},
"post": {
"tags": [
"Authentication"
],
"summary": "Generate or rotate one-time-display Prometheus Bearer token",
"operationId": "rotatePrometheusToken",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false
}
}
}
},
"responses": {
"200": {
"description": "Successful request",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"401": {
"description": "Authentication required"
},
"403": {
"description": "Permission or origin restriction"
},
"422": {
"description": "Invalid input"
},
"500": {
"description": "Internal failure"
}
}
},
"delete": {
"tags": [
"Authentication"
],
"summary": "Revoke the currently configured Prometheus Bearer token",
"operationId": "revokePrometheusToken",
"responses": {
"200": {
"description": "Successful request",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"401": {
"description": "Authentication required"
},
"403": {
"description": "Permission or origin restriction"
},
"422": {
"description": "Invalid input"
},
"500": {
"description": "Internal failure"
}
}
}
}
}
},
"tags": []
}
+6
View File
@@ -96,6 +96,8 @@ fn admin_app_javascript() -> &'static str {
include_str!("web/acme.js"),
include_str!("web/settings.js"),
include_str!("web/certificate-api.js"),
include_str!("web/api-tokens.js"),
include_str!("web/prometheus-token.js"),
include_str!("web/file-logs.js"),
]
.join("\n")
@@ -198,6 +200,9 @@ pub fn router(state: Arc<App>) -> Router {
"/api/certificates/{certificate_id}/reload",
post(certificate_reload_one).layer(DefaultBodyLimit::max(4096)),
)
.route("/api/api-tokens", get(list_api_tokens).post(create_api_token))
.route("/api/api-tokens/{id}", delete(revoke_api_token))
.route("/api/prometheus-token", get(prometheus_token_status).post(rotate_prometheus_token).delete(revoke_prometheus_token))
.route(
"/api/certificate-api",
get(certificate_api_status)
@@ -238,6 +243,7 @@ pub fn router(state: Arc<App>) -> Router {
)
.route("/api/cache/cold/cleanup/{id}", get(cache_cold_cleanup_job))
.route("/api/config", get(configuration).merge(put(update)))
.route("/api/config/reload", post(reload_configuration))
.route("/api/config/history", get(config_history))
.route(
"/api/config/history/restore",
+39 -6
View File
@@ -120,8 +120,7 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
// Pinned browser origin is panel-only. Certificate automation using API
// credentials keeps the original forwarding/TLS authorization semantics.
let automation_credentials = headers.contains_key(CERTIFICATE_API_KEY_HEADER)
|| (headers.contains_key(AUTHORIZATION)
&& certificate_api_operation(request.method(), request.uri().path()).is_some());
|| headers.contains_key(AUTHORIZATION);
let pinned_authority_result = if automation_credentials {
Ok(None)
} else {
@@ -173,7 +172,10 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
let secret = auth::cookie_secret(headers);
let certificate_api_operation = certificate_api_operation(request.method(), &path);
let mut certificate_api_access = None;
let certificate_api_session = if let Some(operation) = certificate_api_operation.as_ref() {
let certificate_api_session = if headers.contains_key(CERTIFICATE_API_KEY_HEADER) {
let Some(operation) = certificate_api_operation.as_ref() else {
return secure(reject(StatusCode::FORBIDDEN, "certificate_api.invalid_route"));
};
match certificate_api_credentials(headers) {
Ok(None) => None,
Ok(Some((key_id, token))) => {
@@ -241,7 +243,36 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
} else {
None
};
let external_certificate_api = certificate_api_session.is_some();
let general_api_session = if certificate_api_session.is_none() && headers.contains_key(AUTHORIZATION) {
// No cookies are accepted as a fallback when a malformed Bearer header is supplied.
let bearer = single_header(headers, AUTHORIZATION.as_str())
.and_then(|header| header.strip_prefix("Bearer "));
let Some(token) = bearer.filter(|value| valid_certificate_api_token(value)) else {
return secure(reject(StatusCode::UNAUTHORIZED, "api_token.invalid_token"));
};
let Some(peer) = peer else {
return secure(reject(StatusCode::FORBIDDEN, "api_token.peer_required"));
};
if !peer.0.ip().is_loopback() && scheme != "https" {
return secure(reject(StatusCode::FORBIDDEN, "api_token.https_required"));
}
let hash = auth::secret_hash(token);
let peer_ip = crate::vhost::canonical_ip(peer.0.ip());
let grant = match state.store.verify_api_token(&hash, peer_ip).await {
Ok(Some(grant)) => grant,
Ok(None) => return secure(reject(StatusCode::UNAUTHORIZED, "api_token.invalid_token_or_ip")),
Err(error) => return secure(auth::internal(error).into_response()),
};
if !general_api_token_allowed(request.method(), &path, &grant.permission) {
return secure(reject(StatusCode::FORBIDDEN, "api_token.insufficient_permission"));
}
Some(Session {
token_hash: hash, csrf: String::new(),
username: format!("api-token:{}", grant.id),
expires_at: auth::now().saturating_add(60), generation: 0,
})
} else { None };
let external_credential = certificate_api_session.is_some() || general_api_session.is_some();
let session_seconds = state
.config
.load()
@@ -254,6 +285,8 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
// login resources and capability discovery never load operational state.
let session = if certificate_api_session.is_some() {
certificate_api_session
} else if general_api_session.is_some() {
general_api_session
} else if !public || panel_document_path(&path) {
match secret.as_deref() {
Some(secret) => match state
@@ -272,7 +305,7 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
if !public && session.is_none() {
return secure(unauthorized(&request));
}
if unsafe_method && !external_certificate_api {
if unsafe_method && !external_credential {
if single_header(headers, "origin")
.is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority))
|| single_header(headers, "x-rustproxy-request") != Some("1")
@@ -304,7 +337,7 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
path.as_str(),
"/api/auth/logout" | "/api/auth/logout-all" | "/api/auth/password"
);
if refresh_session_cookie {
if refresh_session_cookie && !external_credential {
if let (Some(secret), Some(session)) = (secret.as_deref(), session.as_ref()) {
let remaining = session.expires_at.saturating_sub(auth::now());
if remaining > 0 {
+47
View File
@@ -0,0 +1,47 @@
"use strict";
window.rustProxyApiTokens = (() => {
let loading = false;
const hideSecret = () => { $("api-token-secret").textContent = ""; $("api-token-secret-wrap").hidden = true; };
async function refresh() {
if (!authenticated || loading) return;
loading = true;
try {
const result = await api("/api/api-tokens");
const list = $("api-token-list"); list.replaceChildren();
for (const entry of result.tokens || []) {
const card = el("div", undefined, "entity");
card.append(el("span", `${entry.id} · ${entry.permission.toUpperCase()} · ${entry.comment || "-"}`, "entity-title"));
card.append(el("p", `${t("apiTokenIpAllow")}: ${(entry.allowed_cidrs || []).join(", ")}`, "fine"));
card.append(el("p", `${t("apiTokenLastUsed")}: ${entry.last_used_at ? formatDateTime(entry.last_used_at * 1000) : "-"}`, "fine"));
card.append(action(t("apiTokenRevoke"), async () => {
if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return;
try { await api(`/api/api-tokens/${encodeURIComponent(entry.id)}`, { method: "DELETE" }); hideSecret(); await refresh(); }
catch (error) { toast(error.message, true); }
}));
list.append(card);
}
if (!result.tokens?.length) list.append(el("p", t("apiTokenEmpty"), "fine"));
} catch (error) { if (error.status !== 401) toast(error.message, true); }
finally { loading = false; }
}
$("api-tokens-form").addEventListener("submit", async event => {
event.preventDefault(); if (!authenticated) return;
const button = event.submitter; button.disabled = true;
hideSecret();
try {
const result = await api("/api/api-tokens", {
method: "POST", body: JSON.stringify({
permission: $("api-token-permission").value,
comment: $("api-token-comment").value.trim(),
allowed_cidrs: $("api-token-cidrs").value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean)
})
});
$("api-token-secret").textContent = result.token;
$("api-token-secret-wrap").hidden = false;
$("api-token-comment").value = "";
await refresh();
} catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});
return { refresh, hideSecret, reset() { hideSecret(); $("api-token-list").replaceChildren(); } };
})();
+5 -1
View File
@@ -151,7 +151,7 @@ function setPanel(name, navigate = true) {
currentPanel = name;
$("overview-refresh").hidden = name !== "overview";
$("overview-samples-toolbar").hidden = name !== "overview";
for (const id of ["overview-layout-open", "overview-layout-unlock"]) if ($(id)) $(id).hidden = name !== "overview";
for (const id of ["overview-layout-open", "overview-layout-unlock"]) if ($(id)) $(id).hidden = name !== "overview" || (id === "overview-layout-unlock" && !!window.rustProxyInlineLayout?.isEditing?.());
document.querySelectorAll(".nav").forEach(e => e.classList.toggle("active", e.dataset.panel === name));
document.querySelectorAll(".panel").forEach(e => e.hidden = e.id !== `panel-${name}`);
$("page-label").textContent = t(name); $("page-title").textContent = t(`${name}Title`); $("page-description").textContent = t(`${name}Description`);
@@ -414,6 +414,10 @@ async function activate(info) {
}
}
if (!authenticated || epoch !== authEpoch) return false;
if (window.rustProxySettings?.current?.() === "security") {
void window.rustProxyApiTokens?.refresh?.();
window.rustProxySettings?.scrollToApiTokens?.();
}
void refresh(true).catch(error => {
if (authenticated && epoch === authEpoch && error?.status !== 401) toast(error.message, true);
});
@@ -138,6 +138,7 @@ window.rustProxyInlineLayout = (() => {
unlock.setAttribute("aria-pressed", "true");
unlock.dataset.i18n = "overviewLayoutLock";
unlock.textContent = t("overviewLayoutLock");
unlock.hidden = true; // Save and lock is the only save action while unlocked.
actions.hidden = false;
hint.hidden = false;
hint.textContent = t("overviewLayoutInlineHint");
@@ -153,6 +154,7 @@ window.rustProxyInlineLayout = (() => {
unlock.setAttribute("aria-pressed", "false");
unlock.dataset.i18n = "overviewLayoutUnlock";
unlock.textContent = t("overviewLayoutUnlock");
unlock.hidden = false;
actions.hidden = true;
hint.hidden = true;
if (hiddenShelf) { hiddenShelf.open = false; hiddenShelf.hidden = true; }
+28 -1
View File
@@ -109,6 +109,33 @@ function renderCertificates() {
box.append(details);
const buttons = el("div", undefined, "entity-actions");
const assignPane = el("div", undefined, "certificate-bulk-assign");
assignPane.hidden = true;
const assignmentList = el("div", undefined, "certificate-bulk-list");
for (const host of envelope.config.vhosts || []) {
const label = el("label", undefined, "check");
const check = document.createElement("input");
check.type = "checkbox"; check.value = host.id;
check.checked = host.certificate_id === cert.id;
label.append(check, el("span", `${host.id} · ${(host.frontend?.hosts || []).join(", ")}`));
assignmentList.append(label);
}
const applyBindings = action(t("certAssignApply"), async () => {
if (busy) return;
const selected = new Set([...assignmentList.querySelectorAll('input:checked')].map(input => input.value));
const next = structuredClone(envelope.config);
for (const host of next.vhosts) {
if (selected.has(host.id)) host.certificate_id = cert.id;
else if (host.certificate_id === cert.id) host.certificate_id = null;
}
await saveSection("vhosts", next.vhosts);
});
assignPane.append(el("p", t("certAssignHelp"), "fine"), assignmentList, applyBindings);
const assignButton = action(t("certAssignVhosts"), () => {
assignPane.hidden = !assignPane.hidden;
});
assignButton.disabled = !(envelope.config.vhosts || []).length;
buttons.append(assignButton);
if (!managed) {
buttons.append(action(t("reloadCertificate"), async () => {
if (busy) return;
@@ -125,7 +152,7 @@ function renderCertificates() {
}
const remove = action(t("remove"), async () => { if (inUse) { toast(t("certInUse"), true); return; } if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.tls.certificates = next.tls.certificates.filter(v => v.id !== cert.id); await saveSection("tls", next.tls); });
remove.disabled = inUse; remove.title = inUse ? t("certInUse") : ""; buttons.append(remove);
box.append(buttons);
box.append(buttons, assignPane);
return box;
});
$("certificate-list").replaceChildren(...entries);
+26 -4
View File
@@ -2769,10 +2769,14 @@
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>
<label><span data-i18n="prometheusTokenEnv"></span><input autocomplete="off"
id="prom-token-env" maxlength="128"
placeholder="RUST_PROXY_METRICS_TOKEN" /></label>
<p class="fine" data-i18n="prometheusTokenHelp"></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>
</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>
@@ -2864,6 +2868,11 @@
<div class="form-actions"><button class="primary" data-i18n="apply"
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>
</article>
</div>
<div aria-labelledby="settings-tab-system-info" class="settings-page"
data-settings-page="system-info" hidden="" id="settings-page-system-info" role="tabpanel">
@@ -3062,6 +3071,19 @@
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 security-account-card">
<div class="card-heading">
<div><span class="small-label" data-i18n="currentSession">CURRENT SESSION</span>
+35
View File
@@ -0,0 +1,35 @@
"use strict";
window.rustProxyPrometheusToken = (() => {
let isConfigured = false;
const hideSecret = () => { $("prom-token-secret").textContent = ""; $("prom-token-secret-wrap").hidden = true; };
function showStatus() {
$("prom-token-status").textContent = t(isConfigured ? "promTokenConfigured" : "promTokenMissing");
$("prom-token-revoke").disabled = !isConfigured;
}
async function refresh() {
if (!authenticated) return;
try {
const result = await api("/api/prometheus-token");
isConfigured = !!result.configured;
showStatus();
} catch (error) { if (error.status !== 401) toast(error.message, true); }
}
$("prom-token-generate").addEventListener("click", async event => {
const button = event.currentTarget; button.disabled = true; hideSecret();
try {
const result = await api("/api/prometheus-token", { method: "POST", body: "{}" });
isConfigured = true; $("prom-token-secret").textContent = result.token;
$("prom-token-secret-wrap").hidden = false;
showStatus(); window.rustProxySettings?.render?.();
} catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});
$("prom-token-revoke").addEventListener("click", async () => {
if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return;
try {
await api("/api/prometheus-token", { method: "DELETE" });
isConfigured = false; hideSecret(); showStatus(); window.rustProxySettings?.render?.();
} catch (error) { toast(error.message, true); }
});
return { configured: () => isConfigured, refresh, hideSecret, reset() { isConfigured = false; hideSecret(); showStatus(); } };
})();
+29 -6
View File
@@ -48,7 +48,13 @@
if (target.hidden && search?.value) { search.value = ""; applySettingsSearch(); }
active = name;
if (persist) { rememberedTab = name; try { localStorage.setItem("rp-settings-last-tab", name); } catch { } }
if (typeof window !== "undefined") window.rustProxyRefresh?.setSettingsTab(name);
if (typeof window !== "undefined") {
window.rustProxyRefresh?.setSettingsTab(name);
if (authenticated && name === "security") void window.rustProxyApiTokens?.refresh?.();
if (authenticated && name === "prometheus") void window.rustProxyPrometheusToken?.refresh?.();
if (name !== "prometheus") window.rustProxyPrometheusToken?.hideSecret?.();
if (name !== "security") window.rustProxyApiTokens?.hideSecret?.();
}
for (const b of buttons) { const chosen = b.dataset.settingsTab === name; b.setAttribute("aria-selected", String(chosen)); b.tabIndex = chosen ? 0 : -1; if (chosen && focus) b.focus(); }
document.querySelectorAll("[data-settings-page]").forEach(p => p.hidden = p.dataset.settingsPage !== name);
if (persist) syncLocation();
@@ -61,6 +67,23 @@
});
}
search?.addEventListener("input", applySettingsSearch);
function scrollToApiTokens() {
if (active !== "security" || typeof location === "undefined" || location.hash !== "#api-tokens-card") return;
const target = $("api-tokens-card");
if (target) { target.scrollIntoView({ block: "start" }); target.focus({ preventScroll: true }); }
}
// Preserve a real deep link (including opening in a new tab), while switching
// tabs without reloading the panel for ordinary left-click navigation.
$("api-tokens-security-link")?.addEventListener("click", event => {
if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault();
select("security");
if (typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol)) {
const url = new URL($("api-tokens-security-link").href);
history.replaceState({ ...(history.state || {}), panel: "settings", settingsTab: "security" }, "", `${url.pathname}${url.search}${url.hash}`);
}
scrollToApiTokens();
});
const policyTabs = [...document.querySelectorAll("[data-policy-settings-tab]")].filter(button => typeof button.addEventListener === "function" && typeof button.setAttribute === "function");
function selectPolicyTab(name, focus = false) {
if (!policyTabs.some(button => button.dataset.policySettingsTab === name)) return;
@@ -143,14 +166,14 @@
$("geoip-mmdb-url").required = mmdb && $("geoip-enabled").checked;
}
function example() {
const addr = $("prom-listen").value.trim(), path = $("prom-path").value.trim(), env = $("prom-token-env").value.trim();
$("prom-scrape-example").textContent = `scrape_configs:\n - job_name: rustproxy\n metrics_path: ${JSON.stringify(path)}\n static_configs:\n - targets: [${JSON.stringify(addr)}]${env ? "\n authorization:\n credentials_file: /etc/prometheus/rustproxy.token" : ""}`;
const addr = $("prom-listen").value.trim(), path = $("prom-path").value.trim(), configured = window.rustProxyPrometheusToken?.configured?.() || Boolean(envelope?.config?.prometheus?.bearer_token_env);
$("prom-scrape-example").textContent = `scrape_configs:\n - job_name: rustproxy\n metrics_path: ${JSON.stringify(path)}\n static_configs:\n - targets: [${JSON.stringify(addr)}]${configured ? "\n authorization:\n credentials_file: /etc/prometheus/rustproxy.token" : ""}`;
}
function render() {
if (!envelope) return; const p = { ...defaults, ...envelope.config.prometheus };
if (!settingsDirty.has("prometheus-form")) {
$("prom-enabled").checked = p.enabled; $("prom-listen").value = p.listen; $("prom-path").value = p.path;
$("prom-cidrs").value = p.allowed_cidrs.join("\n"); $("prom-token-env").value = p.bearer_token_env || ""; $("prom-trusted-no-token").checked = !!p.allow_trusted_network_without_token;
$("prom-cidrs").value = p.allowed_cidrs.join("\n"); $("prom-trusted-no-token").checked = !!p.allow_trusted_network_without_token;
for (const [id, key] of [["system", "include_system"], ["vhosts", "include_vhosts"], ["traffic", "include_traffic"], ["backends", "include_backends"], ["docker-vhosts", "include_docker_vhosts"], ["domains", "include_cache_domains"], ["streams", "include_streams"], ["logging", "include_logging"]]) $(`prom-${id}`).checked = p[key];
}
const status = latestStatus?.prometheus;
@@ -263,7 +286,7 @@
});
bindSettingsForm("prometheus-form", next => {
next.prometheus = {
enabled: $("prom-enabled").checked, listen: $("prom-listen").value.trim(), path: $("prom-path").value.trim(), allowed_cidrs: $("prom-cidrs").value.split(/[\n,]/).map(v => v.trim()).filter(Boolean), bearer_token_env: $("prom-token-env").value.trim() || null, allow_trusted_network_without_token: $("prom-trusted-no-token").checked,
enabled: $("prom-enabled").checked, listen: $("prom-listen").value.trim(), path: $("prom-path").value.trim(), allowed_cidrs: $("prom-cidrs").value.split(/[\n,]/).map(v => v.trim()).filter(Boolean), bearer_token_env: envelope?.config?.prometheus?.bearer_token_env || null, allow_trusted_network_without_token: $("prom-trusted-no-token").checked,
include_system: $("prom-system").checked, include_vhosts: $("prom-vhosts").checked, include_traffic: $("prom-traffic").checked, include_backends: $("prom-backends").checked, include_docker_vhosts: $("prom-docker-vhosts").checked, include_cache_domains: $("prom-domains").checked, include_streams: $("prom-streams").checked, include_logging: $("prom-logging").checked
};
}, "prometheus");
@@ -284,6 +307,6 @@
const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false;
} catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; }
});
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select(validTab(rememberedTab) ? rememberedTab : "appearance", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); window.rustProxyCertificateApi?.reset(); $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, scrollToApiTokens, reset() { if (search) search.value = ""; applySettingsSearch(); select(validTab(rememberedTab) ? rememberedTab : "appearance", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); window.rustProxyCertificateApi?.reset(); window.rustProxyApiTokens?.reset?.(); window.rustProxyPrometheusToken?.reset?.(); $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
select(active); render();
})();
+18
View File
@@ -12158,4 +12158,22 @@ html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.pool
.traffic-chart-card>h3.subheading,
.vhost-chart-head>span:first-child {
color: var(--muted);
}
.certificate-bulk-assign {
margin-top: 12px;
border-top: 1px solid var(--line);
padding-top: 12px;
}
.certificate-bulk-list {
display: grid;
gap: 8px;
max-height: 250px;
overflow-y: auto;
margin: 10px 0;
}
.certificate-bulk-list .check {
overflow-wrap: anywhere;
}