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;
}
+7 -2
View File
@@ -4,6 +4,8 @@ use crate::config::{Config, Envelope};
mod certificate_api;
mod dashboard_presets;
mod history;
mod api_tokens;
pub use api_tokens::{ApiTokenGrant, ApiTokenMetadata, API_TOKEN_CAPACITY};
use anyhow::{ensure, Context, Result};
pub use history::{ConfigurationHistoryDetail, ConfigurationRevision};
use serde::Serialize;
@@ -13,7 +15,7 @@ use sqlx::{
};
use std::{path::Path, time::Duration};
pub const SCHEMA_VERSION: i64 = 10;
pub const SCHEMA_VERSION: i64 = 11;
pub const CERTIFICATE_API_CREDENTIAL_CAPACITY: i64 = 32;
pub const AUDIT_CAPACITY: i64 = 10_000;
@@ -127,6 +129,9 @@ impl Store {
sqlx::raw_sql(include_str!("../migrations/0010_dashboard_presets.sql"))
.execute(&mut *tx)
.await?;
sqlx::raw_sql(include_str!("../migrations/0011_tokens.sql"))
.execute(&mut *tx)
.await?;
sqlx::query("INSERT OR IGNORE INTO configuration(id, revision, document) VALUES(1, 0, ?)")
.bind(serde_json::to_string(initial)?)
.execute(&mut *tx)
@@ -137,7 +142,7 @@ impl Store {
.bind(crate::auth::now())
.execute(&mut *tx)
.await?;
sqlx::query("PRAGMA user_version = 10")
sqlx::query("PRAGMA user_version = 11")
.execute(&mut *tx)
.await?;
tx.commit().await?;
+93
View File
@@ -0,0 +1,93 @@
use super::{audit_tx, Store};
use anyhow::{ensure, Context, Result};
use serde::Serialize;
use sqlx::Row;
use std::net::IpAddr;
pub const API_TOKEN_CAPACITY: i64 = 32;
#[derive(Clone, Debug, Serialize)]
pub struct ApiTokenMetadata {
pub id: String,
pub permission: String,
pub comment: String,
pub allowed_cidrs: Vec<String>,
pub created_at: i64,
pub last_used_at: Option<i64>,
}
#[derive(Debug)]
pub struct ApiTokenGrant { pub id: String, pub permission: String }
impl Store {
pub async fn api_tokens(&self) -> Result<Vec<ApiTokenMetadata>> {
let rows = sqlx::query("SELECT id,permission,comment,allowed_cidrs,created_at,last_used_at FROM administration_api_tokens ORDER BY created_at DESC,id")
.fetch_all(&self.pool).await?;
rows.into_iter().map(|row| {
let cidrs: String = row.try_get("allowed_cidrs")?;
Ok(ApiTokenMetadata { id: row.try_get("id")?, permission: row.try_get("permission")?,
comment: row.try_get("comment")?, allowed_cidrs: serde_json::from_str(&cidrs)?,
created_at: row.try_get("created_at")?, last_used_at: row.try_get("last_used_at")? })
}).collect()
}
pub async fn create_api_token(&self, id: &str, hash: &str, permission: &str,
comment: &str, cidrs: &[String], actor: &str) -> Result<bool> {
let mut tx = self.pool.begin().await?;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM administration_api_tokens").fetch_one(&mut *tx).await?;
ensure!(count < API_TOKEN_CAPACITY, "API token limit reached");
let result = sqlx::query("INSERT OR IGNORE INTO administration_api_tokens(id,token_hash,permission,comment,allowed_cidrs,created_at) VALUES(?,?,?,?,?,?)")
.bind(id).bind(hash).bind(permission).bind(comment).bind(serde_json::to_string(cidrs)?)
.bind(crate::auth::now()).execute(&mut *tx).await?;
if result.rows_affected() != 1 { return Ok(false); }
audit_tx(&mut tx, actor, "api_token.create", &format!("id={id} permission={permission}")).await?;
tx.commit().await?;
Ok(true)
}
pub async fn revoke_api_token(&self, id: &str, actor: &str) -> Result<bool> {
let mut tx = self.pool.begin().await?;
let result = sqlx::query("DELETE FROM administration_api_tokens WHERE id = ?")
.bind(id).execute(&mut *tx).await?;
if result.rows_affected() == 0 { return Ok(false); }
audit_tx(&mut tx, actor, "api_token.revoke", &format!("id={id}")).await?;
tx.commit().await?;
Ok(true)
}
pub async fn verify_api_token(&self, hash: &str, ip: IpAddr) -> Result<Option<ApiTokenGrant>> {
let Some(row) = sqlx::query("SELECT id,permission,allowed_cidrs FROM administration_api_tokens WHERE token_hash = ?")
.bind(hash).fetch_optional(&self.pool).await? else { return Ok(None); };
let cidrs: String = row.try_get("allowed_cidrs")?;
let allowed: Vec<String> = serde_json::from_str(&cidrs).context("invalid stored CIDR list")?;
if !allowed.iter().filter_map(|s| s.parse::<ipnet::IpNet>().ok()).any(|net| net.contains(&ip)) {
return Ok(None);
}
let id: String = row.try_get("id")?;
let permission: String = row.try_get("permission")?;
sqlx::query("UPDATE administration_api_tokens SET last_used_at = ? WHERE id = ?")
.bind(crate::auth::now()).bind(&id).execute(&self.pool).await?;
Ok(Some(ApiTokenGrant { id, permission }))
}
pub async fn prometheus_token_hash(&self) -> Result<Option<String>> {
Ok(sqlx::query_scalar("SELECT token_hash FROM prometheus_credential WHERE id = 1")
.fetch_optional(&self.pool).await?)
}
pub async fn prometheus_token_status(&self) -> Result<Option<i64>> {
Ok(sqlx::query_scalar("SELECT created_at FROM prometheus_credential WHERE id = 1 AND token_hash != 'revoked'")
.fetch_optional(&self.pool).await?)
}
pub async fn rotate_prometheus_token(&self, hash: &str, actor: &str) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("INSERT INTO prometheus_credential(id,token_hash,created_at) VALUES(1,?,?) ON CONFLICT(id) DO UPDATE SET token_hash=excluded.token_hash,created_at=excluded.created_at")
.bind(hash).bind(crate::auth::now()).execute(&mut *tx).await?;
audit_tx(&mut tx, actor, "prometheus_token.rotate", "exporter token rotated").await?;
tx.commit().await?;
Ok(())
}
pub async fn revoke_prometheus_token(&self, actor: &str) -> Result<()> {
let mut tx = self.pool.begin().await?;
// Keep a disabled marker so an old environment token does not silently
// become valid again after the operator clicks Revoke in the panel.
sqlx::query("INSERT INTO prometheus_credential(id,token_hash,created_at) VALUES(1,'revoked',?) ON CONFLICT(id) DO UPDATE SET token_hash='revoked',created_at=excluded.created_at")
.bind(crate::auth::now()).execute(&mut *tx).await?;
audit_tx(&mut tx, actor, "prometheus_token.revoke", "exporter token revoked").await?;
tx.commit().await?;
Ok(())
}
}
+15
View File
@@ -2,6 +2,7 @@ async fn run(
data_dir: PathBuf,
startup_admin_addr: SocketAddr,
options: ServeOptions,
file_config: Option<PathBuf>,
startup_locks: rust_proxy::settings::StartupLocks,
update_check: rust_proxy::update_check::Config,
) -> Result<()> {
@@ -74,7 +75,15 @@ async fn run(
};
let store = Store::open(&data_dir.join("config.sqlite3"), &initial).await?;
let mut saved = store.load().await?;
let external = if let Some(path) = file_config {
Some((path.clone(), rust_proxy::runtime::external_config::read(&path)?))
} else { None };
let saved_revision = saved.revision;
let stored_document = serde_json::to_value(&saved.config)?;
if let Some((_, (config, _))) = &external { saved.config = config.clone(); }
startup_locks.ensure_config(&mut saved.config);
let changed_on_disk = external.is_some() && serde_json::to_value(&saved.config)? != stored_document;
if changed_on_disk { saved.revision = saved.revision.checked_add(1).context("revision overflow")?; }
startup_locks
.validate(&saved.config, startup_admin_addr)
.context("stored configuration conflicts with a listener fixed by systemd/startup")?;
@@ -129,6 +138,10 @@ async fn run(
})
.await?
.context("stored configuration is invalid; no automatic reset was performed")?;
if changed_on_disk {
ensure!(store.replace_as(saved_revision, &snapshot.envelope.config, "file-startup").await?,
"configuration history changed while loading --config");
}
rust_proxy::logging::configure_error_log(snapshot.envelope.config.error_log.clone());
let admin_tls_config = {
let tls = snapshot.envelope.config.tls.clone();
@@ -166,6 +179,8 @@ async fn run(
startup_locks,
geoip,
);
if let Some((path, (_, hash))) = external { state.set_config_file(path, hash).await; }
state.prometheus_token_hash.store(store.prometheus_token_hash().await?.map(Arc::new));
state.acme.restore_renewals(renewal_observations);
// Bind EVERY listener before spawning servers: a missing private IP or
// occupied port fails startup atomically, never falling back to 0.0.0.0.
+9
View File
@@ -51,6 +51,9 @@ struct Cli {
default_value = "/var/lib/rustproxy"
)]
data_dir: PathBuf,
/// Use an existing JSON file as canonical desired configuration.
#[arg(long, global = true, env = "RUST_PROXY_CONFIG")]
config: Option<PathBuf>,
#[arg(
long,
global = true,
@@ -78,6 +81,8 @@ struct Cli {
enum Command {
/// Start proxy and local administration listener.
Serve(ServeOptions),
/// Soft reload configuration and certificate files in the running service.
Reload,
/// Compatibility alias for `admin setup-token`; never an API access token.
Token,
/// Local administrator setup, password recovery and session management.
@@ -349,6 +354,9 @@ async fn application() -> Result<()> {
.command
.context("a subcommand is required (or use --livelogs)")?;
match command {
Command::Reload => {
config_command(resolve_installed_data_dir(cli.data_dir, data_dir_explicit), cli.admin_listen, ConfigCommand::Reload).await
}
Command::Token => {
admin_command(
resolve_installed_data_dir(cli.data_dir, data_dir_explicit),
@@ -398,6 +406,7 @@ async fn application() -> Result<()> {
cli.data_dir,
cli.admin_listen,
options,
cli.config,
startup_locks,
update_check,
)
+2 -2
View File
@@ -86,8 +86,8 @@ async fn config_command(dir: PathBuf, addr: SocketAddr, action: ConfigCommand) -
match action {
ConfigCommand::Get => println!("{}", serde_json::to_string_pretty(&current.config)?),
ConfigCommand::Reload => {
let body = serde_json::json!({"expected_revision":current.revision,"config":current.config}).to_string();
let updated: Envelope = serde_json::from_str(&api(&admin, &session, Method::PUT, "/api/config", Some(body)).await?)?;
let body = serde_json::json!({"expected_revision":current.revision}).to_string();
let updated: Envelope = serde_json::from_str(&api(&admin, &session, Method::POST, "/api/config/reload", Some(body)).await?)?;
println!("Reloaded configuration and certificates at revision {}.", updated.revision);
},
ConfigCommand::Apply { file } => {
+35 -31
View File
@@ -99,13 +99,9 @@ impl Settings {
"metrics: token environment variable must use A-Z, 0-9, underscore"
);
}
ensure!(
!self.enabled
|| self.listen.ip().is_loopback()
|| self.bearer_token_env.is_some()
|| self.allow_trusted_network_without_token,
"metrics: a non-loopback bind requires a bearer token or explicit trusted-network token bypass; protect HTTP with a private network/TLS tunnel"
);
// Runtime checks a generated credential (stored in SQLite) or the
// legacy environment variable. Without either, scrapes fail closed.
// Non-loopback HTTP still needs a private network/TLS tunnel.
Ok(())
}
pub fn restart_differs(&self, other: &Self) -> bool {
@@ -166,32 +162,40 @@ async fn scrape(State(exporter): State<Arc<Exporter>>, request: Request) -> Resp
if !allowed {
return StatusCode::FORBIDDEN.into_response();
}
if let Some(expected) = &exporter.token {
if settings.allow_trusted_network_without_token {
// The actual transport peer already matched an administrator-configured
// CIDR above. Forwarded headers never participate in this decision.
if !settings.allow_trusted_network_without_token {
let values: Vec<_> = request
.headers()
.get_all(http::header::AUTHORIZATION)
.iter()
.collect();
let provided = if values.len() == 1 {
values[0]
.to_str()
.ok()
.and_then(|s| s.strip_prefix("Bearer "))
} else {
let values: Vec<_> = request
.headers()
.get_all(http::header::AUTHORIZATION)
.iter()
.collect();
let valid = values.len() == 1
&& values[0]
.to_str()
.ok()
.and_then(|v| v.strip_prefix("Bearer "))
.is_some_and(|value| crate::auth::equal_secret(value, expected));
if !valid {
return (
StatusCode::UNAUTHORIZED,
[
("www-authenticate", "Bearer"),
("cache-control", "no-store"),
],
)
.into_response();
None
};
let generated = exporter.app.prometheus_token_hash.load_full();
let valid = provided.is_some_and(|value| {
if let Some(hash) = generated.as_deref() {
crate::auth::equal_secret(&crate::auth::secret_hash(value), hash)
} else {
exporter
.token
.as_ref()
.is_some_and(|token| crate::auth::equal_secret(value, token))
}
});
if !valid {
return (
StatusCode::UNAUTHORIZED,
[
("www-authenticate", "Bearer"),
("cache-control", "no-store"),
],
)
.into_response();
}
}
let Ok(_permit) = exporter.slots.try_acquire() else {
+3 -1
View File
@@ -8,7 +8,9 @@ mod tests {
settings.validate().unwrap();
settings.enabled = true;
settings.listen = "0.0.0.0:9191".parse().unwrap();
assert!(settings.validate().is_err());
// With generated tokens validation permits remote binds; without a
// configured credential the exporter itself returns 401, never 200.
assert!(settings.validate().is_ok());
settings.bearer_token_env = Some("RUST_PROXY_METRICS_TOKEN".into());
settings.validate().unwrap();
settings.bearer_token_env = None;
+1
View File
@@ -1,3 +1,4 @@
pub mod external_config;
include!("runtime/types.rs");
include!("runtime/constructors.rs");
include!("runtime/updates.rs");
+2
View File
@@ -158,6 +158,7 @@ impl App {
basic_workers: Arc::new(Semaphore::new(2)),
basic_limits: Default::default(),
active_prometheus,
prometheus_token_hash: arc_swap::ArcSwapOption::from(None),
active_administration,
active_administration_tls_policy,
active_administration_certificate_files,
@@ -193,6 +194,7 @@ impl App {
started: Instant::now(),
header_timeout,
body_idle_timeout,
config_file: Mutex::new(None),
update_lock: Mutex::new(()),
update_slots: Arc::new(Semaphore::new(8)),
});
+89
View File
@@ -0,0 +1,89 @@
//! Explicit JSON configuration backing file. SQLite remains the revision/audit journal.
//! File replacement is atomic on the containing filesystem and refuses symlink targets.
use crate::config::{Config, MAX_CONFIG_BYTES};
use anyhow::{bail, ensure, Context, Result};
use sha2::{Digest, Sha256};
use std::{
fs,
io::{Read, Write},
path::{Path, PathBuf},
};
pub type Fingerprint = [u8; 32];
pub fn read(path: &Path) -> Result<(Config, Fingerprint)> {
ensure!(path.is_absolute(), "--config requires an absolute filename");
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options
.open(path)
.with_context(|| format!("cannot open configuration file {}", path.display()))?;
let meta = file.metadata()?;
ensure!(
meta.is_file() && meta.len() <= MAX_CONFIG_BYTES as u64,
"configuration file must be a regular, non-symlink JSON file within size limit"
);
let mut contents = Vec::new();
file.take((MAX_CONFIG_BYTES as u64) + 1)
.read_to_end(&mut contents)?;
ensure!(
contents.len() <= MAX_CONFIG_BYTES,
"configuration file exceeds size limit"
);
let config = serde_json::from_slice(&contents).context("invalid --config JSON")?;
Ok((config, Sha256::digest(&contents).into()))
}
// Only compare fingerprints of bytes, never raw secrets in logs. Caller serializes
// saves under App::update_lock; conflicts with out-of-band file edits fail closed.
pub fn save(path: &Path, expected: Fingerprint, config: &Config) -> Result<Fingerprint> {
let (_, actual) = read(path)?;
if actual != expected {
bail!("configuration file changed externally; reload before saving");
}
let bytes = serde_json::to_vec_pretty(config)?;
ensure!(
bytes.len() <= MAX_CONFIG_BYTES,
"configuration exceeds file size limit"
);
let parent = path
.parent()
.context("configuration has no parent directory")?;
let name = path
.file_name()
.context("configuration has no filename")?
.to_string_lossy();
let temp: PathBuf = parent.join(format!(
".{name}.rustproxy-{}",
crate::auth::random_secret()
));
let result = (|| -> Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
let mut file = options.open(&temp)?;
file.write_all(&bytes)?;
file.sync_all()?;
// An external writer may have changed the source while the temp was prepared.
if read(path)?.1 != expected {
bail!("configuration file changed during save");
}
fs::rename(&temp, path)?;
fs::File::open(parent)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(temp);
}
result?;
Ok(Sha256::digest(&bytes).into())
}
+7 -1
View File
@@ -23,6 +23,7 @@ pub struct App {
pub basic_workers: Arc<Semaphore>,
pub basic_limits: crate::basic_auth::Limits,
pub active_prometheus: crate::prometheus::Settings,
pub prometheus_token_hash: arc_swap::ArcSwapOption<String>,
pub active_administration: crate::settings::Administration,
pub active_administration_tls_policy: crate::tls::ProtocolPolicy,
pub active_administration_certificate_files: Option<(String, String)>,
@@ -58,6 +59,7 @@ pub struct App {
pub started: Instant,
pub header_timeout: Duration,
pub body_idle_timeout: Duration,
pub config_file: Mutex<Option<(std::path::PathBuf, external_config::Fingerprint)>>,
update_lock: Mutex<()>,
update_slots: Arc<Semaphore>,
}
@@ -173,7 +175,11 @@ fn vhost_cache_revision(vhost: &crate::vhost::Vhost) -> u64 {
}
fn vhost_cache_revisions(config: &config::Config) -> HashMap<String, u64> {
config.vhosts.iter().map(|vhost| (vhost.id.clone(), vhost_cache_revision(vhost))).collect()
config
.vhosts
.iter()
.map(|vhost| (vhost.id.clone(), vhost_cache_revision(vhost)))
.collect()
}
fn cache_invalidation(previous: &config::Config, next: &config::Config) -> CacheInvalidation {
+129 -17
View File
@@ -1,7 +1,57 @@
impl App {
pub async fn set_config_file(
&self,
path: std::path::PathBuf,
hash: external_config::Fingerprint,
) {
*self.config_file.lock().await = Some((path, hash));
}
/// Reads an operator-edited JSON file and publishes it through the same
/// validation, history and hot-apply path as a panel save.
pub async fn reload_from_file(
self: &Arc<Self>,
expected_revision: u64,
) -> Result<config::Envelope, ApiError> {
let file = self
.config_file
.lock()
.await
.as_ref()
.map(|(path, _)| path.clone());
let Some(file) = file else {
let config = self.config.load().envelope.config.clone();
return self
.update_as(
Update {
expected_revision,
config,
},
"reload".into(),
)
.await;
};
let (config, fingerprint) =
tokio::task::spawn_blocking(move || external_config::read(&file))
.await
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{e:#}")))?;
self.update_as_action(
Update {
expected_revision,
config,
},
"file-reload".into(),
None,
Some(fingerprint),
)
.await
}
pub fn start_dns_acl_updates(self: &Arc<Self>) {
self.tunnels.spawn(crate::acl::dynamic::poll_loop(
Arc::downgrade(self), self.shutdown.clone(),
Arc::downgrade(self),
self.shutdown.clone(),
));
}
@@ -34,7 +84,7 @@ impl App {
update: Update,
actor: String,
) -> Result<config::Envelope, ApiError> {
self.update_as_action(update, actor, None).await
self.update_as_action(update, actor, None, None).await
}
pub async fn restore_as(
self: &Arc<Self>,
@@ -42,7 +92,7 @@ impl App {
actor: String,
source_revision: u64,
) -> Result<config::Envelope, ApiError> {
self.update_as_action(update, actor, Some(source_revision))
self.update_as_action(update, actor, Some(source_revision), None)
.await
}
async fn update_as_action(
@@ -50,6 +100,7 @@ impl App {
update: Update,
actor: String,
source_revision: Option<u64>,
reload_hash: Option<external_config::Fingerprint>,
) -> Result<config::Envelope, ApiError> {
let slot = self.update_slots.clone().try_acquire_owned().map_err(|_| {
ApiError(
@@ -110,6 +161,39 @@ impl App {
"configuration changed; reload before saving".into(),
));
}
// Explicit --config is the source of truth. A panel save writes the
// JSON file before recording its revision in SQLite, while a manual
// reload imports the operator-edited file without overwriting it.
let file_updated_hash =
if let Some((path, last_hash)) = this.config_file.lock().await.as_ref().cloned() {
let config = prepared.envelope.config.clone();
let target = path.clone();
let result = tokio::task::spawn_blocking(move || {
if let Some(reloaded) = reload_hash {
if external_config::read(&target)?.1 != reloaded {
anyhow::bail!("configuration file changed during reload");
}
Ok(reloaded)
} else {
external_config::save(&target, last_hash, &config)
}
})
.await;
match result {
Ok(Ok(hash)) => Some((path, last_hash, hash)),
Ok(Err(error)) => {
return Err(ApiError(StatusCode::CONFLICT, format!("{error:#}")))
}
Err(_) => {
return Err(ApiError(
StatusCode::INTERNAL_SERVER_ERROR,
"configuration file writer failed".into(),
))
}
}
} else {
None
};
let persisted = match source_revision {
Some(source) => {
this.store
@@ -127,21 +211,48 @@ impl App {
.await
}
};
match persisted {
Ok(true) => {}
Ok(false) => {
return Err(ApiError(
if !matches!(&persisted, Ok(true)) {
// The database commit failed. Best-effort file rollback only
// for saves (never for an operator-authored manual reload).
if reload_hash.is_none() {
if let Some((path, _, new_hash)) = &file_updated_hash {
let path = path.clone();
let rollback_path = path.clone();
let old_config = this.config.load().envelope.config.clone();
let new_hash = *new_hash;
match tokio::task::spawn_blocking(move || {
external_config::save(&rollback_path, new_hash, &old_config)
})
.await
{
Ok(Ok(restored_hash)) => {
// The old document may be reformatted, so use its new fingerprint.
*this.config_file.lock().await = Some((path, restored_hash));
}
result => tracing::error!(
?result,
"configuration file rollback failed; investigate before continuing"
),
}
}
}
return match persisted {
Ok(false) => Err(ApiError(
StatusCode::CONFLICT,
"stored revision differs; external SQLite writes are unsupported".into(),
))
}
Err(error) => {
tracing::error!(%error, "configuration persistence failed");
return Err(ApiError(
StatusCode::INTERNAL_SERVER_ERROR,
"configuration was not saved".into(),
));
}
)),
Err(error) => {
tracing::error!(%error, "configuration persistence failed");
Err(ApiError(
StatusCode::INTERNAL_SERVER_ERROR,
"configuration was not saved".into(),
))
}
Ok(true) => unreachable!(),
};
}
if let Some((path, _, new_hash)) = file_updated_hash.as_ref() {
*this.config_file.lock().await = Some((path.clone(), *new_hash));
}
// No await between successful commit and publication. Cache identity is
// intentionally decoupled from the configuration revision, so unrelated
@@ -164,7 +275,8 @@ impl App {
// A COLD entry is usable after restart only if its scope still has
// the same origin/content policy. Publish the fingerprints after
// the cache generation barriers but before the routing snapshot.
this.cache.replace_scope_revisions(vhost_cache_revisions(&envelope.config));
this.cache
.replace_scope_revisions(vhost_cache_revisions(&envelope.config));
this.cache.configure(envelope.config.cache.clone());
this.rate_limiter.reset();
this.logs.configure(envelope.config.logging.clone());