v0.24.13
This commit is contained in:
1 parent
bd077754a9
commit
ed7e3377c1
40 files changed
+1764
-365
No files matched your search
@@ -21,17 +21,14 @@ fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> b
|
||||
| "/api/metrics/json"
|
||||
| "/api/metrics/summary"
|
||||
| "/api/cache/bans"
|
||||
);
|
||||
) || (method == Method::GET
|
||||
&& path.strip_prefix("/api/config/")
|
||||
.is_some_and(|section| ConfigSection::from_name(section).is_some()));
|
||||
let write = permission == "rw"
|
||||
&& ((method == Method::PUT
|
||||
&& (path == "/api/config"
|
||||
|| path.strip_prefix("/api/config/").is_some_and(|part| {
|
||||
!part.is_empty()
|
||||
&& part != "history"
|
||||
&& part != "reload"
|
||||
&& part != "storage"
|
||||
&& !part.contains('/')
|
||||
})))
|
||||
|| path.strip_prefix("/api/config/")
|
||||
.is_some_and(|section| ConfigSection::from_name(section).is_some())))
|
||||
|| (method == Method::POST
|
||||
&& matches!(
|
||||
path,
|
||||
@@ -39,7 +36,7 @@ fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> b
|
||||
| "/api/config/history/restore"
|
||||
| "/api/cache/purge"
|
||||
| "/api/cache/invalidate"
|
||||
| "/api/geoip/update"
|
||||
| "/api/config/geoip/update"
|
||||
| "/api/docker/refresh"
|
||||
| "/api/certificates/reload"
|
||||
))
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// GET and PUT resolve exactly the same named sections. All writes go through
|
||||
// App::update_as: full validation, revision check under the update lock, and
|
||||
// one durable transaction. These handlers do not implement partial patches.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ConfigSection {
|
||||
Listeners,
|
||||
DockerAgents,
|
||||
Http2,
|
||||
Pages,
|
||||
Cache,
|
||||
Transform,
|
||||
Forwarding,
|
||||
Headers,
|
||||
Prometheus,
|
||||
Geoip,
|
||||
Administration,
|
||||
Tls,
|
||||
Vhosts,
|
||||
WebServices,
|
||||
PhpFpmProfiles,
|
||||
Redirects,
|
||||
NotFoundHosts,
|
||||
Streams,
|
||||
AclProfiles,
|
||||
HostPolicies,
|
||||
Response,
|
||||
Logging,
|
||||
GlobalSecurity,
|
||||
}
|
||||
|
||||
impl ConfigSection {
|
||||
fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"listeners" => Some(Self::Listeners),
|
||||
"docker-agents" => Some(Self::DockerAgents),
|
||||
"http2" => Some(Self::Http2),
|
||||
"pages" => Some(Self::Pages),
|
||||
"cache" => Some(Self::Cache),
|
||||
"transform" => Some(Self::Transform),
|
||||
"forwarding" => Some(Self::Forwarding),
|
||||
"headers" => Some(Self::Headers),
|
||||
"prometheus" => Some(Self::Prometheus),
|
||||
"geoip" => Some(Self::Geoip),
|
||||
"administration" => Some(Self::Administration),
|
||||
"tls" => Some(Self::Tls),
|
||||
"vhosts" => Some(Self::Vhosts),
|
||||
"web-services" => Some(Self::WebServices),
|
||||
"php-fpm-profiles" => Some(Self::PhpFpmProfiles),
|
||||
"redirects" => Some(Self::Redirects),
|
||||
"not-found-hosts" => Some(Self::NotFoundHosts),
|
||||
"streams" => Some(Self::Streams),
|
||||
"acl-profiles" => Some(Self::AclProfiles),
|
||||
"host-policies" => Some(Self::HostPolicies),
|
||||
"response" => Some(Self::Response),
|
||||
"logging" => Some(Self::Logging),
|
||||
"global-security" => Some(Self::GlobalSecurity),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn read(self, config: &crate::config::Config) -> Value {
|
||||
match self {
|
||||
Self::Listeners => json!(&config.listeners),
|
||||
Self::DockerAgents => json!(&config.docker_agents),
|
||||
Self::Http2 => json!(&config.http2),
|
||||
Self::Pages => json!(&config.pages),
|
||||
Self::Cache => json!(&config.cache),
|
||||
Self::Transform => json!(&config.transform),
|
||||
Self::Forwarding => json!(&config.forwarding),
|
||||
Self::Headers => json!(&config.headers),
|
||||
Self::Prometheus => json!(&config.prometheus),
|
||||
Self::Geoip => json!(&config.geoip_updates),
|
||||
Self::Administration => json!(&config.administration),
|
||||
Self::Tls => json!(&config.tls),
|
||||
Self::Vhosts => json!(&config.vhosts),
|
||||
Self::WebServices => json!(&config.web_services),
|
||||
Self::PhpFpmProfiles => json!(&config.php_fpm_profiles),
|
||||
Self::Redirects => json!(&config.redirects),
|
||||
Self::NotFoundHosts => json!(&config.not_found_hosts),
|
||||
Self::Streams => json!(&config.streams),
|
||||
Self::AclProfiles => json!(&config.acl_profiles),
|
||||
Self::HostPolicies => json!(&config.host_policies),
|
||||
Self::Response => json!({
|
||||
"server_header": &config.server_header,
|
||||
"diagnostic_headers": &config.diagnostic_headers,
|
||||
"compression": &config.compression,
|
||||
}),
|
||||
Self::Logging => json!({
|
||||
"logging": &config.logging,
|
||||
"error_log": &config.error_log,
|
||||
}),
|
||||
Self::GlobalSecurity => json!(&config.global_security),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace(self, config: &mut crate::config::Config, name: &str, value: Value) -> Result<(), ApiError> {
|
||||
match self {
|
||||
Self::Listeners => config.listeners = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::DockerAgents => config.docker_agents = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Http2 => config.http2 = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Pages => config.pages = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Cache => config.cache = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Transform => config.transform = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Forwarding => config.forwarding = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Headers => config.headers = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Prometheus => config.prometheus = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Geoip => config.geoip_updates = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Administration => config.administration = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Tls => config.tls = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Vhosts => config.vhosts = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::WebServices => config.web_services = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::PhpFpmProfiles => config.php_fpm_profiles = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Redirects => config.redirects = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::NotFoundHosts => config.not_found_hosts = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Streams => config.streams = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::AclProfiles => config.acl_profiles = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::HostPolicies => config.host_policies = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Response => {
|
||||
let part: ResponseSection = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?;
|
||||
config.server_header = part.server_header;
|
||||
config.diagnostic_headers = part.diagnostic_headers;
|
||||
config.compression = part.compression;
|
||||
}
|
||||
Self::Logging => {
|
||||
// A logging save must include both sinks; there is no implicit fallback
|
||||
// to a single-field payload that could reset error_log accidentally.
|
||||
let part: LoggingSection = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?;
|
||||
config.logging = part.logging;
|
||||
config.error_log = part.error_log;
|
||||
}
|
||||
Self::GlobalSecurity => {
|
||||
let part: crate::config::GlobalSecurity = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?;
|
||||
if part.user_agent_blocking.is_none() {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"global-security requires user_agent_blocking in its complete section value".into(),
|
||||
));
|
||||
}
|
||||
config.global_security = part;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn requested_config_section(name: &str) -> Result<ConfigSection, ApiError> {
|
||||
ConfigSection::from_name(name).ok_or_else(|| ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown configuration section".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn configuration_section(
|
||||
State(state): State<Arc<App>>,
|
||||
Path(section): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let selected = requested_config_section(§ion)?;
|
||||
let snapshot = state.config.load();
|
||||
Ok(Json(json!({
|
||||
"revision": snapshot.envelope.revision,
|
||||
"section": section,
|
||||
"value": selected.read(&snapshot.envelope.config),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn update_section(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(section): Path<String>,
|
||||
Json(input): Json<SectionUpdate>,
|
||||
) -> Result<Json<crate::config::Envelope>, ApiError> {
|
||||
// Resolve the name before taking the snapshot, including for stale requests.
|
||||
let selected = requested_config_section(§ion)?;
|
||||
let current = state.config.load_full();
|
||||
if current.envelope.revision != input.expected_revision {
|
||||
return Err(ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
"configuration changed; reload before saving".into(),
|
||||
));
|
||||
}
|
||||
let mut config = current.envelope.config.clone();
|
||||
selected.replace(&mut config, §ion, input.value)?;
|
||||
state.update_as(Update { expected_revision: input.expected_revision, config }, session.username)
|
||||
.await.map(Json)
|
||||
}
|
||||
|
||||
// GeoIP is an explicit side-effecting action within the geoip settings section.
|
||||
// GET and PUT are only for desired settings; POST refreshes external data.
|
||||
async fn geoip_update(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Result<Json<crate::geoip::UpdateResult>, ApiError> {
|
||||
let result = crate::geoip::update_now(&state).await.map_err(|error| {
|
||||
tracing::warn!(error = %format!("{error:#}"), "manual GeoIP update failed");
|
||||
ApiError(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("GeoIP update failed: {error:#}"),
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.store
|
||||
.audit(
|
||||
&session.username,
|
||||
"geoip.update",
|
||||
&format!(
|
||||
"outcome={}; files={}",
|
||||
result.outcome,
|
||||
if result.updated_files.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
result.updated_files.join(",")
|
||||
}
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
@@ -1,138 +1,3 @@
|
||||
async fn update_section(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(section): Path<String>,
|
||||
Json(input): Json<SectionUpdate>,
|
||||
) -> Result<Json<crate::config::Envelope>, ApiError> {
|
||||
let current = state.config.load_full();
|
||||
if current.envelope.revision != input.expected_revision {
|
||||
return Err(ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
"configuration changed; reload before saving".into(),
|
||||
));
|
||||
}
|
||||
let mut config = current.envelope.config.clone();
|
||||
let value = input.value;
|
||||
match section.as_str() {
|
||||
"listeners" => {
|
||||
config.listeners =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"docker-agents" => {
|
||||
config.docker_agents =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"http2" => {
|
||||
config.http2 =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"response" => {
|
||||
let part: ResponseSection =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?;
|
||||
config.server_header = part.server_header;
|
||||
config.diagnostic_headers = part.diagnostic_headers;
|
||||
config.compression = part.compression;
|
||||
}
|
||||
"pages" => {
|
||||
config.pages =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"cache" => {
|
||||
config.cache =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"transform" => {
|
||||
config.transform =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"logging" => {
|
||||
if value.get("logging").is_some() || value.get("error_log").is_some() {
|
||||
let part: LoggingSection =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?;
|
||||
config.logging = part.logging;
|
||||
config.error_log = part.error_log;
|
||||
} else {
|
||||
config.logging =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?;
|
||||
}
|
||||
}
|
||||
"forwarding" => {
|
||||
config.forwarding =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"headers" => {
|
||||
config.headers =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"prometheus" => {
|
||||
config.prometheus =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"geoip" => {
|
||||
config.geoip_updates =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"administration" => {
|
||||
config.administration =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"tls" => {
|
||||
config.tls = serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"vhosts" => {
|
||||
config.vhosts =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"web-services" => {
|
||||
config.web_services =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"php-fpm-profiles" => {
|
||||
config.php_fpm_profiles =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"redirects" => {
|
||||
config.redirects =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"not_found_hosts" => {
|
||||
config.not_found_hosts =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"streams" => {
|
||||
config.streams =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"acl-profiles" => {
|
||||
config.acl_profiles =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"host-policies" => {
|
||||
config.host_policies =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"user-agent-blocking" => {
|
||||
config.user_agent_blocking =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
_ => {
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown configuration section".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
state
|
||||
.update_as(
|
||||
Update {
|
||||
expected_revision: input.expected_revision,
|
||||
config,
|
||||
},
|
||||
session.username,
|
||||
)
|
||||
.await
|
||||
.map(Json)
|
||||
}
|
||||
fn logs_payload(state: &App) -> Value {
|
||||
json!({
|
||||
"entries": state.logs.recent_limit(crate::logging::LIVE_WINDOW),
|
||||
|
||||
@@ -5,6 +5,7 @@ include!("session.rs");
|
||||
include!("preferences.rs");
|
||||
include!("dashboard_presets.rs");
|
||||
include!("status.rs");
|
||||
include!("config_sections.rs");
|
||||
include!("logging_api.rs");
|
||||
include!("config_reload.rs");
|
||||
include!("api_tokens.rs");
|
||||
|
||||
+465
-68
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.34.12",
|
||||
"version": "0.34.13",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
||||
},
|
||||
"servers": [
|
||||
@@ -1025,11 +1025,22 @@
|
||||
},
|
||||
"description": "Named PHP-FPM configurations reusable by Web Services."
|
||||
},
|
||||
"global_security": {
|
||||
"$ref": "#/components/schemas/GlobalSecurity"
|
||||
},
|
||||
"user_agent_blocking": {
|
||||
"$ref": "#/components/schemas/UserAgentBlocking"
|
||||
"deprecated": true,
|
||||
"writeOnly": true,
|
||||
"description": "Migration-only input for full configuration imports from older installations. The live GET response and canonical saved configuration use global_security.user_agent_blocking instead. Conflicting legacy and nested policies are rejected.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UserAgentBlocking"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
"additionalProperties": false,
|
||||
"description": "The canonical global User-Agent policy lives at global_security.user_agent_blocking; the old root-level key is accepted only as a migration input, not returned by this API."
|
||||
},
|
||||
"Envelope": {
|
||||
"type": "object",
|
||||
@@ -1797,10 +1808,10 @@
|
||||
"expected_revision": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Revision returned by the latest GET /api/config."
|
||||
"description": "Current revision from GET /api/config or GET /api/config/{section}."
|
||||
},
|
||||
"value": {
|
||||
"description": "Complete value of the selected configuration section. This is a section replacement, not a merge patch."
|
||||
"description": "Complete value of the selected configuration section. This is a section replacement, not a merge patch. Read GET /api/config/{section} first, then return its complete `value` with edits. Even an empty array is a full replacement of that collection."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -7585,6 +7596,135 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"GlobalSecurity": {
|
||||
"type": "object",
|
||||
"description": "One atomic global HTTP security configuration for all public HTTP hosts. Includes User-Agent blocking, country/IP restrictions, methods, request Content-Type, shared rate limiting and response security headers. Individual policies default off. Old top-level user_agent_blocking in an imported full config is migrated at validation time; conflicting old and new values are rejected.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"country_access": {
|
||||
"$ref": "#/components/schemas/CountryAccess"
|
||||
},
|
||||
"acl": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Acl"
|
||||
}
|
||||
],
|
||||
"nullable": true,
|
||||
"description": "Optional global client IP/CIDR and allow-only DNS ACL; null disables this layer."
|
||||
},
|
||||
"method_policy": {
|
||||
"$ref": "#/components/schemas/MethodPolicy"
|
||||
},
|
||||
"content_type_policy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"blocked": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 64,
|
||||
"description": "Blocked MIME types, e.g. application/json, image/*."
|
||||
}
|
||||
}
|
||||
},
|
||||
"rate_limit": {
|
||||
"$ref": "#/components/schemas/VhostRateLimit"
|
||||
},
|
||||
"security_headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"hsts": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"max_age_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 63072000,
|
||||
"default": 31536000
|
||||
},
|
||||
"include_subdomains": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"preload": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"nosniff": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"frame_options": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"preserve",
|
||||
"deny",
|
||||
"sameorigin"
|
||||
],
|
||||
"default": "preserve"
|
||||
},
|
||||
"referrer_policy": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"maxLength": 8192
|
||||
},
|
||||
"content_security_policy": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"maxLength": 8192
|
||||
},
|
||||
"permissions_policy": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"maxLength": 8192
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_agent_blocking": {
|
||||
"$ref": "#/components/schemas/UserAgentBlocking"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user_agent_blocking"
|
||||
]
|
||||
},
|
||||
"ConfigSectionRead": {
|
||||
"type": "object",
|
||||
"description": "Read one whole configuration section with the same value format accepted by PUT. It does not contain storage metadata or unrelated sections.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"section": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"description": "Serialized complete value for the requested section. The format is identical to PUT /api/config/{section}. See x-rustproxy-section-fields on the operation for section-to-Config mapping."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"revision",
|
||||
"section",
|
||||
"value"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9813,66 +9953,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/geoip/update": {
|
||||
"post": {
|
||||
"summary": "Check, download and hot-reload GeoIP country databases now",
|
||||
"description": "Download the configured direct GeoIP source(s), optionally verify publisher SHA-256 values, validate the selected CSV/MMDB format, atomically publish changed files and hot-reload future GeoIP lookups.",
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"operationId": "post_api_geoip_update",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "GeoIP check completed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GeoIpUpdateResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required or revoked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Host, origin or CSRF validation failed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "Publisher check, download, validation or reload failed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"AdminSession": []
|
||||
},
|
||||
{
|
||||
"GeneralApiBearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/cache/purge": {
|
||||
"post": {
|
||||
"summary": "Purge all cache entries or one vhost",
|
||||
@@ -10644,9 +10724,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Unknown or removed configuration section",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Preferred UI/control-plane save path. Replaces only the selected section while preserving every other section from the current revision. This is not a merge patch. Cache identity is independent from the global configuration revision: logging, listeners, TLS inventory, metrics and administration saves do not purge cached objects. Changes which can alter origin requests or cached responses (cache/transform/response/forwarding/vhosts) rotate the cache compatibility identity and purge incompatible hot/cold objects. Supported section names include docker-agents, vhosts, redirects, not_found_hosts, streams, listeners, http2, tls, pages, cache, transform, logging, forwarding, headers, prometheus, geoip, administration, acl-profiles, host-policies and response.",
|
||||
"description": "Replaces the named section in one revision-checked, validated, durable configuration update; unrelated sections are preserved. This is a full section replacement, not a merge/patch. Use GET /api/config/{section} to read the exact value shape before PUT. logging requires {logging, error_log}; response requires {server_header, diagnostic_headers, compression}; global-security requires one GlobalSecurity object containing user_agent_blocking. The legacy standalone user-agent-blocking section and standalone error_log are not writable. Global revision conflicts return 409, even for writes to different sections; retry after fetching a new revision. PUT returns the complete updated {revision, config} envelope so the UI can refresh all sections. Origin-dependent cache policy changes still invalidate incompatible cached objects; unrelated logging/listener/administration updates do not purge the cache. See x-rustproxy-section-fields for canonical JSON field mapping.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "section",
|
||||
@@ -10671,11 +10761,13 @@
|
||||
"tls",
|
||||
"vhosts",
|
||||
"redirects",
|
||||
"not_found_hosts",
|
||||
"not-found-hosts",
|
||||
"streams",
|
||||
"acl-profiles",
|
||||
"host-policies",
|
||||
"user-agent-blocking"
|
||||
"global-security",
|
||||
"web-services",
|
||||
"php-fpm-profiles"
|
||||
]
|
||||
},
|
||||
"description": "Configuration section to replace."
|
||||
@@ -10705,6 +10797,9 @@
|
||||
{
|
||||
"AdminSession": [],
|
||||
"CsrfToken": []
|
||||
},
|
||||
{
|
||||
"GeneralApiBearer": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@@ -10716,6 +10811,248 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-rustproxy-section-fields": {
|
||||
"listeners": [
|
||||
"listeners"
|
||||
],
|
||||
"docker-agents": [
|
||||
"docker_agents"
|
||||
],
|
||||
"http2": [
|
||||
"http2"
|
||||
],
|
||||
"pages": [
|
||||
"pages"
|
||||
],
|
||||
"cache": [
|
||||
"cache"
|
||||
],
|
||||
"transform": [
|
||||
"transform"
|
||||
],
|
||||
"forwarding": [
|
||||
"forwarding"
|
||||
],
|
||||
"headers": [
|
||||
"headers"
|
||||
],
|
||||
"prometheus": [
|
||||
"prometheus"
|
||||
],
|
||||
"geoip": [
|
||||
"geoip_updates"
|
||||
],
|
||||
"administration": [
|
||||
"administration"
|
||||
],
|
||||
"tls": [
|
||||
"tls"
|
||||
],
|
||||
"vhosts": [
|
||||
"vhosts"
|
||||
],
|
||||
"web-services": [
|
||||
"web_services"
|
||||
],
|
||||
"php-fpm-profiles": [
|
||||
"php_fpm_profiles"
|
||||
],
|
||||
"redirects": [
|
||||
"redirects"
|
||||
],
|
||||
"not-found-hosts": [
|
||||
"not_found_hosts"
|
||||
],
|
||||
"streams": [
|
||||
"streams"
|
||||
],
|
||||
"acl-profiles": [
|
||||
"acl_profiles"
|
||||
],
|
||||
"host-policies": [
|
||||
"host_policies"
|
||||
],
|
||||
"response": [
|
||||
"server_header",
|
||||
"diagnostic_headers",
|
||||
"compression"
|
||||
],
|
||||
"logging": [
|
||||
"logging",
|
||||
"error_log"
|
||||
],
|
||||
"global-security": [
|
||||
"global_security"
|
||||
]
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"summary": "Read one revisioned configuration section",
|
||||
"description": "Returns {revision, section, value}. The value is the complete canonical section payload accepted by PUT /api/config/{section}, not a partial patch. logging groups logging + error_log, response groups the three response controls, and global-security contains user_agent_blocking and all other global security rules. No storage paths are returned by this endpoint. See x-rustproxy-section-fields for the exact Config property mapping of all supported names.",
|
||||
"tags": [
|
||||
"Configuration"
|
||||
],
|
||||
"operationId": "get_api_config_section",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "section",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"listeners",
|
||||
"docker-agents",
|
||||
"response",
|
||||
"pages",
|
||||
"cache",
|
||||
"transform",
|
||||
"logging",
|
||||
"forwarding",
|
||||
"headers",
|
||||
"prometheus",
|
||||
"geoip",
|
||||
"administration",
|
||||
"http2",
|
||||
"tls",
|
||||
"vhosts",
|
||||
"redirects",
|
||||
"not-found-hosts",
|
||||
"streams",
|
||||
"acl-profiles",
|
||||
"host-policies",
|
||||
"global-security",
|
||||
"web-services",
|
||||
"php-fpm-profiles"
|
||||
]
|
||||
},
|
||||
"description": "Exactly one supported configuration section."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Section value and current global revision",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConfigSectionRead"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden by Host or token policy",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Unknown or removed configuration section",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"AdminSession": []
|
||||
},
|
||||
{
|
||||
"GeneralApiBearer": []
|
||||
}
|
||||
],
|
||||
"x-rustproxy-section-fields": {
|
||||
"listeners": [
|
||||
"listeners"
|
||||
],
|
||||
"docker-agents": [
|
||||
"docker_agents"
|
||||
],
|
||||
"http2": [
|
||||
"http2"
|
||||
],
|
||||
"pages": [
|
||||
"pages"
|
||||
],
|
||||
"cache": [
|
||||
"cache"
|
||||
],
|
||||
"transform": [
|
||||
"transform"
|
||||
],
|
||||
"forwarding": [
|
||||
"forwarding"
|
||||
],
|
||||
"headers": [
|
||||
"headers"
|
||||
],
|
||||
"prometheus": [
|
||||
"prometheus"
|
||||
],
|
||||
"geoip": [
|
||||
"geoip_updates"
|
||||
],
|
||||
"administration": [
|
||||
"administration"
|
||||
],
|
||||
"tls": [
|
||||
"tls"
|
||||
],
|
||||
"vhosts": [
|
||||
"vhosts"
|
||||
],
|
||||
"web-services": [
|
||||
"web_services"
|
||||
],
|
||||
"php-fpm-profiles": [
|
||||
"php_fpm_profiles"
|
||||
],
|
||||
"redirects": [
|
||||
"redirects"
|
||||
],
|
||||
"not-found-hosts": [
|
||||
"not_found_hosts"
|
||||
],
|
||||
"streams": [
|
||||
"streams"
|
||||
],
|
||||
"acl-profiles": [
|
||||
"acl_profiles"
|
||||
],
|
||||
"host-policies": [
|
||||
"host_policies"
|
||||
],
|
||||
"response": [
|
||||
"server_header",
|
||||
"diagnostic_headers",
|
||||
"compression"
|
||||
],
|
||||
"logging": [
|
||||
"logging",
|
||||
"error_log"
|
||||
],
|
||||
"global-security": [
|
||||
"global_security"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -13905,6 +14242,66 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/config/geoip/update": {
|
||||
"post": {
|
||||
"summary": "Update GeoIP data using saved geoip configuration now",
|
||||
"description": "Manually start a GeoIP data refresh using the already-saved settings at GET/PUT /api/config/geoip. This is an operational POST, not a configuration PUT and it does not increment the configuration revision. Downloads configured GeoIP sources, verifies optional SHA-256 checksums, checks CSV/MMDB, atomically publishes changed databases and hot-reloads future lookups. Save any edited GeoIP settings before calling this action. The response is GeoIpUpdateResult; a failed external fetch returns 502 and existing usable data remains untouched.",
|
||||
"tags": [
|
||||
"Configuration"
|
||||
],
|
||||
"operationId": "post_api_config_geoip_update",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "GeoIP check completed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GeoIpUpdateResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required or revoked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Host, origin or CSRF validation failed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "Publisher check, download, validation or reload failed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"AdminSession": []
|
||||
},
|
||||
{
|
||||
"GeneralApiBearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
|
||||
@@ -1,34 +1,3 @@
|
||||
async fn geoip_update(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Result<Json<crate::geoip::UpdateResult>, ApiError> {
|
||||
let result = crate::geoip::update_now(&state).await.map_err(|error| {
|
||||
tracing::warn!(error = %format!("{error:#}"), "manual GeoIP update failed");
|
||||
ApiError(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("GeoIP update failed: {error:#}"),
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.store
|
||||
.audit(
|
||||
&session.username,
|
||||
"geoip.update",
|
||||
&format!(
|
||||
"outcome={}; files={}",
|
||||
result.outcome,
|
||||
if result.updated_files.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
result.updated_files.join(",")
|
||||
}
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
async fn transform_status(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
let snapshot = state.config.load_full();
|
||||
let global = &snapshot.envelope.config.transform;
|
||||
|
||||
+2
-2
@@ -235,7 +235,6 @@ pub fn router(state: Arc<App>) -> Router {
|
||||
.route("/api/traffic", get(traffic_status))
|
||||
.route("/api/cache", get(cache_status))
|
||||
.route("/api/transform", get(transform_status))
|
||||
.route("/api/geoip/update", post(geoip_update))
|
||||
.route("/api/cache/purge", post(cache_purge))
|
||||
.route(
|
||||
"/api/cache/invalidate",
|
||||
@@ -260,7 +259,8 @@ pub fn router(state: Arc<App>) -> Router {
|
||||
"/api/config/history/restore",
|
||||
post(restore_config_history).layer(DefaultBodyLimit::max(4096)),
|
||||
)
|
||||
.route("/api/config/{section}", put(update_section))
|
||||
.route("/api/config/geoip/update", post(geoip_update))
|
||||
.route("/api/config/{section}", get(configuration_section).put(update_section))
|
||||
.route("/api/logs", get(logs))
|
||||
.route("/api/live-logs", get(live_logs_stream))
|
||||
.route("/api/file-logs", get(file_logs))
|
||||
|
||||
+162
-3
@@ -651,6 +651,99 @@ mod tests {
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_section_registry_read_and_replace_are_symmetric() {
|
||||
let names = [
|
||||
"listeners", "docker-agents", "http2", "pages", "cache", "transform",
|
||||
"forwarding", "headers", "prometheus", "geoip", "administration",
|
||||
"tls", "vhosts", "web-services", "php-fpm-profiles", "redirects",
|
||||
"not-found-hosts", "streams", "acl-profiles", "host-policies",
|
||||
"response", "logging", "global-security",
|
||||
];
|
||||
let mut original = Config::default();
|
||||
original.global_security.compile().unwrap();
|
||||
let expected = serde_json::to_value(&original).unwrap();
|
||||
for name in names {
|
||||
let section = ConfigSection::from_name(name).expect(name);
|
||||
let value = section.read(&original);
|
||||
let mut updated = original.clone();
|
||||
section.replace(&mut updated, name, value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&updated).unwrap(), expected, "{name}");
|
||||
assert_eq!(section.read(&updated), value, "{name}");
|
||||
}
|
||||
for name in ["user-agent-blocking", "error_log", "history", "storage", "unknown", "tls/nested"] {
|
||||
assert!(ConfigSection::from_name(name).is_none(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn section_get_matches_put_payload_and_rejects_unknown_names() {
|
||||
let (router, store, _dir, secret, _csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for name in [
|
||||
"listeners", "docker-agents", "http2", "pages", "cache", "transform",
|
||||
"forwarding", "headers", "prometheus", "geoip", "administration", "tls",
|
||||
"vhosts", "web-services", "php-fpm-profiles", "redirects", "not-found-hosts",
|
||||
"streams", "acl-profiles", "host-policies", "response", "logging",
|
||||
"global-security",
|
||||
] {
|
||||
let response = router.clone().oneshot(
|
||||
request(&format!("/api/config/{name}"))
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty()).unwrap(),
|
||||
).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "{name}");
|
||||
let data = json_body(response).await;
|
||||
assert_eq!(data["section"], name);
|
||||
assert_eq!(data["revision"], 0);
|
||||
assert!(!data["value"].is_null(), "{name}");
|
||||
if name == "logging" {
|
||||
assert!(data["value"].get("logging").is_some());
|
||||
assert!(data["value"].get("error_log").is_some());
|
||||
}
|
||||
if name == "response" {
|
||||
assert_eq!(data["value"].as_object().unwrap().len(), 3);
|
||||
}
|
||||
if name == "global-security" {
|
||||
assert!(data["value"]["user_agent_blocking"].is_object());
|
||||
}
|
||||
}
|
||||
let unknown = router.clone().oneshot(
|
||||
request("/api/config/user-agent-blocking")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty()).unwrap(),
|
||||
).await.unwrap();
|
||||
assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geoip_update_is_scoped_to_settings_for_general_api_tokens() {
|
||||
assert!(general_api_token_allowed(&Method::GET, "/api/config/geoip", "ro"));
|
||||
assert!(general_api_token_allowed(&Method::PUT, "/api/config/geoip", "rw"));
|
||||
assert!(!general_api_token_allowed(&Method::PUT, "/api/config/geoip", "ro"));
|
||||
assert!(general_api_token_allowed(&Method::POST, "/api/config/geoip/update", "rw"));
|
||||
assert!(!general_api_token_allowed(&Method::POST, "/api/config/geoip/update", "ro"));
|
||||
assert!(!general_api_token_allowed(&Method::POST, "/api/geoip/update", "rw"));
|
||||
assert!(!general_api_token_allowed(&Method::GET, "/api/config/geoip/update", "rw"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn geoip_action_new_path_and_old_path_route_contract() {
|
||||
let (router, store, _dir, secret, _csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for (path, expected) in [
|
||||
("/api/config/geoip/update", StatusCode::METHOD_NOT_ALLOWED),
|
||||
("/api/geoip/update", StatusCode::NOT_FOUND),
|
||||
] {
|
||||
let response = router.clone().oneshot(
|
||||
request(path).header("cookie", &cookie).body(Body::empty()).unwrap(),
|
||||
).await.unwrap();
|
||||
assert_eq!(response.status(), expected, "{path}");
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn section_updates_are_revisioned_and_do_not_require_full_config_payloads() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
@@ -658,7 +751,7 @@ mod tests {
|
||||
let logging = Config::default().logging;
|
||||
let body = serde_json::to_vec(&json!({
|
||||
"expected_revision": 0,
|
||||
"value": logging,
|
||||
"value": {"logging": logging, "error_log": Config::default().error_log},
|
||||
}))
|
||||
.unwrap();
|
||||
let response = router
|
||||
@@ -721,7 +814,10 @@ mod tests {
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(
|
||||
&json!({"expected_revision":0,"value":Config::default().logging}),
|
||||
&json!({"expected_revision":0,"value":{
|
||||
"logging":Config::default().logging,
|
||||
"error_log":Config::default().error_log
|
||||
}}),
|
||||
)
|
||||
.unwrap(),
|
||||
))
|
||||
@@ -750,6 +846,69 @@ mod tests {
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_security_section_saves_rules_and_existing_ua_atomically() {
|
||||
let (router, _store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let value = serde_json::json!({
|
||||
"country_access": {"mode":"block", "countries":["ru"]},
|
||||
"method_policy": {"mode":"block", "methods":["TRACE"]},
|
||||
"security_headers": {"nosniff":true},
|
||||
"user_agent_blocking": {"enabled":true, "patterns":["bad-bot"]}
|
||||
});
|
||||
let body = serde_json::to_vec(&json!({"expected_revision":0, "value":value})).unwrap();
|
||||
let response = router.clone().oneshot(
|
||||
request("/api/config/global-security")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(body)).unwrap(),
|
||||
).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let json = json_body(response).await;
|
||||
assert_eq!(json["revision"], 1);
|
||||
assert_eq!(json["config"]["global_security"]["country_access"]["countries"][0], "RU");
|
||||
assert_eq!(json["config"]["global_security"]["security_headers"]["nosniff"], true);
|
||||
assert_eq!(json["config"]["global_security"]["user_agent_blocking"]["patterns"][0], "bad-bot");
|
||||
assert!(json["config"].get("user_agent_blocking").is_none());
|
||||
|
||||
// A partial section update must not silently reset the existing UA policy.
|
||||
let body = serde_json::to_vec(&json!({
|
||||
"expected_revision":1,
|
||||
"value": {"method_policy":{"mode":"off","methods":[]}}
|
||||
})).unwrap();
|
||||
let response = router.clone().oneshot(
|
||||
request("/api/config/global-security")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(body)).unwrap(),
|
||||
).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// GET returns the same standalone section shape accepted by PUT, at the
|
||||
// matching revision and without unrequested configuration fields.
|
||||
let read = router.clone().oneshot(
|
||||
request("/api/config/global-security")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty()).unwrap(),
|
||||
).await.unwrap();
|
||||
assert_eq!(read.status(), StatusCode::OK);
|
||||
let view = json_body(read).await;
|
||||
assert_eq!(view["revision"], 1);
|
||||
assert_eq!(view["section"], "global-security");
|
||||
assert_eq!(view["value"]["user_agent_blocking"]["patterns"][0], "bad-bot");
|
||||
assert!(view.get("config").is_none());
|
||||
assert!(view["value"].get("global_security").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logging_section_persists_error_log_settings_atomically() {
|
||||
let (router, store, dir, secret, csrf) = fixture().await;
|
||||
@@ -2295,7 +2454,7 @@ mod tests {
|
||||
("/api/acme/issue", "POST"),
|
||||
("/api/acme/renew", "POST"),
|
||||
("/api/docker/refresh", "POST"),
|
||||
("/api/geoip/update", "POST"),
|
||||
("/api/config/geoip/update", "POST"),
|
||||
("/api/cache/purge", "POST"),
|
||||
("/api/cache/invalidate", "POST"),
|
||||
("/api/cache/bans", "GET"),
|
||||
|
||||
@@ -112,7 +112,7 @@ function appConfirm(message, options = {}) {
|
||||
}
|
||||
function apiArea(path) {
|
||||
const section = path.match(/^\/api\/config\/([^/?]+)/)?.[1];
|
||||
if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "geoip": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not_found_hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security", "user-agent-blocking": "security" })[section] || "configuration";
|
||||
if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "geoip": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not-found-hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security", "global-security": "settings" })[section] || "configuration";
|
||||
if (path === "/api/config") return "configuration";
|
||||
if (path.startsWith("/api/traffic")) return "traffic";
|
||||
if (path.startsWith("/api/cache")) return "cache";
|
||||
@@ -386,9 +386,6 @@ function renderConfig() {
|
||||
$("host-policy-list").replaceChildren(...(config.host_policies || []).map((policy, index) => { const box = el("div", undefined, "entity"); box.append(el("span", policy.hosts.join(", "), "entity-title"), el("p", `${t("aclDefault")}: ${t(policy.acl.default || "deny")}\n${rulesText(policy.acl)}`, "entity-details")); const buttons = el("div", undefined, "entity-actions"); buttons.append(action(t("edit"), () => { $("policy-hosts").value = policy.hosts.join(", "); $("policy-default").value = policy.acl.default || "deny"; $("policy-rules").value = rulesText(policy.acl); const editor = $("security-policy-editor"); if (editor) { editor.open = true; editor.scrollIntoView({ block: "nearest" }); } }), action(t("remove"), async () => { if (await appConfirm(t("confirmDelete"), { danger: true })) { const next = structuredClone(config); next.host_policies.splice(index, 1); await saveSection("host-policies", next.host_policies); } })); box.append(buttons); return box; }));
|
||||
if (!config.host_policies?.length) $("host-policy-list").append(el("p", t("noPolicies"), "empty"));
|
||||
if ($("security-policy-count")) $("security-policy-count").textContent = String((config.host_policies || []).length);
|
||||
const uaPolicy = config.user_agent_blocking || {};
|
||||
if ($("global-ua-block-enabled")) $("global-ua-block-enabled").checked = uaPolicy.enabled === true;
|
||||
if ($("global-ua-block-patterns")) $("global-ua-block-patterns").value = (uaPolicy.patterns || []).join("\n");
|
||||
}
|
||||
async function saveRequest(path, payload) { if (busy) { toast(t("busy"), true); return false; } if (!authenticated || !envelope) return false; busy = true; const epoch = authEpoch; try { const value = await api(path, { method: "PUT", body: JSON.stringify(payload) }); if (!authenticated || epoch !== authEpoch) return false; envelope = value; if (path === "/api/config" || path === "/api/config/tls") await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch || !envelope) return false; renderConfig(); toast(t("saved")); await refresh(true); return true; } catch (e) { toast(e.message, true); if (e.status === 401) lock(); return false; } finally { busy = false; } }
|
||||
async function save(config) { return saveRequest("/api/config", { expected_revision: envelope?.revision, config }); }
|
||||
@@ -514,7 +511,69 @@ document.addEventListener("click", event => { if (dashboardSidebar?.classList.co
|
||||
document.addEventListener("keydown", event => { if (event.key === "Escape" && dashboardSidebar?.classList.contains("mobile-nav-open")) { setMobileMenu(false); mobileMenuToggle?.focus(); } });
|
||||
window.addEventListener("popstate", () => { setPanel(panelFromLocation(), false); setMobileMenu(false); });
|
||||
$("host-policy-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const hosts = [...new Set($("policy-hosts").value.split(",").map(h => h.trim().toLowerCase()).filter(Boolean))]; const acl = { default: $("policy-default").value, rules: parseRules($("policy-rules").value, $("policy-rules")) }; const next = structuredClone(envelope.config); next.host_policies = (next.host_policies || []).map(p => ({ ...p, hosts: p.hosts.filter(h => !hosts.includes(h)) })).filter(p => p.hosts.length); next.host_policies.push({ hosts, acl }); if (await saveSection("host-policies", next.host_policies)) { form.reset(); if ($("security-policy-editor")) $("security-policy-editor").open = false; } }); } catch (e) { toast(e.message, true); } });
|
||||
$("global-ua-block-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const patterns = [...new Set($("global-ua-block-patterns").value.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]; await saveSection("user-agent-blocking", { enabled: $("global-ua-block-enabled").checked, patterns }); }); } catch (e) { toast(e.message, true); } });
|
||||
// Global HTTP controls are a single atomic API section. A header-only save preserves
|
||||
// the current block rules; a block-only save preserves the current security headers.
|
||||
const globalLines = id => [...new Set($(id).value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean))];
|
||||
const globalPatternLines = id => [...new Set($(id).value.split(/\r?\n/).map(v => v.trim()).filter(Boolean))];
|
||||
function globalSecurityPayload(security, ua) {
|
||||
return { ...security, user_agent_blocking: ua };
|
||||
}
|
||||
$("global-security-form")?.addEventListener("submit", async event => {
|
||||
event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return;
|
||||
try {
|
||||
await withFormSubmitState(form, event.submitter, async () => {
|
||||
const previous = envelope.config.global_security || {}, rate = previous.rate_limit || {};
|
||||
const countryMode = $("global-country-mode").value;
|
||||
const countries = globalLines("global-country-list").map(v => v.toUpperCase());
|
||||
if (countryMode !== "off" && !countries.length) throw fieldError("global-country-list", t("globalCountriesRequired"));
|
||||
if (countries.some(v => !/^[A-Z]{2}$/.test(v))) throw fieldError("global-country-list", t("globalCountriesInvalid"));
|
||||
const methodMode = $("global-method-mode").value;
|
||||
const methods = [...new Set($("global-method-list").value.split(/[\s,]+/).map(v => v.trim().toUpperCase()).filter(Boolean))];
|
||||
if (methodMode !== "off" && !methods.length) throw fieldError("global-method-list", t("globalMethodsRequired"));
|
||||
const contentTypes = globalLines("global-content-type-list").map(v => v.toLowerCase());
|
||||
if ($("global-content-type-enabled").checked && !contentTypes.length) throw fieldError("global-content-type-list", t("globalContentTypesRequired"));
|
||||
const acl = $("global-ip-enabled").checked ? { default: $("global-ip-default").value, rules: parseRules($("global-ip-rules").value, $("global-ip-rules")) } : null;
|
||||
const ua = { enabled: $("global-ua-block-enabled").checked, patterns: globalPatternLines("global-ua-block-patterns") };
|
||||
const security = {
|
||||
...previous,
|
||||
country_access: { mode: countryMode, countries },
|
||||
acl,
|
||||
method_policy: { mode: methodMode, methods },
|
||||
content_type_policy: { enabled: $("global-content-type-enabled").checked, blocked: contentTypes },
|
||||
rate_limit: { ...rate, enabled: $("global-rate-enabled").checked,
|
||||
requests: Number($("global-rate-requests").value),
|
||||
window_seconds: Number($("global-rate-window").value),
|
||||
burst: Number($("global-rate-burst").value) },
|
||||
};
|
||||
if (await saveSection("global-security", globalSecurityPayload(security, ua))) {
|
||||
setSettingsDirty("global-security-form", false); renderSettings();
|
||||
}
|
||||
});
|
||||
} catch (e) { toast(e.message, true); }
|
||||
});
|
||||
$("global-security-headers-form")?.addEventListener("submit", async event => {
|
||||
event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return;
|
||||
try {
|
||||
await withFormSubmitState(form, event.submitter, async () => {
|
||||
const previous = envelope.config.global_security || {};
|
||||
const optional = id => $(id).value.trim() || null;
|
||||
const security = { ...previous, security_headers: {
|
||||
hsts: { enabled: $("global-hsts-enabled").checked,
|
||||
max_age_seconds: Number($("global-hsts-age").value),
|
||||
include_subdomains: $("global-hsts-enabled").checked && $("global-hsts-sub").checked,
|
||||
preload: $("global-hsts-enabled").checked && $("global-hsts-preload").checked },
|
||||
nosniff: $("global-nosniff").checked, frame_options: $("global-frame").value,
|
||||
referrer_policy: optional("global-referrer"),
|
||||
content_security_policy: optional("global-csp"),
|
||||
permissions_policy: optional("global-permissions")
|
||||
}};
|
||||
if (await saveSection("global-security", globalSecurityPayload(security, previous.user_agent_blocking || { enabled: false, patterns: [] }))) {
|
||||
setSettingsDirty("global-security-headers-form", false); renderSettings();
|
||||
}
|
||||
});
|
||||
} catch (e) { toast(e.message, true); }
|
||||
});
|
||||
|
||||
$("password-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; try { await withFormSubmitState(form, event.submitter, async () => { if ($("new-password").value !== $("new-password-confirm").value) throw fieldError("new-password-confirm", t("passwordMismatch")); await api("/api/auth/password", { method: "POST", body: JSON.stringify({ current_password: $("current-password").value, new_password: $("new-password").value }) }); lock(); toast(t("passwordChanged")); }); } catch (e) { toast(e.message, true); } });
|
||||
$("revoke-sessions").addEventListener("click", async () => { if (!(await appConfirm(t("confirmRevoke"), { danger: true }))) return; try { await api("/api/auth/logout-all", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { toast(e.message, true); } });
|
||||
$("security-audit-open").addEventListener("click", () => { const dialog = $("audit-dialog"); if (!dialog.open) dialog.showModal(); void loadAudit(); });
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
output.textContent = target.hidden ? "" : commands({ ...options, user: account.value });
|
||||
output.hidden = !output.textContent;
|
||||
description.hidden = output.hidden;
|
||||
copy.hidden = !output.textContent;
|
||||
copy.disabled = !output.textContent;
|
||||
empty.hidden = !!output.textContent;
|
||||
empty.textContent = t(options.kind === "web" ? "pathPermissionsWebPrompt" : "pathPermissionsCertificatePrompt");
|
||||
@@ -133,7 +134,7 @@
|
||||
const empty = el("p", undefined, "fine");
|
||||
details.append(summary, label, help);
|
||||
actions.append(copy, details);
|
||||
target.replaceChildren(description, output, empty, actions);
|
||||
target.replaceChildren(output, description, empty, actions);
|
||||
view = { target, options, description, summary, help, accountLabel, account, output, empty, copy };
|
||||
views.set(target, view);
|
||||
account.addEventListener("input", () => update(view));
|
||||
|
||||
@@ -76,6 +76,61 @@ function renderSettings() {
|
||||
$("error-log-status").textContent = errorSink ? `${t("loggingQueue")}: ${fmt(errorSink.queue_dropped)} ${t("loggingDropped")} / ${fmt(errorSink.written)} ${t("loggingSent")} / ${fmt(errorSink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(errorSink.queue_capacity)}` : "";
|
||||
if (!settingsDirty.has("forwarding-form")) { $("trusted-proxies").value = (config.forwarding?.trusted_proxies || []).join("\n"); $("trusted-proto").checked = !!config.forwarding?.trust_forwarded_proto; }
|
||||
if (!settingsDirty.has("headers-form")) { for (const direction of ["request", "response"]) renderHeaderRules(direction, config.headers?.[direction] || []); }
|
||||
const global = config.global_security || {};
|
||||
if (!settingsDirty.has("global-security-form")) {
|
||||
const country = global.country_access || {}, methods = global.method_policy || {}, content = global.content_type_policy || {}, rate = global.rate_limit || {};
|
||||
const ua = global.user_agent_blocking || {}, acl = global.acl;
|
||||
$("global-country-mode").value = country.mode || "off";
|
||||
$("global-country-list").value = (country.countries || []).join("\n");
|
||||
$("global-ua-block-enabled").checked = ua.enabled === true;
|
||||
$("global-ua-block-patterns").value = (ua.patterns || []).join("\n");
|
||||
$("global-ip-enabled").checked = !!acl;
|
||||
$("global-ip-default").value = acl?.default || "allow";
|
||||
$("global-ip-rules").value = rulesText(acl);
|
||||
$("global-method-mode").value = methods.mode || "off";
|
||||
$("global-method-list").value = (methods.methods || []).join("\n");
|
||||
$("global-content-type-enabled").checked = content.enabled === true;
|
||||
$("global-content-type-list").value = (content.blocked || []).join("\n");
|
||||
$("global-rate-enabled").checked = rate.enabled === true;
|
||||
$("global-rate-requests").value = rate.requests ?? 120;
|
||||
$("global-rate-window").value = rate.window_seconds ?? 60;
|
||||
$("global-rate-burst").value = rate.burst ?? 30;
|
||||
syncGlobalSecurityControls();
|
||||
}
|
||||
if (!settingsDirty.has("global-security-headers-form")) {
|
||||
const headers = global.security_headers || {}, hsts = headers.hsts || {};
|
||||
$("global-hsts-enabled").checked = hsts.enabled === true;
|
||||
$("global-hsts-age").value = hsts.max_age_seconds ?? 31536000;
|
||||
$("global-hsts-sub").checked = hsts.include_subdomains === true;
|
||||
$("global-hsts-preload").checked = hsts.preload === true;
|
||||
$("global-nosniff").checked = headers.nosniff === true;
|
||||
$("global-frame").value = headers.frame_options || "preserve";
|
||||
$("global-referrer").value = headers.referrer_policy || "";
|
||||
$("global-csp").value = headers.content_security_policy || "";
|
||||
$("global-permissions").value = headers.permissions_policy || "";
|
||||
syncGlobalSecurityControls();
|
||||
}
|
||||
}
|
||||
function syncGlobalSecurityControls() {
|
||||
for (const [toggle, children] of [
|
||||
["global-country-mode", ["global-country-list"]],
|
||||
["global-ua-block-enabled", ["global-ua-block-patterns"]],
|
||||
["global-ip-enabled", ["global-ip-default", "global-ip-rules"]],
|
||||
["global-method-mode", ["global-method-list"]],
|
||||
["global-content-type-enabled", ["global-content-type-list"]],
|
||||
["global-rate-enabled", ["global-rate-requests", "global-rate-window", "global-rate-burst"]],
|
||||
["global-hsts-enabled", ["global-hsts-age", "global-hsts-sub", "global-hsts-preload"]]
|
||||
]) {
|
||||
const source = $(toggle);
|
||||
const enabled = source.type === "checkbox" ? source.checked : source.value !== "off";
|
||||
for (const id of children) $(id).disabled = !enabled;
|
||||
}
|
||||
}
|
||||
for (const id of ["global-country-mode", "global-ua-block-enabled", "global-ip-enabled", "global-method-mode", "global-content-type-enabled", "global-rate-enabled", "global-hsts-enabled"]) {
|
||||
$(id)?.addEventListener("change", syncGlobalSecurityControls);
|
||||
}
|
||||
for (const id of ["global-security-form", "global-security-headers-form"]) {
|
||||
for (const type of ["input", "change"]) $(id)?.addEventListener(type, () => setSettingsDirty(id));
|
||||
}
|
||||
function effectiveCacheMemory() {
|
||||
const d = latestStatus?.system || {}, host = Number(d.memory_total_bytes), cgroup = Number(d.cgroup_memory_max_bytes);
|
||||
|
||||
@@ -1504,4 +1504,9 @@ html[data-table-density=ultra] :is(table th, table td) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-width: 260px;
|
||||
}
|
||||
}
|
||||
/* Permission guidance belongs to its generated command block, not the path inputs. */
|
||||
#certificate-external-acl-example.path-permissions { margin-top: 20px; border-top: 1px solid var(--line); padding-top: 15px; }
|
||||
.path-permissions-commands { margin-bottom: 0; }
|
||||
.path-permissions-commands + .path-permissions-description { margin: 7px 0 0; }
|
||||
.path-permissions-actions { margin-top: 10px; }
|
||||
@@ -1204,4 +1204,28 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
|
||||
.refresh-settings-grid select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
/* 0.34.13: global security policy forms, grouped with clear control boundaries. */
|
||||
.global-policy-form .global-policy-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: 18px;
|
||||
padding-top: 17px;
|
||||
min-width: 0;
|
||||
}
|
||||
.global-policy-form .global-policy-section h3 { margin: 0; font-size: 14px; }
|
||||
.global-policy-heading { display: grid; gap: 5px; }
|
||||
.global-policy-heading p { margin: 0; max-width: 85ch; }
|
||||
.global-policy-fields { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr); gap: 14px; align-items: start; }
|
||||
.global-rate-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; align-items: end; }
|
||||
.global-policy-fields label, .global-rate-fields label, .global-policy-section > label:not(.check) { display: grid; gap: 6px; min-width: 0; }
|
||||
.global-policy-section textarea { width: 100%; box-sizing: border-box; }
|
||||
.global-policy-form .form-actions { margin-top: 20px; }
|
||||
.cache-static-guidance { margin: 9px 0 0; max-width: 88ch; }
|
||||
.cache-header-settings-heading { border-top: 1px solid var(--line); padding-top: 18px; margin-top: 24px; margin-bottom: 13px; }
|
||||
.transform-operations-title { border-top: 1px solid var(--line); padding-top: 18px; margin-top: 22px; margin-bottom: 14px; }
|
||||
.transform-operations-grid { margin-top: 3px; row-gap: 12px; }
|
||||
@media (max-width: 760px) {
|
||||
.global-policy-fields, .global-rate-fields { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -80,13 +80,13 @@
|
||||
list.replaceChildren(...entries.map(item => {
|
||||
const card = el("article", undefined, "vhost-card redirect-card"), head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
|
||||
title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title, el("div", (item.frontend?.hosts || []).join(" · "), "vhost-hosts")); head.append(identity);
|
||||
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.not_found_hosts || []).filter(value => value.id !== item.id); if (await saveSection("not_found_hosts", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
|
||||
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.not_found_hosts || []).filter(value => value.id !== item.id); if (await saveSection("not-found-hosts", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
|
||||
const detail = el("div", undefined, "vhost-detail-strip"); for (const [key, text] of [["notfoundStatus", String(item.page?.status ?? 404)], ["notfoundMode", t(`notfoundMode${(item.page?.kind || "welcome").replace(/^./, c => c.toUpperCase())}`)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ")], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("div", undefined, "vhost-detail"); node.append(el("span", t(key), "small-label"), el("b", text)); detail.append(node); } card.append(detail); return card;
|
||||
}));
|
||||
if (!entries.length) list.append(el("p", t(query ? "notfoundSearchEmpty" : "notfoundEmpty"), "fine"));
|
||||
}
|
||||
form.addEventListener("input", changed); form.addEventListener("change", changed); $("notfound-mode").addEventListener("change", syncMode); $("notfound-status").addEventListener("change", syncMode);
|
||||
form.addEventListener("submit", async event => { event.preventDefault(); if (!envelope || busy) return; const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { invalid.reportValidity(); return; } if (revision !== envelope.revision) { toast(t("stale"), true); return; } try { const item = collect(), next = structuredClone(envelope.config.not_found_hosts || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("notfoundDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("not_found_hosts", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); } });
|
||||
form.addEventListener("submit", async event => { event.preventDefault(); if (!envelope || busy) return; const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { invalid.reportValidity(); return; } if (revision !== envelope.revision) { toast(t("stale"), true); return; } try { const item = collect(), next = structuredClone(envelope.config.not_found_hosts || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("notfoundDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("not-found-hosts", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); } });
|
||||
$("notfound-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("notfound-search").addEventListener("input", render); $("notfound-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); render(); }); $("notfound-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); render(); }); $("notfound-close").addEventListener("click", closeEditor); $("notfound-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.not_found_hosts || []).find(item => item.id === editing) || defaults()) : defaults()); }); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
|
||||
window.rustProxyDrafts.register("notfound", { labelKey: "notfound", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
|
||||
window.rustProxyNotFound = { render, applyPreferences, reset() { dialog.close(); editing = null; revision = null; dirty = false; listPrefs = { sort: "id", descending: false }; list.replaceChildren(); $("notfound-search").value = ""; if ($("notfound-sort")) $("notfound-sort").value = listPrefs.sort; syncSortDirection(); } };
|
||||
|
||||
+84
-29
@@ -2088,40 +2088,93 @@
|
||||
</div>
|
||||
<nav aria-label="Rules and headers" class="settings-tabs sub-tabs"
|
||||
id="policy-settings-tabs" role="tablist">
|
||||
<button aria-selected="true" data-policy-settings-tab="user-agent"
|
||||
data-i18n="globalUaBlockingTitle" type="button">Global User-Agent
|
||||
blocklist</button>
|
||||
<button aria-selected="true" data-policy-settings-tab="block-allow"
|
||||
data-i18n="globalBlockAllow" type="button">Global block / allow</button>
|
||||
<button aria-selected="false" data-policy-settings-tab="security-headers"
|
||||
data-i18n="globalSecurityHeaders" type="button">Security headers</button>
|
||||
<button aria-selected="false" data-policy-settings-tab="headers"
|
||||
data-i18n="customHeaders" type="button">Global custom headers</button>
|
||||
<button aria-selected="false" data-policy-settings-tab="response"
|
||||
data-i18n="responseSettings" type="button">Client response</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div data-policy-settings-page="user-agent">
|
||||
<article class="card security-policy-card">
|
||||
<div data-policy-settings-page="block-allow">
|
||||
<form class="card edit-form global-policy-form" id="global-security-form">
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label" data-i18n="userAgentBlocking">USER-AGENT
|
||||
BLOCKING</span>
|
||||
<h2 data-i18n="globalUaBlockingTitle">Global User-Agent blocklist</h2>
|
||||
<p class="fine" data-i18n="globalUaBlockingHelp">Blocks matching User-Agent
|
||||
values on public proxy vhosts before an upstream is opened. Per-vhost
|
||||
lists are configured in Vhosts → Security.</p>
|
||||
</div>
|
||||
<div><span class="small-label" data-i18n="globalPolicyScope">PUBLIC HTTP LISTENERS</span>
|
||||
<h2 data-i18n="globalBlockAllow">Global block / allow</h2>
|
||||
</div><span class="pill live" data-i18n="hotApply">LIVE APPLY</span>
|
||||
</div>
|
||||
<form class="edit-form security-inline-form" id="global-ua-block-form">
|
||||
<label class="check"><input id="global-ua-block-enabled" type="checkbox" /><span
|
||||
data-i18n="enabled">Enabled</span></label>
|
||||
<label><span data-i18n="uaBlockPatterns">Blocked patterns, one per
|
||||
line</span><textarea id="global-ua-block-patterns" rows="6"
|
||||
maxlength="32768" spellcheck="false"
|
||||
placeholder="BadBot curl/* *scanner*"></textarea></label>
|
||||
<p class="fine" data-i18n="uaBlockPatternHelp">Matching is case-insensitive.
|
||||
Plain text matches a substring; * is a wildcard. Requests without User-Agent
|
||||
are not blocked by this rule.</p>
|
||||
<div class="form-actions"><button class="primary" data-i18n="apply"
|
||||
type="submit">Validate, save and apply</button></div>
|
||||
</form>
|
||||
</article>
|
||||
<p class="fine" data-i18n="globalPolicyHelp">These rules apply before routing to the backend on all public HTTP vhosts, redirects and 404 hosts. Per-vhost restrictions remain active; a local allow cannot bypass a global block. Administration and TCP/UDP Streams are excluded.</p>
|
||||
<section class="global-policy-section">
|
||||
<div class="global-policy-heading"><h3 data-i18n="globalCountryTitle">Country access</h3><p class="fine" data-i18n="globalCountryHelp">Uses the verified client IP and GeoIP database. Unknown countries are denied in allow mode, but allowed in block mode.</p></div>
|
||||
<div class="global-policy-fields">
|
||||
<label><span data-i18n="globalCountryMode">Country policy</span><select id="global-country-mode">
|
||||
<option value="off" data-i18n="vhOff">Off</option>
|
||||
<option value="block" data-i18n="vhCountryBlock">Block listed</option>
|
||||
<option value="allow" data-i18n="vhCountryAllow">Allow listed only</option>
|
||||
</select></label>
|
||||
<label><span data-i18n="globalCountryList">ISO country codes (one per line)</span><textarea id="global-country-list" rows="3" maxlength="1024" spellcheck="false" placeholder="RU CN"></textarea></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="global-policy-section">
|
||||
<div class="global-policy-heading"><h3 data-i18n="globalUaTitle">User-Agent blocklist</h3><p class="fine" data-i18n="uaBlockPatternHelp">Case-insensitive substring matching; * is a wildcard. A missing User-Agent is not blocked.</p></div>
|
||||
<label class="check"><input id="global-ua-block-enabled" type="checkbox"/><span data-i18n="globalUaEnabled">Block matching User-Agents</span></label>
|
||||
<label><span data-i18n="uaBlockPatterns">Blocked patterns, one per line</span><textarea id="global-ua-block-patterns" rows="4" maxlength="32768" spellcheck="false" placeholder="BadBot curl/* *scanner*"></textarea></label>
|
||||
</section>
|
||||
<section class="global-policy-section">
|
||||
<div class="global-policy-heading"><h3 data-i18n="globalIpTitle">IP / CIDR access</h3><p class="fine" data-i18n="globalIpHelp">Each line: allow or deny followed by IPv4/IPv6 address, CIDR or allow-only DNS name. Default allow with no rules leaves access unrestricted.</p></div>
|
||||
<label class="check"><input id="global-ip-enabled" type="checkbox"/><span data-i18n="globalIpEnabled">Enable global IP rules</span></label>
|
||||
<div class="global-policy-fields">
|
||||
<label><span data-i18n="aclDefault">Default action</span><select id="global-ip-default"><option value="allow" data-i18n="allow">Allow</option><option value="deny" data-i18n="deny">Deny</option></select></label>
|
||||
<label><span data-i18n="globalIpRules">Rules (one per line)</span><textarea id="global-ip-rules" rows="3" maxlength="32768" placeholder="deny 192.0.2.0/24 allow 10.0.0.0/8" spellcheck="false"></textarea></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="global-policy-section">
|
||||
<div class="global-policy-heading"><h3 data-i18n="globalMethodTitle">HTTP methods</h3><p class="fine" data-i18n="globalMethodHelp">Block selected methods or allow only those listed. Applies before redirects and cache invalidation; denied requests receive 405.</p></div>
|
||||
<div class="global-policy-fields">
|
||||
<label><span data-i18n="globalMethodMode">Method policy</span><select id="global-method-mode"><option value="off" data-i18n="vhOff">Off</option><option value="block" data-i18n="locationMethodBlock">Block listed</option><option value="allow" data-i18n="locationMethodAllow">Allow listed only</option></select></label>
|
||||
<label><span data-i18n="globalMethodList">Methods (comma, space or newline separated)</span><textarea id="global-method-list" rows="2" maxlength="1024" spellcheck="false" placeholder="TRACE, TRACK"></textarea></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="global-policy-section">
|
||||
<div class="global-policy-heading"><h3 data-i18n="globalContentTypeTitle">Request Content-Type</h3><p class="fine" data-i18n="globalContentTypeHelp">Reject declared MIME types (exact or type/*) with 403. This is a header rule, not body inspection.</p></div>
|
||||
<label class="check"><input id="global-content-type-enabled" type="checkbox"/><span data-i18n="globalContentTypeEnabled">Enable Content-Type denylist</span></label>
|
||||
<label><span data-i18n="globalContentTypeList">Blocked MIME types, one per line</span><textarea id="global-content-type-list" rows="3" maxlength="16384" spellcheck="false" placeholder="application/x-msdownload application/x-sh"></textarea></label>
|
||||
</section>
|
||||
<section class="global-policy-section">
|
||||
<div class="global-policy-heading"><h3 data-i18n="globalRateTitle">Shared rate limit</h3><p class="fine" data-i18n="globalRateHelp">Optional per-client-IP limit shared by all public vhosts. Runs in addition to per-vhost limits; use conservative values to avoid blocking legitimate shared clients.</p></div>
|
||||
<label class="check"><input id="global-rate-enabled" type="checkbox"/><span data-i18n="globalRateEnabled">Enable global request rate limit</span></label>
|
||||
<div class="global-rate-fields">
|
||||
<label><span data-i18n="globalRateRequests">Requests per window</span><input id="global-rate-requests" type="number" min="1" max="1000000" required value="120"/></label>
|
||||
<label><span data-i18n="globalRateWindow">Window (seconds)</span><input id="global-rate-window" type="number" min="1" max="3600" required value="60"/></label>
|
||||
<label><span data-i18n="globalRateBurst">Burst allowance</span><input id="global-rate-burst" type="number" min="0" max="1000000" required value="30"/></label>
|
||||
</div>
|
||||
</section>
|
||||
<div class="form-actions"><button class="primary" data-i18n="globalPolicySave" type="submit">Apply global block / allow rules</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<div data-policy-settings-page="security-headers" hidden="">
|
||||
<form class="card edit-form global-policy-form" id="global-security-headers-form">
|
||||
<div class="card-heading"><div><span class="small-label" data-i18n="globalPolicyScope">PUBLIC HTTP LISTENERS</span><h2 data-i18n="globalSecurityHeaders">Security headers</h2></div><span class="pill live" data-i18n="hotApply">LIVE APPLY</span></div>
|
||||
<p class="fine" data-i18n="globalHeadersSecurityHelp">Global response-header baseline for public HTTP vhosts, redirects and 404 hosts. Explicit per-vhost values override matching global headers. HSTS is sent only over HTTPS; test CSP carefully before rollout.</p>
|
||||
<section class="global-policy-section"><h3 data-i18n="vhSecurityHstsSection">HTTP Strict Transport Security (HSTS)</h3>
|
||||
<label class="check"><input id="global-hsts-enabled" type="checkbox"/><span data-i18n="vhHsts">Enable HSTS</span></label>
|
||||
<div class="global-rate-fields">
|
||||
<label><span data-i18n="vhHstsAge">Max age (seconds)</span><input id="global-hsts-age" type="number" min="0" max="63072000" required value="31536000"/></label>
|
||||
<label class="check"><input id="global-hsts-sub" type="checkbox"/><span data-i18n="vhHstsSub">Include subdomains</span></label>
|
||||
<label class="check"><input id="global-hsts-preload" type="checkbox"/><span data-i18n="vhHstsPreload">Preload</span></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="global-policy-section"><h3 data-i18n="vhSecurityHeadersSection">Response security headers</h3>
|
||||
<label class="check"><input id="global-nosniff" type="checkbox"/><span data-i18n="vhNosniff">X-Content-Type-Options: nosniff</span></label>
|
||||
<label><span data-i18n="vhFrame">X-Frame-Options</span><select id="global-frame"><option value="preserve" data-i18n="vhPreserve">Preserve origin value</option><option value="deny" data-i18n="vhDeny">DENY</option><option value="sameorigin" data-i18n="vhSameorigin">SAMEORIGIN</option></select></label>
|
||||
<label><span data-i18n="vhReferrer">Referrer-Policy</span><input id="global-referrer" maxlength="8192" spellcheck="false" placeholder="strict-origin-when-cross-origin"/></label>
|
||||
<label><span data-i18n="vhCsp">Content-Security-Policy</span><textarea id="global-csp" rows="3" maxlength="8192" spellcheck="false" placeholder="default-src 'self'"></textarea></label>
|
||||
<label><span data-i18n="vhPermissions">Permissions-Policy</span><input id="global-permissions" maxlength="8192" spellcheck="false" placeholder="camera=(), microphone=()"/></label>
|
||||
</section>
|
||||
<div class="form-actions"><button class="primary" data-i18n="globalSecurityHeadersSave" type="submit">Apply security headers</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<div data-policy-settings-page="headers" hidden="">
|
||||
<form class="card edit-form" id="headers-form">
|
||||
@@ -2447,8 +2500,9 @@
|
||||
<label data-i18n="cacheStaticExtensions" for="cache-static-extensions">Global static
|
||||
extensions</label><textarea id="cache-static-extensions" rows="5"
|
||||
spellcheck="false"></textarea>
|
||||
<p class="fine" data-i18n="cacheStaticHelp">Cookie bypass is limited to these file
|
||||
<p class="fine cache-static-guidance" data-i18n="cacheStaticHelp">Cookie bypass is limited to these file
|
||||
extensions. Response privacy rules still apply.</p>
|
||||
<h3 class="subheading cache-header-settings-heading" data-i18n="cacheHeadersSection">Cache response headers</h3>
|
||||
<div class="switch-grid"><label class="check"><input id="cache-status-header"
|
||||
type="checkbox" /><span>X-Cache: HIT / MISS /
|
||||
BYPASS</span></label><label class="check"><input id="cache-hits-header"
|
||||
@@ -2541,7 +2595,7 @@
|
||||
<p class="fine" id="transform-preset-summary">-</p>
|
||||
</div>
|
||||
<p class="fine" id="transform-memory-warning" hidden></p>
|
||||
<div class="field-grid">
|
||||
<div class="field-grid transform-limits-grid">
|
||||
<div><label data-i18n="transformMemoryBudget"
|
||||
for="transform-memory-budget">Temporary
|
||||
transform memory budget (MiB)</label><input id="transform-memory-budget"
|
||||
@@ -2554,7 +2608,8 @@
|
||||
concurrent transformations</label><input id="transform-max-inflight" min="1"
|
||||
max="1024" required="" type="number" /></div>
|
||||
</div>
|
||||
<div class="switch-grid">
|
||||
<h3 class="subheading transform-operations-title" data-i18n="transformOperationsTitle">Content transformations</h3>
|
||||
<div class="switch-grid transform-operations-grid">
|
||||
<label class="check"><input id="transform-javascript" type="checkbox" /><span
|
||||
data-i18n="transformJavascript">JavaScript minification</span></label>
|
||||
<label class="check"><input id="transform-css" type="checkbox" /><span
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
event.preventDefault(); selectPolicyTab(policyTabs[next].dataset.policySettingsTab, true);
|
||||
});
|
||||
}
|
||||
selectPolicyTab(["headers", "response"].includes(requestedTab) ? requestedTab : "user-agent");
|
||||
selectPolicyTab(["headers", "response", "security-headers", "block-allow"].includes(requestedTab) ? requestedTab : "block-allow");
|
||||
const cacheTabs = [...document.querySelectorAll("[data-cache-settings-tab]")];
|
||||
function selectCacheTab(name, focus = false) {
|
||||
if (!cacheTabs.some(button => button.dataset.cacheSettingsTab === name)) return;
|
||||
@@ -270,7 +270,7 @@
|
||||
const button = $("geoip-update-now"); button.disabled = true;
|
||||
$("geoip-status").textContent = t("geoipUpdateWorking");
|
||||
try {
|
||||
const result = await api("/api/geoip/update", { method: "POST" });
|
||||
const result = await api("/api/config/geoip/update", { method: "POST" });
|
||||
if (result.database) latestStatus = { ...(latestStatus || {}), geoip: result.database };
|
||||
render();
|
||||
toast(t(result.outcome === "updated" ? "geoipUpdateUpdated" : result.outcome === "busy" ? "geoipUpdateBusy" : "geoipUpdateCurrent"));
|
||||
@@ -301,6 +301,6 @@
|
||||
const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false;
|
||||
} catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; }
|
||||
});
|
||||
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, scrollToApiTokens, reset() { if (search) search.value = ""; applySettingsSearch(); select(validTab(rememberedTab) ? rememberedTab : "appearance", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); window.rustProxyCertificateApi?.reset(); window.rustProxyApiTokens?.reset?.(); window.rustProxyPrometheusToken?.reset?.(); $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
|
||||
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, scrollToApiTokens, reset() { if (search) search.value = ""; applySettingsSearch(); select(validTab(rememberedTab) ? rememberedTab : "appearance", false, false); selectPolicyTab("block-allow"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); window.rustProxyCertificateApi?.reset(); window.rustProxyApiTokens?.reset?.(); window.rustProxyPrometheusToken?.reset?.(); $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
|
||||
select(active); render();
|
||||
})();
|
||||
Reference in new issue
Block a user