0.34.6_repo_cleanup

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 09:31:15 +02:00
1 parent ce1a90397c
commit 133d2f0f91
273 files changed
+5969 -6926

No files matched your search

+7 -34
View File
@@ -46,8 +46,6 @@ fn single_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
}
}
fn unauthorized(request: &Request) -> Response {
// Only top-level documentation navigation gets a fixed, same-origin login
// redirect. API/spec/assets ALWAYS return a small 401, never a login HTML body.
if (request.method() == Method::GET || request.method() == Method::HEAD)
&& matches!(request.uri().path(), "/docs" | "/docs/")
{
@@ -65,9 +63,6 @@ fn admin_request_authority(request: &Request, tls: bool) -> Result<String, ()> {
};
if matches!(request.version(), Version::HTTP_2 | Version::HTTP_3) {
// Hyper exposes :authority through the request URI for H2/H3. Browsers do
// not have to send a legacy Host field, so requiring one here rejects
// perfectly valid administration requests negotiated over ALPN h2.
let authority = request.uri().authority().ok_or(())?.as_str();
if let Some(scheme) = request.uri().scheme_str() {
if scheme != if tls { "https" } else { "http" } {
@@ -86,9 +81,6 @@ fn admin_request_authority(request: &Request, tls: bool) -> Result<String, ()> {
return Ok(authority.to_owned());
}
// The administration listener is an origin server, never a forward proxy.
// HTTP/1.x therefore keeps the existing exact single-Host requirement and
// rejects absolute-form targets.
if request.uri().scheme().is_some() || request.uri().authority().is_some() {
return Err(());
}
@@ -131,14 +123,7 @@ fn admin_host_allowed(
!a.as_str().contains('@')
&& ((direct_host && port == configured_addr.port())
// An explicit DNS allow-list describes the browser-facing
// authority. A reverse proxy is therefore free to terminate
// on 80/443 (or another external port) while forwarding to
// the private administration socket on its configured port.
|| explicit_allowed
// Preserve the historical direct-TLS convenience when no
// strict DNS allow-list exists; unlike the explicit list it
// remains bound to the actual administration listener port.
|| (dns_without_allowlist && port == configured_addr.port()))
})
}
@@ -229,21 +214,19 @@ fn external_admin_authority(
}
}
// In pinned-origin mode accept only bounded, consistent proxy-hop metadata:
// the first hop must report browser HTTPS, later internal hops may use HTTP.
// We still do NOT pick a trusted scheme from these values; the explicit pin
// defines the panel origin. A clear public HTTP or unknown value fails closed.
fn pinned_admin_proto_compatible(headers: &HeaderMap) -> bool {
let mut hops = 0;
for value in headers.get_all("x-forwarded-proto").iter() {
let Ok(value) = value.to_str() else { return false; };
let Ok(value) = value.to_str() else {
return false;
};
for part in value.split(',') {
hops += 1;
if hops > 8 { return false; }
if hops > 8 {
return false;
}
let scheme = part.trim();
if (hops == 1 && scheme != "https")
|| !matches!(scheme, "http" | "https")
{
if (hops == 1 && scheme != "https") || !matches!(scheme, "http" | "https") {
return false;
}
}
@@ -251,11 +234,6 @@ fn pinned_admin_proto_compatible(headers: &HeaderMap) -> bool {
true
}
/// A configured panel URL is authoritative only for an explicitly trusted
/// direct reverse-proxy peer. In this mode X-Forwarded-Proto is deliberately
/// ignored: conflicting hop values such as "https, http" are not evidence of
/// the browser scheme. The public URL comes from administrator configuration,
/// not from a user-controlled header.
fn configured_admin_public_authority(
state: &App,
headers: &HeaderMap,
@@ -267,7 +245,6 @@ fn configured_admin_public_authority(
return Ok(None);
};
if !trusted_admin_proxy(state, peer) {
// Never apply the pin to arbitrary direct/untrusted connections.
return Ok(None);
}
if !admin_host_allowed(
@@ -314,10 +291,6 @@ fn external_admin_scheme(
}
}
/// Plain HTTP requests accepted on an HTTPS administration socket are redirected
/// to the same authority/path. The TLS server only selects this router after it
/// has positively identified a non-TLS first byte, so admin content is never
/// served over this path.
pub fn https_redirect_router() -> Router {
Router::new().fallback(|request: Request| async move {
let raw_host = request
-2
View File
@@ -1,5 +1,3 @@
// 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!(
-2
View File
@@ -1,5 +1,3 @@
// 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 {
+24 -10
View File
@@ -119,8 +119,6 @@ async fn certificate_api_rotate(
Ok(Json(value))
}
// Per-credential settings are intentionally separate from token rotation.
// The input cannot include token/token_hash, and every read returns only metadata.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CertificateApiEdit {
@@ -134,7 +132,10 @@ async fn certificate_api_detail(
Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
let credential = state
.store
@@ -152,11 +153,17 @@ async fn certificate_api_edit(
Json(input): Json<CertificateApiEdit>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
let comment = input.comment.trim();
if !valid_certificate_api_comment(comment) {
return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "certificate_api.invalid_comment".into()));
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"certificate_api.invalid_comment".into(),
));
}
let mut certificate_ids: Vec<String> = input
.certificate_ids
@@ -168,7 +175,9 @@ async fn certificate_api_edit(
certificate_ids.dedup();
if certificate_ids.is_empty()
|| certificate_ids.len() > 32
|| certificate_ids.iter().any(|value| value == "*" || !crate::config::valid_id(value))
|| certificate_ids
.iter()
.any(|value| value == "*" || !crate::config::valid_id(value))
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
@@ -178,7 +187,11 @@ async fn certificate_api_edit(
let credential = state
.store
.edit_certificate_api_credential(
&key_id, comment, input.allow_deploy, &certificate_ids, &session.username,
&key_id,
comment,
input.allow_deploy,
&certificate_ids,
&session.username,
)
.await
.map_err(auth::internal)?
@@ -192,7 +205,10 @@ async fn certificate_api_regenerate(
Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
let token = auth::random_secret();
let hash = auth::secret_hash(&token);
@@ -202,7 +218,6 @@ async fn certificate_api_regenerate(
.await
.map_err(auth::internal)?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?;
// The only endpoint in this flow that returns the new bearer secret.
Ok(Json(json!({ "credential": credential, "token": token })))
}
@@ -242,4 +257,3 @@ async fn certificate_api_revoke_all(
.map_err(auth::internal)?;
Ok(Json(json!({"configured": false, "revoked": revoked})))
}
-4
View File
@@ -24,7 +24,6 @@ async fn certificate_deploy(
));
}
}
// Check the credential scope before disclosing certificate storage state.
let directory = state.policy.cert_dir.clone().ok_or_else(|| {
ApiError(
StatusCode::CONFLICT,
@@ -106,8 +105,6 @@ async fn certificate_deploy(
let key_file = config.tls.certificates[index].key_file.clone();
if config.tls.certificates[index].covered_hosts == covered_hosts {
// A no-op does not pass through update_as(), so explicitly retain
// the same optimistic-concurrency guarantee as a real publish.
if state.config.load().envelope.revision != expected_revision {
return Err(ApiError(
StatusCode::CONFLICT,
@@ -423,7 +420,6 @@ async fn basic_hash(
) -> Result<Json<Value>, ApiError> {
crate::basic_auth::validate_username(&input.username)
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "basic.invalid_username".into()))?;
// The semaphore is held by the blocking worker even if the HTTP client disconnects.
let permit = state
.basic_workers
.clone()
-3
View File
@@ -1,4 +1,3 @@
/// Account-scoped snapshots of Overview order, visibility, widths and display.
use std::collections::BTreeMap;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -63,8 +62,6 @@ async fn dashboard_presets(
tracing::warn!("skipped invalid saved Overview preset");
continue;
};
// Compatibility with future/retired widgets: use the same registry
// reconciliation logic as ordinary saved administrator preferences.
let mut prefs = AdminPreferences::default();
prefs.dashboard_widgets = layout.dashboard_widgets;
prefs.dashboard_hidden_widgets = layout.dashboard_hidden_widgets;
+3 -8
View File
@@ -1,5 +1,3 @@
//! Authenticated documentation Reader and OpenAPI/Swagger UI on the admin listener.
//! Documentation and vendor assets are embedded at compile time; no runtime filesystem or CDN access.
use axum::{
response::{Html, IntoResponse, Response},
Json,
@@ -8,9 +6,6 @@ use http::StatusCode;
use serde_json::{json, Value};
use std::sync::OnceLock;
// Keep Reader input independent from build-script output. `include_str!` makes every
// document a direct compiler input, so a clean or partially removed target directory
// cannot leave the crate pointing at a missing OUT_DIR artifact.
mod documents;
use documents::READER_DOCUMENTS;
@@ -18,7 +13,9 @@ pub(super) async fn index() -> impl IntoResponse {
Html(include_str!("web/docs.html").replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id()))
}
pub(super) async fn fingerprinted(axum::extract::Path(name): axum::extract::Path<String>) -> Response {
pub(super) async fn fingerprinted(
axum::extract::Path(name): axum::extract::Path<String>,
) -> Response {
let full = format!("/docs/{name}");
match super::fingerprinted_path(&full, "/docs/").as_deref() {
Some("/docs/init.js") => script().await.into_response(),
@@ -93,8 +90,6 @@ pub(super) async fn schema() -> Json<Value> {
}
pub(super) fn asset(name: &'static str) -> Response {
// Bytes are embedded for debug AND release, and always served through the
// authenticated router. Never read a file or fetch a CDN on an HTTP request.
let (content_type, bytes): (&'static str, &'static [u8]) = match name {
"swagger-ui-bundle.js" => (
"text/javascript; charset=utf-8",
-3
View File
@@ -1,6 +1,3 @@
//! Embedded current-version Markdown guides for the authenticated documentation Reader.
//! Keep this list in sync with docs/README.md. The embedded files must exist at build time.
macro_rules! reader_doc {
($path:literal, $category:literal) => {
(
+60 -28
View File
@@ -1,12 +1,11 @@
// Bound candidate retention while scanning potentially enormous rotation folders.
// The heap's top is the least desirable result: oldest timestamp, then
// lexicographically last name. Final output preserves newest/name order.
fn newest_log_files(
candidates: impl Iterator<Item = (String, std::path::PathBuf, u64)>,
limit: usize,
) -> Vec<(String, std::path::PathBuf, u64)> {
use std::{cmp::Reverse, collections::BinaryHeap};
if limit == 0 { return Vec::new(); }
if limit == 0 {
return Vec::new();
}
let mut bounded = BinaryHeap::with_capacity(limit);
for (name, path, modified_ms) in candidates {
let item = (Reverse(modified_ms), name, path);
@@ -17,14 +16,14 @@ fn newest_log_files(
bounded.push(item);
}
}
let mut result = bounded.into_iter()
let mut result = bounded
.into_iter()
.map(|(Reverse(modified), name, path)| (name, path, modified))
.collect::<Vec<_>>();
result.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
result
}
// Bounded file-log inventory helpers and gzip tail reader.
fn rotated_gzip_sources(
active: &std::path::Path,
id_prefix: &str,
@@ -33,25 +32,52 @@ fn rotated_gzip_sources(
enabled: bool,
format: &'static str,
) -> Vec<FileLogSource> {
let Some(parent) = active.parent() else { return Vec::new(); };
let Some(base) = active.file_name().and_then(|name| name.to_str()) else { return Vec::new(); };
let safe = std::fs::symlink_metadata(parent).ok().is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
if !safe { return Vec::new(); }
let Some(parent) = active.parent() else {
return Vec::new();
};
let Some(base) = active.file_name().and_then(|name| name.to_str()) else {
return Vec::new();
};
let safe = std::fs::symlink_metadata(parent)
.ok()
.is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
if !safe {
return Vec::new();
}
let prefix = format!("{base}.");
let entries = newest_log_files(std::fs::read_dir(parent).ok().into_iter().flat_map(|iter| iter.filter_map(|entry| entry.ok())).filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
if name.len() > 180 || !name.starts_with(&prefix) || !name.ends_with(".gz") { return None; }
let file_type = entry.file_type().ok()?;
if !file_type.is_file() || file_type.is_symlink() { return None; }
let meta = entry.metadata().ok()?;
Some((name, entry.path(), file_modified_ms(&meta)))
}), 64);
entries.into_iter().map(|(name,path,_)| source_from_path(
format!("{id_prefix}:gz:{name}"),
format!("{label_prefix} · {name}"),
id_prefix.to_owned(),
kind, path, enabled, format
)).collect()
let entries = newest_log_files(
std::fs::read_dir(parent)
.ok()
.into_iter()
.flat_map(|iter| iter.filter_map(|entry| entry.ok()))
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
if name.len() > 180 || !name.starts_with(&prefix) || !name.ends_with(".gz") {
return None;
}
let file_type = entry.file_type().ok()?;
if !file_type.is_file() || file_type.is_symlink() {
return None;
}
let meta = entry.metadata().ok()?;
Some((name, entry.path(), file_modified_ms(&meta)))
}),
64,
);
entries
.into_iter()
.map(|(name, path, _)| {
source_from_path(
format!("{id_prefix}:gz:{name}"),
format!("{label_prefix} · {name}"),
id_prefix.to_owned(),
kind,
path,
enabled,
format,
)
})
.collect()
}
fn open_file_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
@@ -81,8 +107,6 @@ fn read_tail(
let mut file = open_file_log(&source.path)?;
let length = file.metadata()?.len();
if source.compressed {
// gzip streams are not seekable. Read a complete bounded rotation, then
// keep only the same 2 MiB decompressed tail used for plain files.
if length > budget || length > FILE_LOG_GZIP_INPUT_MAX {
return Ok((Vec::new(), length.min(budget), true));
}
@@ -108,7 +132,11 @@ fn read_tail(
}
let text = String::from_utf8_lossy(&content);
let mut iter = text.lines().rev();
let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::<Vec<_>>();
let lines = iter
.by_ref()
.take(line_budget)
.map(str::to_owned)
.collect::<Vec<_>>();
return Ok((lines, length, truncated || iter.next().is_some()));
}
let bytes = length.min(budget).min(FILE_LOG_SCAN_BYTES_PER_SOURCE);
@@ -126,6 +154,10 @@ fn read_tail(
}
let text = String::from_utf8_lossy(&content);
let mut iter = text.lines().rev();
let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::<Vec<_>>();
let lines = iter
.by_ref()
.take(line_budget)
.map(str::to_owned)
.collect::<Vec<_>>();
Ok((lines, bytes, truncated || iter.next().is_some()))
}
+88 -34
View File
@@ -50,8 +50,6 @@ fn record_matches(value: &Value, query: &FileLogQuery, search: &[FileLogSearchCl
true
}
// Order the entire bounded matching scan before applying offset/limit. Sorting
// only the current browser page would give misleading navigation results.
#[derive(Debug)]
enum FileLogSortKey {
Number(serde_json::Number),
@@ -59,29 +57,43 @@ enum FileLogSortKey {
}
fn stored_log_summary(value: &Value) -> String {
let field = |name| value.get(name).and_then(Value::as_str).filter(|text| !text.is_empty());
let field = |name| {
value
.get(name)
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
};
if let Some(reason) = field("reason").or_else(|| field("protocol_error")) {
return reason.to_owned();
}
if field("method").is_some() || field("path").is_some() {
return format!("{} {}{}", field("method").unwrap_or(""),
return format!(
"{} {}{}",
field("method").unwrap_or(""),
field("host").or_else(|| field("authority")).unwrap_or(""),
field("path").unwrap_or("")).trim().to_owned();
field("path").unwrap_or("")
)
.trim()
.to_owned();
}
for key in ["error", "error_detail", "message", "raw", "outcome"] {
if let Some(text) = field(key) { return text.to_owned(); }
if let Some(text) = field(key) {
return text.to_owned();
}
}
String::new()
}
fn stored_log_sort_key(record: &Value, field: &str) -> Option<FileLogSortKey> {
if field == "record" {
return Some(FileLogSortKey::Text(stored_log_summary(record).to_lowercase()));
return Some(FileLogSortKey::Text(
stored_log_summary(record).to_lowercase(),
));
}
// Prefer millisecond timestamps for numeric chronological sorting. RAW
// file timestamps use the existing ISO/text fallback.
let value = if field == "time" {
record.get("time_ms").filter(|value| value.is_number())
record
.get("time_ms")
.filter(|value| value.is_number())
.or_else(|| record.get("timestamp"))
} else {
file_log_field(record, field)
@@ -91,39 +103,51 @@ fn stored_log_sort_key(record: &Value, field: &str) -> Option<FileLogSortKey> {
Value::Number(number) => Some(FileLogSortKey::Number(number.clone())),
Value::String(text) if !text.is_empty() => Some(FileLogSortKey::Text(text.to_lowercase())),
Value::Bool(boolean) => Some(FileLogSortKey::Text(boolean.to_string())),
Value::Array(_) | Value::Object(_) => Some(FileLogSortKey::Text(value.to_string().to_lowercase())),
Value::Array(_) | Value::Object(_) => {
Some(FileLogSortKey::Text(value.to_string().to_lowercase()))
}
_ => None,
}
}
fn file_log_timestamp_cmp_desc(left: &Value, right: &Value) -> std::cmp::Ordering {
match (left.get("time_ms").and_then(Value::as_u64), right.get("time_ms").and_then(Value::as_u64)) {
match (
left.get("time_ms").and_then(Value::as_u64),
right.get("time_ms").and_then(Value::as_u64),
) {
(Some(a), Some(b)) => b.cmp(&a),
_ => right.get("timestamp").and_then(Value::as_str).unwrap_or("")
_ => right
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("")
.cmp(left.get("timestamp").and_then(Value::as_str).unwrap_or("")),
}
}
fn sort_file_log_records(records: &mut Vec<Value>, field: &str, descending: bool) {
use std::cmp::Ordering;
// Build one key per record; do not repeatedly allocate/parse strings in
// the O(n log n) comparison loop. The scan itself is limited to 20k lines.
let mut keyed = std::mem::take(records).into_iter().enumerate()
let mut keyed = std::mem::take(records)
.into_iter()
.enumerate()
.map(|(ordinal, record)| (stored_log_sort_key(&record, field), ordinal, record))
.collect::<Vec<_>>();
keyed.sort_by(|left, right| {
let order = match (&left.0, &right.0) {
(None, None) => Ordering::Equal,
(None, Some(_)) => return Ordering::Greater, // missing values always last
(None, Some(_)) => return Ordering::Greater,
(Some(_), None) => return Ordering::Less,
(Some(FileLogSortKey::Number(a)), Some(FileLogSortKey::Number(b))) =>
(Some(FileLogSortKey::Number(a)), Some(FileLogSortKey::Number(b))) => {
match (a.as_i64(), b.as_i64()) {
(Some(left), Some(right)) => left.cmp(&right),
_ => match (a.as_u64(), b.as_u64()) {
(Some(left), Some(right)) => left.cmp(&right),
_ => a.as_f64().partial_cmp(&b.as_f64()).unwrap_or(Ordering::Equal),
_ => a
.as_f64()
.partial_cmp(&b.as_f64())
.unwrap_or(Ordering::Equal),
},
},
}
}
(Some(FileLogSortKey::Text(a)), Some(FileLogSortKey::Text(b))) => a.cmp(b),
(Some(FileLogSortKey::Number(_)), Some(FileLogSortKey::Text(_))) => Ordering::Less,
(Some(FileLogSortKey::Text(_)), Some(FileLogSortKey::Number(_))) => Ordering::Greater,
@@ -145,10 +169,20 @@ fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> {
|| !text_ok(&query.q, 512)
|| !text_ok(&query.client_ip, 128)
|| !text_ok(&query.vhost, 256)
|| query.aggregate_field.as_ref().is_some_and(|field| !valid_raw_field_key(field))
|| query.sort_by.as_ref().is_some_and(|field| !valid_raw_field_key(field))
|| query.aggregate_limit.is_some_and(|value| value == 0 || value > FILE_LOG_MAX_AGGREGATE_RESULTS)
|| query.aggregate_min_count.is_some_and(|value| value < 2 || value > FILE_LOG_SCAN_LINES)
|| query
.aggregate_field
.as_ref()
.is_some_and(|field| !valid_raw_field_key(field))
|| query
.sort_by
.as_ref()
.is_some_and(|field| !valid_raw_field_key(field))
|| query
.aggregate_limit
.is_some_and(|value| value == 0 || value > FILE_LOG_MAX_AGGREGATE_RESULTS)
|| query
.aggregate_min_count
.is_some_and(|value| value < 2 || value > FILE_LOG_SCAN_LINES)
|| query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET
|| query
.limit
@@ -187,8 +221,11 @@ async fn file_logs_query(
let requested = query.source.as_deref().unwrap_or("all");
let mut truncated = false;
if requested != "all" {
let consolidate_vhosts = query.consolidate_vhosts.unwrap_or(false) && requested.starts_with("vhost:");
sources.retain(|source| source.id == requested || (consolidate_vhosts && source.group_id == requested));
let consolidate_vhosts =
query.consolidate_vhosts.unwrap_or(false) && requested.starts_with("vhost:");
sources.retain(|source| {
source.id == requested || (consolidate_vhosts && source.group_id == requested)
});
if sources.is_empty() {
return Err(ApiError(
StatusCode::BAD_REQUEST,
@@ -210,9 +247,15 @@ async fn file_logs_query(
let mut scanned_lines = 0usize;
let mut records = Vec::new();
let mut fields = std::collections::BTreeSet::new();
let aggregate_field = query.aggregate_field.as_deref().map(str::to_ascii_lowercase);
let aggregate_field = query
.aggregate_field
.as_deref()
.map(str::to_ascii_lowercase);
let aggregate_min_count = query.aggregate_min_count.unwrap_or(2);
let aggregate_limit = query.aggregate_limit.unwrap_or(50).clamp(1, FILE_LOG_MAX_AGGREGATE_RESULTS);
let aggregate_limit = query
.aggregate_limit
.unwrap_or(50)
.clamp(1, FILE_LOG_MAX_AGGREGATE_RESULTS);
let mut aggregate_counts = std::collections::HashMap::<String, usize>::new();
let mut aggregate_truncated = false;
for (index, source) in sources.iter().enumerate() {
@@ -241,8 +284,13 @@ async fn file_logs_query(
let mut values = Vec::new();
file_log_aggregate_values(field_value, &mut values);
for aggregate_value in values {
if aggregate_counts.contains_key(&aggregate_value) || aggregate_counts.len() < FILE_LOG_MAX_AGGREGATE_VALUES {
*aggregate_counts.entry(aggregate_value).or_insert(0) += 1;
if aggregate_counts.contains_key(&aggregate_value)
|| aggregate_counts.len()
< FILE_LOG_MAX_AGGREGATE_VALUES
{
*aggregate_counts
.entry(aggregate_value)
.or_insert(0) += 1;
} else {
aggregate_truncated = true;
}
@@ -257,9 +305,13 @@ async fn file_logs_query(
Err(_) => truncated = true,
}
}
sort_file_log_records(&mut records, query.sort_by.as_deref().unwrap_or("time"),
query.sort_descending.unwrap_or(true));
let mut aggregation = aggregate_counts.into_iter()
sort_file_log_records(
&mut records,
query.sort_by.as_deref().unwrap_or("time"),
query.sort_descending.unwrap_or(true),
);
let mut aggregation = aggregate_counts
.into_iter()
.filter(|(_, count)| *count >= aggregate_min_count)
.map(|(value, count)| json!({"value": value, "count": count}))
.collect::<Vec<_>>();
@@ -267,7 +319,9 @@ async fn file_logs_query(
let left_count = left.get("count").and_then(Value::as_u64).unwrap_or(0);
let right_count = right.get("count").and_then(Value::as_u64).unwrap_or(0);
right_count.cmp(&left_count).then_with(|| {
left.get("value").and_then(Value::as_str).unwrap_or("")
left.get("value")
.and_then(Value::as_str)
.unwrap_or("")
.cmp(right.get("value").and_then(Value::as_str).unwrap_or(""))
})
});
-4
View File
@@ -52,8 +52,6 @@ async fn update_section(
config.logging = part.logging;
config.error_log = part.error_log;
} else {
// Backward compatibility for clients that still send only the
// historical access-logging object to /api/config/logging.
config.logging =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?;
}
@@ -334,8 +332,6 @@ async fn events(
"events.too_many_viewers".into(),
)
})?;
// One bounded snapshot, produced only when the consumer polls. No per-client queue.
// Absolute expiry/revocation is rechecked on every snapshot; no immortal SSE sessions.
let stream = futures_util::stream::unfold(
(state, secret, permit, false, interval),
|(state, secret, permit, delayed, interval)| async move {
-2
View File
@@ -1,5 +1,3 @@
//! Local control plane. Never mounted on the public proxy listener.
include!("router.rs");
include!("access.rs");
include!("api_token_access.rs");
+31 -11
View File
@@ -79,8 +79,16 @@ async fn cache_purge(
));
}
}
let permit = state.cache_invalidation_slots.clone().try_acquire_owned()
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?;
let permit = state
.cache_invalidation_slots
.clone()
.try_acquire_owned()
.map_err(|_| {
ApiError(
StatusCode::TOO_MANY_REQUESTS,
"cache maintenance busy".into(),
)
})?;
let detail = format!(
"scope={}; in-flight fills invalidated",
scope.vhost.as_deref().unwrap_or("all")
@@ -98,7 +106,9 @@ async fn cache_purge(
Some(id) => cache.purge_scope(id),
None => cache.purge(None),
}
}).await.map_err(auth::internal)?;
})
.await
.map_err(auth::internal)?;
Ok(Json(
json!({"purged_entries":purged, "vhost":scope.vhost, "metrics":state.cache.view()}),
))
@@ -126,8 +136,16 @@ async fn cache_invalidate(
));
}
}
let permit = state.cache_invalidation_slots.clone().try_acquire_owned()
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?;
let permit = state
.cache_invalidation_slots
.clone()
.try_acquire_owned()
.map_err(|_| {
ApiError(
StatusCode::TOO_MANY_REQUESTS,
"cache maintenance busy".into(),
)
})?;
let detail = format!(
"operation={:?}; selector={:?}; vhost={}; host={}; value={:?}",
request.operation,
@@ -145,8 +163,10 @@ async fn cache_invalidate(
let result = tokio::task::spawn_blocking(move || {
let _permit = permit;
cache.invalidate(request)
}).await.map_err(auth::internal)?
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?;
})
.await
.map_err(auth::internal)?
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?;
Ok(Json(json!({
"result": result,
"metrics": state.cache.view(),
@@ -163,8 +183,6 @@ async fn cache_ban_delete(
Extension(session): Extension<Session>,
Path(id): Path<u64>,
) -> Result<Json<Value>, ApiError> {
// This is a destructive operation: fail closed if the audit store is
// unavailable, rather than removing a ban and only then trying to log it.
if !state.cache.bans().iter().any(|rule| rule.id == id) {
return Err(ApiError(
StatusCode::NOT_FOUND,
@@ -181,7 +199,10 @@ async fn cache_ban_delete(
.await
.map_err(auth::internal)?;
if !state.cache.remove_ban(id) {
return Err(ApiError(StatusCode::NOT_FOUND, "unknown cache BAN rule".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"unknown cache BAN rule".into(),
));
}
Ok(Json(json!({"removed": id, "bans": state.cache.bans()})))
}
@@ -322,7 +343,6 @@ async fn certificate_upload(
.audit(&session.username, "certificate.upload", &certificate.id)
.await
.map_err(auth::internal)?;
// Never serialize the PEM request or any key material, including on success.
Ok(Json(json!({"certificate":certificate, "staged":true})))
}
+75 -16
View File
@@ -1,10 +1,14 @@
// Fixed private admin assets, shared by all admins. The existing route/access
// allowlist decides which paths are reachable. Do not expand routes dynamically.
use super::panel_content::PanelContent;
use crate::runtime::App;
use axum::{extract::{Request, State}, response::{IntoResponse, Response}};
use axum::{
extract::{Request, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use std::{collections::HashMap, sync::{Arc, LazyLock, OnceLock}};
use std::{
collections::HashMap,
sync::{Arc, LazyLock, OnceLock},
};
struct Asset {
mime: &'static str,
@@ -20,21 +24,57 @@ const SVG: &str = "image/svg+xml";
static ASSETS: LazyLock<HashMap<&'static str, Asset>> = LazyLock::new(|| {
let entries: [(&'static str, &'static str, &'static str); 22] = [
("/assets/login.js", JS, include_str!("web/login.js")),
("/assets/login-langs.json", JSON, include_str!(concat!(env!("OUT_DIR"), "/login-langs.json"))),
("/assets/langs.json", JSON, include_str!(concat!(env!("OUT_DIR"), "/langs.json"))),
(
"/assets/login-langs.json",
JSON,
include_str!(concat!(env!("OUT_DIR"), "/login-langs.json")),
),
(
"/assets/langs.json",
JSON,
include_str!(concat!(env!("OUT_DIR"), "/langs.json")),
),
("/assets/app.js", JS, super::admin_app_javascript()),
("/assets/management.js", JS, include_str!("web/management.js")),
(
"/assets/management.js",
JS,
include_str!("web/management.js"),
),
("/assets/drafts.js", JS, include_str!("web/drafts.js")),
("/assets/integrations.js", JS, include_str!("web/integrations.js")),
("/assets/traffic-panel.js", JS, include_str!("web/traffic-panel.js")),
("/assets/cache-panel.js", JS, include_str!("web/cache-panel.js")),
("/assets/transform-panel.js", JS, include_str!("web/transform-panel.js")),
(
"/assets/integrations.js",
JS,
include_str!("web/integrations.js"),
),
(
"/assets/traffic-panel.js",
JS,
include_str!("web/traffic-panel.js"),
),
(
"/assets/cache-panel.js",
JS,
include_str!("web/cache-panel.js"),
),
(
"/assets/transform-panel.js",
JS,
include_str!("web/transform-panel.js"),
),
("/assets/file-logs.js", JS, include_str!("web/file-logs.js")),
("/assets/settings.js", JS, include_str!("web/settings.js")),
("/assets/acme.js", JS, include_str!("web/acme.js")),
("/assets/vhosts.js", JS, super::admin_vhosts_javascript()),
("/assets/web-server-profiles.js", JS, include_str!("web/web-server-profiles.js")),
("/assets/web-server.js", JS, include_str!("web/web-server.js")),
(
"/assets/web-server-profiles.js",
JS,
include_str!("web/web-server-profiles.js"),
),
(
"/assets/web-server.js",
JS,
include_str!("web/web-server.js"),
),
("/assets/docker.js", JS, include_str!("web/docker.js")),
("/assets/streams.js", JS, include_str!("web/streams.js")),
("/assets/redirects.js", JS, include_str!("web/redirects.js")),
@@ -42,7 +82,18 @@ static ASSETS: LazyLock<HashMap<&'static str, Asset>> = LazyLock::new(|| {
("/assets/style.css", CSS, include_str!("web/style.css")),
("/favicon.svg", SVG, include_str!("web/favicon.svg")),
];
entries.into_iter().map(|(path, mime, raw)| (path, Asset { mime, raw, content: OnceLock::new() }))
entries
.into_iter()
.map(|(path, mime, raw)| {
(
path,
Asset {
mime,
raw,
content: OnceLock::new(),
},
)
})
.collect()
});
@@ -50,7 +101,13 @@ pub(super) async fn serve(State(state): State<Arc<App>>, request: Request) -> Re
let resolved = super::fingerprinted_path(request.uri().path(), "/assets/");
let path = match resolved.as_deref() {
Some(path) => path,
None if matches!(request.uri().path(), "/assets/login-langs.json" | "/assets/langs.json" | "/favicon.svg") => request.uri().path(),
None if matches!(
request.uri().path(),
"/assets/login-langs.json" | "/assets/langs.json" | "/favicon.svg"
) =>
{
request.uri().path()
}
None => return StatusCode::NOT_FOUND.into_response(),
};
let Some(asset) = ASSETS.get(path) else {
@@ -58,6 +115,8 @@ pub(super) async fn serve(State(state): State<Arc<App>>, request: Request) -> Re
};
let snapshot = state.config.load();
let codecs = &snapshot.envelope.config.administration.panel_compression;
asset.content.get_or_init(|| PanelContent::new(asset.raw))
asset
.content
.get_or_init(|| PanelContent::new(asset.raw))
.response(request.headers(), codecs, asset.mime)
}
+33 -11
View File
@@ -1,5 +1,3 @@
// Shared immutable precompressed responses for private panel HTML and assets.
// Each embedded document is encoded once on first request, never per visitor.
use crate::settings::PanelCompression;
use axum::{body::Body, response::Response};
use bytes::Bytes;
@@ -22,16 +20,31 @@ impl PanelContent {
let mut br = Vec::new();
{
let mut writer = brotli::CompressorWriter::new(&mut br, 4096, 5, 22);
writer.write_all(raw.as_bytes()).expect("compress panel Brotli");
writer
.write_all(raw.as_bytes())
.expect("compress panel Brotli");
}
let zstd = zstd::stream::encode_all(raw.as_bytes(), 3).expect("compress panel zstd");
Self { plain: Bytes::from_static(raw.as_bytes()), gzip, brotli: Bytes::from(br), zstd: Bytes::from(zstd) }
Self {
plain: Bytes::from_static(raw.as_bytes()),
gzip,
brotli: Bytes::from(br),
zstd: Bytes::from(zstd),
}
}
pub(super) fn response(&self, headers: &HeaderMap, codecs: &PanelCompression, mime: &'static str) -> Response {
pub(super) fn response(
&self,
headers: &HeaderMap,
codecs: &PanelCompression,
mime: &'static str,
) -> Response {
let settings = crate::compression::Settings {
gzip: codecs.gzip, br: codecs.br, zstd: codecs.zstd,
deflate: false, min_bytes: 1024,
gzip: codecs.gzip,
br: codecs.br,
zstd: codecs.zstd,
deflate: false,
min_bytes: 1024,
};
let encoding = if crate::compression::no_transform(headers) {
None
@@ -45,12 +58,21 @@ impl PanelContent {
_ => (self.plain.clone(), None),
};
let mut response = Response::new(Body::from(body.clone()));
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(mime));
response.headers_mut().insert(header::CONTENT_LENGTH, HeaderValue::from_str(&body.len().to_string()).expect("valid length"));
response
.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(mime));
response.headers_mut().insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&body.len().to_string()).expect("valid length"),
);
if let Some(name) = name {
response.headers_mut().insert(header::CONTENT_ENCODING, HeaderValue::from_static(name));
response
.headers_mut()
.insert(header::CONTENT_ENCODING, HeaderValue::from_static(name));
}
response.headers_mut().insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
response
.headers_mut()
.insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
response
}
}
-7
View File
@@ -27,9 +27,6 @@ fn default_admin_table_density() -> String {
fn default_admin_dashboard_density() -> String {
DASHBOARD_DEFAULT_PRESET.dashboard_density.clone()
}
// Preserve the user's saved order/visibility, drop identifiers unknown to this
// binary, and append new registry widgets according to their opt-in policy.
// Stored preferences from older/newer releases are normalized on read.
fn reconcile_dashboard_widgets(prefs: &mut AdminPreferences) {
let mut seen = std::collections::HashSet::new();
prefs.dashboard_widgets.retain(|id| {
@@ -131,8 +128,6 @@ struct AdminPreferences {
dashboard_widgets: Vec<String>,
#[serde(default = "default_hidden_dashboard_widgets")]
dashboard_hidden_widgets: Vec<String>,
// Null/absent: retain legacy visual grouping for existing installations.
// A map (including {}) means that order is fully user-controlled.
#[serde(default)]
dashboard_widget_sizes: Option<std::collections::BTreeMap<String, u8>>,
#[serde(default = "default_file_log_columns")]
@@ -473,8 +468,6 @@ async fn update_preferences(
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
) -> Result<Json<AdminPreferences>, ApiError> {
let Json(mut preferences) = input.map_err(public::invalid_json)?;
// Do not rewrite the caller's saved order on PUT; drop only stale IDs.
// Legacy partial lists are expanded on the next GET, as before.
preferences
.dashboard_widgets
.retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id));
-7
View File
@@ -75,8 +75,6 @@ const STREAM_LIST_SORT_COLUMNS: &[&str] = &[
"maxConnections",
"idleTimeout",
];
// The same JSON registry is embedded into the authenticated admin JS bundle.
// No independent allowlist is maintained by the browser or backend.
#[derive(serde::Deserialize)]
struct DashboardWidgetDefinition {
id: String,
@@ -89,9 +87,6 @@ static DASHBOARD_WIDGETS: std::sync::LazyLock<Vec<DashboardWidgetDefinition>> =
.expect("invalid bundled Overview Cards registry")
});
// A single operator-curated factory preset feeds both the Rust API defaults
// and the authenticated dashboard. New registry widgets remain opt-in unless
// explicitly added to the preset; saved user preferences are never rewritten.
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct DashboardDefaultPreset {
@@ -147,8 +142,6 @@ static DASHBOARD_DEFAULT_PRESET: std::sync::LazyLock<DashboardDefaultPreset> =
preset
});
// None denotes the legacy grouped layout. Some({}) switches to freely
// ordered 12-column CSS Grid while retaining every historical card width.
fn valid_dashboard_span(display_type: &str, span: u8) -> bool {
match display_type {
"table" => matches!(span, 6 | 12),
-2
View File
@@ -123,7 +123,6 @@ fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids() {
.dashboard_hidden_widgets
.contains(&"extra_process_cpu".into()));
assert!(prefs.dashboard_widgets.contains(&"backend_table".into()));
// 0.33.0 WWW cards stay opt-in when upgrading an old saved custom layout.
for id in [
"www_status",
"www_kind",
@@ -186,7 +185,6 @@ fn saved_dashboard_order_and_visibility_survive_new_factory_preset() {
assert!(prefs
.dashboard_hidden_widgets
.contains(&"backend_traffic".to_owned()));
// WWW was absent from the saved document and is introduced only as opt-in.
assert!(prefs
.dashboard_hidden_widgets
.contains(&"www_status".to_owned()));
@@ -1,4 +1,3 @@
//! Deliberately small anonymous surface. Operational templates/assets are private.
use crate::{
auth::Session,
runtime::{ApiError, App},
@@ -18,22 +17,19 @@ static ADMIN_INDEX: OnceLock<PanelContent> = OnceLock::new();
fn panel_response(headers: &HeaderMap, compression: &PanelCompression) -> Response {
let content = ADMIN_INDEX.get_or_init(|| {
// Compile-time embedded HTML fragments are assembled once at startup.
// No additional browser requests, changed DOM nodes or script ordering.
let (metrics, web_metrics) = include_str!("web/partials/overview-metrics.html")
let (metrics, web_metrics) = include_str!("../web/partials/overview-metrics.html")
.split_once("<!-- RP_METRICS_SPLIT -->")
.expect("invalid Overview metrics partial");
let (charts, web_charts) = include_str!("web/partials/overview-charts.html")
let (charts, web_charts) = include_str!("../web/partials/overview-charts.html")
.split_once("<!-- RP_CHARTS_SPLIT -->")
.expect("invalid Overview charts partial");
let html = include_str!("web/index.html")
let html = include_str!("../web/index.html")
.replace("<!-- RP_METRICS_MAIN -->", metrics)
.replace("<!-- RP_CHARTS_MAIN -->", charts)
.replace("<!-- RP_METRICS_WEB -->", web_metrics)
.replace("<!-- RP_CHARTS_WEB -->", web_charts)
.replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id())
.replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION"));
// Exactly one static panel document shared between all administrator accounts.
PanelContent::new(Box::leak(html.into_boxed_str()))
});
content.response(headers, compression, "text/html; charset=utf-8")
@@ -45,7 +41,7 @@ pub(super) async fn index(
headers: HeaderMap,
) -> Response {
if session.is_none() {
let html = include_str!("web/login.html")
let html = include_str!("../web/login.html")
.replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id());
return Html(html).into_response();
}
@@ -57,7 +53,6 @@ pub(super) async fn index(
}
pub(super) fn invalid_json(error: JsonRejection) -> ApiError {
// Keep useful protocol statuses without returning serde's input excerpts.
let status = match error.status() {
StatusCode::PAYLOAD_TOO_LARGE => StatusCode::PAYLOAD_TOO_LARGE,
StatusCode::UNSUPPORTED_MEDIA_TYPE => StatusCode::UNSUPPORTED_MEDIA_TYPE,
+4 -3
View File
@@ -1,4 +1,3 @@
// Stored inside the existing per-account administration preferences document.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
struct RefreshPreferences {
@@ -36,6 +35,8 @@ fn validate_refresh_preferences(refresh: &RefreshPreferences) -> Result<(), ApiE
}
fn default_live_log_columns() -> Vec<String> {
LIVE_LOG_COLUMNS.iter().map(|column| (*column).to_owned()).collect()
LIVE_LOG_COLUMNS
.iter()
.map(|column| (*column).to_owned())
.collect()
}
-10
View File
@@ -3,8 +3,6 @@ mod panel_assets;
mod panel_content;
mod public;
// Embedded asset URLs change with every build, independent of Cargo package version.
// Only an exact identifier from this binary is accepted; arbitrary suffixes are 404.
fn asset_build_id() -> &'static str {
include_str!(concat!(env!("OUT_DIR"), "/asset-build-id.txt"))
}
@@ -57,8 +55,6 @@ fn admin_app_javascript() -> &'static str {
"const ADMIN_DASHBOARD_DEFAULTS = Object.freeze(",
include_str!("web/dashboard-defaults.json"),
");",
// The samples controller is needed by top-level listeners in core.js.
// Define it first; DOM initialization and translations happen later.
include_str!("web/app/overview-samples.js"),
include_str!("web/app/core.js"),
include_str!("web/app/session-resume.js"),
@@ -286,8 +282,6 @@ pub fn router(state: Arc<App>) -> Router {
.layer(middleware::from_fn_with_state(state.clone(), observe_admin))
.with_state(state)
}
// Separate from proxy exchanges: measure only time to admin response headers,
// including access denials. SSE stream lifetime is intentionally not included.
async fn observe_admin(State(state): State<Arc<App>>, request: Request, next: Next) -> Response {
use std::sync::atomic::Ordering;
state.metrics.admin_requests.fetch_add(1, Ordering::Relaxed);
@@ -325,14 +319,10 @@ fn secure(mut response: Response) -> Response {
"permissions-policy",
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
);
// Do not let downstream middleware add implementation/version fingerprints.
h.remove("server");
h.remove("x-powered-by");
response
}
// This is a method-and-path allowlist, NOT a path prefix. Everything else,
// including unknown routes, schema, metrics, assets and method mismatches,
// requires an active administrator session before extractors read a body.
fn panel_document_path(path: &str) -> bool {
matches!(
path,
+111 -66
View File
@@ -1,9 +1,5 @@
// Only log browser origin metadata: never cookies, CSRF tokens, Authorization,
// query strings, or untrusted forwarding data. Malformed origins are redacted.
fn diagnostic_admin_authority(value: &str) -> &str {
if value.len() <= 255 && !value.contains('@')
&& value.parse::<http::uri::Authority>().is_ok()
{
if value.len() <= 255 && !value.contains('@') && value.parse::<http::uri::Authority>().is_ok() {
value
} else {
"(invalid)"
@@ -47,7 +43,11 @@ fn diagnostic_forwarded_header<'a>(headers: &'a HeaderMap, name: &str, trusted:
return "(invalid)";
};
if name == "x-forwarded-proto" {
if matches!(value, "http" | "https") { value } else { "(invalid)" }
if matches!(value, "http" | "https") {
value
} else {
"(invalid)"
}
} else {
diagnostic_admin_authority(value)
}
@@ -63,14 +63,19 @@ fn invalid_admin_origin(
) -> Response {
let headers = request.headers();
let path = request.uri().path();
let path = if path.len() <= 160 { path } else { "(long_path)" };
let path = if path.len() <= 160 {
path
} else {
"(long_path)"
};
let browser_host = single_header(headers, HOST.as_str())
.map(diagnostic_admin_authority)
.unwrap_or("(absent_or_duplicate)");
let browser_fetch_site = match single_header(headers, "sec-fetch-site") {
None => "(absent)",
Some("same-origin" | "same-site" | "cross-site" | "none") =>
single_header(headers, "sec-fetch-site").unwrap_or("(invalid)"),
Some("same-origin" | "same-site" | "cross-site" | "none") => {
single_header(headers, "sec-fetch-site").unwrap_or("(invalid)")
}
_ => "(invalid)",
};
tracing::warn!(
@@ -88,12 +93,15 @@ fn invalid_admin_origin(
forwarded_proto = diagnostic_forwarded_header(headers, "x-forwarded-proto", trusted_proto),
"administration origin rejected"
);
// When a browser navigates to the dashboard document, JSON here would
// replace the entire page with a raw API error. Keep the 403 (no shell or
// protected data) but present a standalone recovery message instead.
let wants_html = request.headers().get(http::header::ACCEPT)
let wants_html = request
.headers()
.get(http::header::ACCEPT)
.and_then(|header| header.to_str().ok())
.is_some_and(|value| value.split(',').any(|entry| entry.trim_start().starts_with("text/html")));
.is_some_and(|value| {
value
.split(',')
.any(|entry| entry.trim_start().starts_with("text/html"))
});
if request.method() == Method::GET && panel_document_path(request.uri().path()) && wants_html {
return secure((
StatusCode::FORBIDDEN,
@@ -117,10 +125,8 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
let headers = request.headers();
let trusted_host = trusted_admin_proxy(&state, peer);
let trusted_proto = trusted_admin_forwarded_proto(&state, peer);
// 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);
let automation_credentials =
headers.contains_key(CERTIFICATE_API_KEY_HEADER) || headers.contains_key(AUTHORIZATION);
let pinned_authority_result = if automation_credentials {
Ok(None)
} else {
@@ -128,25 +134,42 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
};
let pinned_authority = match pinned_authority_result {
Ok(value) => value,
Err(()) => return invalid_admin_origin(&request, "pinned_origin_mismatch", Some("https"),
state.active_administration.public_origin.as_deref().and_then(|value| value.strip_prefix("https://")),
trusted_host, trusted_proto),
Err(()) => {
return invalid_admin_origin(
&request,
"pinned_origin_mismatch",
Some("https"),
state
.active_administration
.public_origin
.as_deref()
.and_then(|value| value.strip_prefix("https://")),
trusted_host,
trusted_proto,
)
}
};
let (scheme, external_authority) = if let Some(authority) = pinned_authority {
// HTTPS is a trusted, administrator-pinned identity here. Do not select
// any list element of an ambiguous forwarded-proto chain.
("https", authority)
} else {
let scheme = match external_admin_scheme(&state, headers, peer, transport) {
Ok(value) => value,
Err(()) => {
return invalid_admin_origin(&request, "external_scheme", None, None, trusted_host, trusted_proto);
return invalid_admin_origin(
&request,
"external_scheme",
None,
None,
trusted_host,
trusted_proto,
);
}
};
let authority = match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
Ok(value) => value,
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
};
let authority =
match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
Ok(value) => value,
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
};
(scheme, authority)
};
if headers.get_all(ORIGIN).iter().count() > 1
@@ -156,13 +179,27 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
.is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority))
})
{
return invalid_admin_origin(&request, "origin_mismatch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto);
return invalid_admin_origin(
&request,
"origin_mismatch",
Some(scheme),
Some(&external_authority),
trusted_host,
trusted_proto,
);
}
if headers
.get("sec-fetch-site")
.is_some_and(|h| h == "cross-site")
{
return invalid_admin_origin(&request, "cross_site_fetch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto);
return invalid_admin_origin(
&request,
"cross_site_fetch",
Some(scheme),
Some(&external_authority),
trusted_host,
trusted_proto,
);
}
let path = request.uri().path().to_owned();
let public = anonymous_allowed(request.method(), &path);
@@ -174,7 +211,10 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
let mut certificate_api_access = None;
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"));
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.invalid_route",
));
};
match certificate_api_credentials(headers) {
Ok(None) => None,
@@ -243,35 +283,47 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
} else {
None
};
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 general_api_session =
if certificate_api_session.is_none() && headers.contains_key(AUTHORIZATION) {
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 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
@@ -280,9 +332,6 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
.config
.administration
.session_seconds();
// Panel document routes optionally authenticate so a direct refresh can choose
// the application shell when the browser still has a valid cookie. Public static
// 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() {
@@ -352,16 +401,12 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
}
}
if matches!(path.as_str(), "/docs" | "/docs/") && response.status().is_success() {
// Swagger UI sets inline layout styles. This exception is limited to the
// authenticated documentation document; scripts remain self-only/no-eval.
response.headers_mut().insert("content-security-policy", HeaderValue::from_static(
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"));
}
response
}
async fn auth_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
// The one bit needed to render first-account setup; no version, paths, names,
// listeners, provider state, metrics or configuration before authentication.
Ok(Json(
json!({"setup_required":state.store.admin_record().await.map_err(auth::internal)?.is_none()}),
))
-7
View File
@@ -228,8 +228,6 @@ fn status_payload(state: &App) -> Value {
});
payload
}
// SSE and emergency polling use only volatile telemetry. Never serialize the
// whole configuration, OS diagnostics or certificate metadata on each tick.
include!("status_live.rs");
async fn backend_status(State(state): State<Arc<App>>) -> Json<Value> {
let snapshot = state.config.load_full();
@@ -275,9 +273,6 @@ async fn certificate_inventory(State(state): State<Arc<App>>) -> Json<Value> {
let config = &snapshot.envelope.config;
let configured_certificates = config.tls.certificates.clone();
let certificate_directory = state.policy.cert_dir.clone();
// Inspect only public certificate files on a blocking worker; never read keys.
// Live PEM coverage wins so in-place renewals are reflected immediately; stored
// metadata is only a fallback when the public certificate file cannot be read.
let inspected_metadata = tokio::task::spawn_blocking(move || {
configured_certificates
.into_iter()
@@ -313,8 +308,6 @@ async fn certificate_inventory(State(state): State<Arc<App>>) -> Json<Value> {
json!({"revision":snapshot.envelope.revision,"certificates":entries,"acme":true, "providers":["http01", "cloudflare", "ovh", "rfc2136"]}),
)
}
// Storage metadata belongs to GET /api/config, not to the editable `config`
// document or the revisioned envelopes returned by update/restore endpoints.
#[derive(Serialize)]
struct ConfigurationRead {
#[serde(flatten)]
-5
View File
@@ -1,6 +1,3 @@
// The OS collector already samples in the background. Expose just the
// chart inputs in compact SSE/fallback snapshots; the full, slower System
// information document remains behind /api/system-info.
fn live_chart_system_payload(state: &App) -> Value {
let view = state.diagnostics.view();
json!({
@@ -14,8 +11,6 @@ fn live_chart_system_payload(state: &App) -> Value {
})
}
// Snapshot already holds bounded, configured Web Service IDs. Do not expose
// paths, FastCGI addresses or PHP INI parameters in a status poll.
fn web_server_metrics_payload(snapshot: &crate::config::Snapshot) -> Value {
let mut services = serde_json::Map::new();
for service in &snapshot.envelope.config.web_services {
+16 -17
View File
@@ -1,6 +1,3 @@
// Reuse sysinfo's native OS APIs on Linux, BSD, macOS and Windows. The
// diagnostics endpoint must never spawn system commands or parse platform files.
// Host identification is cached because it changes only after a reboot.
fn valid_host_text(value: String) -> Option<String> {
let value = value.trim();
(!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control))
@@ -9,30 +6,32 @@ fn valid_host_text(value: String) -> Option<String> {
fn os_release() -> Option<&'static str> {
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
VALUE.get_or_init(|| {
sysinfo::System::long_os_version()
.or_else(|| {
let name = sysinfo::System::name()?;
Some(match sysinfo::System::os_version() {
Some(version) if !version.is_empty() => format!("{name} {version}"),
_ => name,
VALUE
.get_or_init(|| {
sysinfo::System::long_os_version()
.or_else(|| {
let name = sysinfo::System::name()?;
Some(match sysinfo::System::os_version() {
Some(version) if !version.is_empty() => format!("{name} {version}"),
_ => name,
})
})
})
.and_then(valid_host_text)
}).as_deref()
.and_then(valid_host_text)
})
.as_deref()
}
fn kernel_release() -> Option<&'static str> {
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
VALUE.get_or_init(|| sysinfo::System::kernel_version().and_then(valid_host_text))
VALUE
.get_or_init(|| sysinfo::System::kernel_version().and_then(valid_host_text))
.as_deref()
}
// Cached diagnostics and ACME metadata are requested separately from high-rate SSE.
// Preserve the legacy full /api/status shape for existing API clients.
fn system_info_payload(state: &App) -> Value {
let cache_view = state.cache.view();
let transform_resources = crate::transform::resource_usage(&state.config.load().envelope.config.transform);
let transform_resources =
crate::transform::resource_usage(&state.config.load().envelope.config.transform);
let mut payload = json!({});
let system = state.diagnostics.view();
let cache_ram_bytes =
+110 -63
View File
@@ -315,7 +315,6 @@ mod tests {
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
// Origin on an ordinary same-origin GET may be absent, or may match.
for origin in [None, Some("http://127.0.0.1:9090")] {
let mut request = Request::builder()
.uri("/api/auth/session")
@@ -902,7 +901,6 @@ mod tests {
.await
.unwrap();
if path == "/logs" {
// The operational log route is private even on browser refresh.
assert_eq!(public.status(), StatusCode::UNAUTHORIZED);
assert_eq!(json_body(public).await, json!({"error":"auth.required"}));
} else {
@@ -1243,7 +1241,6 @@ mod tests {
.body(Body::from("{}"))
.unwrap()
};
// Certificate API credentials cannot trigger an all-certificate reload.
let response = router
.clone()
.oneshot(external(
@@ -1254,8 +1251,6 @@ mod tests {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
// The allowlisted ID passes the auth gate and reaches the handler; this
// fixture intentionally has no such configured certificate, so it is 404.
let response = router
.clone()
.oneshot(external(
@@ -1266,7 +1261,6 @@ mod tests {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// A reload-only token cannot use the deploy endpoint at all.
let response = router
.clone()
.oneshot(
@@ -1281,7 +1275,6 @@ mod tests {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
// A different ID is rejected by the credential grant before the handler.
let response = router
.clone()
.oneshot(external(
@@ -1320,8 +1313,6 @@ mod tests {
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
// This fixture has no certificate directory. An out-of-scope credential
// must see only the grant error, never the storage availability error.
assert_eq!(
json_body(response).await,
json!({"error":"certificate_api.certificate_forbidden"})
@@ -1482,7 +1473,6 @@ mod tests {
assert!(detail["credential"].get("token_hash").is_none());
let created_at = detail["credential"]["created_at"].clone();
// A bearer-only automation client cannot read or modify management records.
let denied = router
.clone()
.oneshot(
@@ -1526,7 +1516,6 @@ mod tests {
.unwrap();
assert_eq!(wildcard.status(), StatusCode::UNPROCESSABLE_ENTITY);
// An edit changes only metadata and grants, not the current bearer secret.
let edited = router.clone().oneshot(admin(
Method::PATCH, "/api/certificate-api/certbot-prod",
json!({"comment":"updated owner", "certificate_ids":["new-cert", "new-cert"], "allow_deploy":true}),
@@ -1895,8 +1884,6 @@ mod tests {
"backend_table_sort":"requests",
"backend_table_descending":true
});
// Older clients may omit fields added since their preferences were
// saved. The API returns and stores the typed, defaulted document.
let normalized = serde_json::to_value(
serde_json::from_value::<AdminPreferences>(selected.clone()).unwrap(),
)
@@ -1943,7 +1930,6 @@ mod tests {
serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap())
.unwrap();
assert_eq!(saved, normalized);
// Preferences must also survive a subsequent authenticated read.
let persisted = router
.clone()
.oneshot(
@@ -1976,7 +1962,6 @@ mod tests {
}
}
assert_eq!(json_body(persisted).await, upgraded);
// Wrong allowlist value is rejected even if the rest is valid.
let mut invalid = selected.clone();
invalid["refresh"]["traffic_seconds"] = json!(4);
let rejected = router
@@ -2120,8 +2105,6 @@ mod tests {
if valid_csrf {
req = req.header("x-csrf-token", &csrf);
}
// This fixture has no certificate directory: valid auth reaches the
// 409 precondition, never a child process, DNS update or CA request.
let response = router
.clone()
.oneshot(req.body(Body::from("{}")).unwrap())
@@ -2898,7 +2881,6 @@ mod tests {
.collect::<Vec<_>>(),
vec![Some(200), Some(404), Some(500), None]
);
// Page two must reflect the sorted complete scan, not a local page sort.
assert_eq!(records[1]["status"], 404);
sort_file_log_records(&mut records, "status", true);
assert_eq!(
@@ -3099,8 +3081,6 @@ mod tests {
assert_eq!(live.status(), StatusCode::OK);
let live = json_body(live).await;
assert!(live.get("metrics").is_some());
// Compact snapshots contain only already-collected data required for
// fast Overview charts, not the expensive System details document.
assert!(live["live_system"].get("sampled_at_ms").is_some());
assert!(live["live_system"].get("process_cpu_percent").is_some());
assert!(live["live_system"].get("process_rss_bytes").is_some());
@@ -3170,7 +3150,7 @@ mod tests {
}
value.overview_seconds = 3;
assert!(validate_refresh_preferences(&value).is_ok());
value.overview_seconds = 2; // previous installations stay valid
value.overview_seconds = 2;
assert!(validate_refresh_preferences(&value).is_ok());
value.overview_seconds = 1;
for seconds in [1, 2, 4, 31] {
@@ -3256,8 +3236,6 @@ mod tests {
"CSRF {method} {path}"
);
}
// The valid session and CSRF continue to work for a benign mutation,
// proving the failed requests above did not revoke the cookie.
let response = router
.oneshot(
request("/api/auth/logout")
@@ -3341,8 +3319,6 @@ mod tests {
config.administration.public_origin = Some("https://panel.example.test".into());
let (router, store, _dir, secret, csrf) = fixture_with_config(config).await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
// Without an explicit origin pin, 0.31.3 rejected this session GET as
// external_scheme although the browser address remained correct.
let result = router
.clone()
.oneshot(
@@ -3367,8 +3343,6 @@ mod tests {
.unwrap();
assert!(session_cookie.contains("; Secure"));
// The incoming admin Host may itself be the pinned public DNS name.
// There is no separate allowed_hosts entry in the saved configuration.
let pinned_host = Request::builder()
.uri("/api/auth/session")
.header("host", "panel.example.test")
@@ -3383,8 +3357,6 @@ mod tests {
StatusCode::OK
);
// Explicit Origin, CSRF token and app request marker remain mandatory
// for a state-changing request even when XFP contains multiple hops.
let unauthorized = router
.clone()
.oneshot(
@@ -3484,8 +3456,6 @@ mod tests {
assert_eq!(result.status(), StatusCode::FORBIDDEN,
"must reject forwarded-host={xfh}, proto={xfp}, origin={origin}, fetch={fetch_site}");
}
// Credential-bearing certificate automation does not inherit the
// browser-origin override, even through the same trusted admin socket.
let automation = router
.clone()
.oneshot(
@@ -3582,10 +3552,8 @@ mod tests {
assert!(payload["config"].get("storage").is_none());
assert_eq!(payload["storage"]["mode"], "sqlite");
assert!(payload["storage"]["config_path"].is_null());
assert!(payload["storage"]["database_path"].is_null()); // fixture omits data_dir
assert!(payload["storage"]["database_path"].is_null());
// The old GET endpoint is gone. A dynamic PUT-only section route may
// match the path, but it must not respond to GET.
let old_route = router
.oneshot(
request("/api/config/storage")
@@ -3669,15 +3637,38 @@ mod tests {
async fn json_metrics_api_is_authenticated_and_matches_prometheus_families() {
let (router, store, _dir, secret, _) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
let snapshot = router.clone().oneshot(
request("/api/metrics/json").header("cookie", &cookie)
.body(Body::empty()).unwrap()
).await.unwrap();
let snapshot = router
.clone()
.oneshot(
request("/api/metrics/json")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(snapshot.status(), StatusCode::OK);
assert!(snapshot.headers()["content-type"].to_str().unwrap().starts_with("application/json"));
assert!(snapshot.headers()["content-type"]
.to_str()
.unwrap()
.starts_with("application/json"));
let snapshot = json_body(snapshot).await;
for field in ["summary", "vhosts", "requests", "system", "cache", "backends", "web_server",
"streams", "docker", "traffic", "transform", "logging", "capacity", "prometheus"] {
for field in [
"summary",
"vhosts",
"requests",
"system",
"cache",
"backends",
"web_server",
"streams",
"docker",
"traffic",
"transform",
"logging",
"capacity",
"prometheus",
] {
assert!(snapshot.get(field).is_some(), "missing: {field}");
}
assert_eq!(snapshot["vhosts"]["total"], 0);
@@ -3687,21 +3678,52 @@ mod tests {
assert_eq!(snapshot["summary"]["backends"]["total"], 0);
assert!(snapshot["summary"]["traffic"]["bytes_total"].is_number());
assert!(snapshot["prometheus"]["samples"]["rust_proxy_requests_total"].is_array());
assert_eq!(snapshot["prometheus"]["types"]["rust_proxy_requests_total"], "counter");
assert!(general_api_token_allowed(&Method::GET, "/api/metrics/json", "ro"));
assert!(general_api_token_allowed(&Method::GET, "/api/metrics/summary", "ro"));
assert!(!general_api_token_allowed(&Method::POST, "/api/metrics/json", "rw"));
assert!(!general_api_token_allowed(&Method::POST, "/api/metrics/summary", "rw"));
let summary = router.clone().oneshot(
request("/api/metrics/summary").header("cookie", &cookie)
.body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(
snapshot["prometheus"]["types"]["rust_proxy_requests_total"],
"counter"
);
assert!(general_api_token_allowed(
&Method::GET,
"/api/metrics/json",
"ro"
));
assert!(general_api_token_allowed(
&Method::GET,
"/api/metrics/summary",
"ro"
));
assert!(!general_api_token_allowed(
&Method::POST,
"/api/metrics/json",
"rw"
));
assert!(!general_api_token_allowed(
&Method::POST,
"/api/metrics/summary",
"rw"
));
let summary = router
.clone()
.oneshot(
request("/api/metrics/summary")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(summary.status(), StatusCode::OK);
assert_eq!(summary.headers()["cache-control"], "no-store");
let summary = json_body(summary).await;
assert!(summary.get("prometheus").is_none());
assert_eq!(summary["vhosts"]["enabled"], snapshot["summary"]["vhosts"]["enabled"]);
assert_eq!(summary["backends"]["total"], snapshot["summary"]["backends"]["total"]);
assert_eq!(
summary["vhosts"]["enabled"],
snapshot["summary"]["vhosts"]["enabled"]
);
assert_eq!(
summary["backends"]["total"],
snapshot["summary"]["backends"]["total"]
);
store.close().await;
}
@@ -3710,12 +3732,20 @@ mod tests {
let (router, store, _dir, _, _) = fixture().await;
let token = "T".repeat(64);
let cidrs = vec!["127.0.0.1/32".to_string()];
assert!(store.create_api_token(
"api-monitor", &auth::secret_hash(&token), "ro", "monitor test", &cidrs, "owner"
).await.unwrap());
assert!(store
.create_api_token(
"api-monitor",
&auth::secret_hash(&token),
"ro",
"monitor test",
&cidrs,
"owner"
)
.await
.unwrap());
let build = |peer: Option<&str>| {
let mut request = request("/api/metrics/json")
.header("authorization", format!("Bearer {token}"));
let mut request =
request("/api/metrics/json").header("authorization", format!("Bearer {token}"));
if let Some(peer) = peer {
request = request.extension(PeerAddr(peer.parse().unwrap()));
}
@@ -3723,19 +3753,36 @@ mod tests {
};
let response = router.clone().oneshot(build(None)).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let response = router.clone().oneshot(build(Some("127.0.0.1:12345"))).await.unwrap();
let response = router
.clone()
.oneshot(build(Some("127.0.0.1:12345")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = json_body(response).await;
assert!(response["prometheus"]["samples"].is_object());
assert!(response.get("config").is_none());
let summarize = |peer: Option<&str>| {
let mut req = request("/api/metrics/summary")
.header("authorization", format!("Bearer {token}"));
if let Some(peer) = peer { req = req.extension(PeerAddr(peer.parse().unwrap())); }
let mut req =
request("/api/metrics/summary").header("authorization", format!("Bearer {token}"));
if let Some(peer) = peer {
req = req.extension(PeerAddr(peer.parse().unwrap()));
}
req.body(Body::empty()).unwrap()
};
assert_eq!(router.clone().oneshot(summarize(None)).await.unwrap().status(), StatusCode::FORBIDDEN);
let summary = router.oneshot(summarize(Some("127.0.0.1:12345"))).await.unwrap();
assert_eq!(
router
.clone()
.oneshot(summarize(None))
.await
.unwrap()
.status(),
StatusCode::FORBIDDEN
);
let summary = router
.oneshot(summarize(Some("127.0.0.1:12345")))
.await
.unwrap();
assert_eq!(summary.status(), StatusCode::OK);
let summary = json_body(summary).await;
assert!(summary["traffic"]["requests_total"].is_number());