0.34.6_repo_cleanup
This commit is contained in:
1 parent
ce1a90397c
commit
133d2f0f91
273 files changed
+5969
-6926
No files matched your search
+7
-34
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()))
|
||||
}
|
||||
@@ -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(""))
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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(§ion, 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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()}),
|
||||
))
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user