This commit is contained in:
Mateusz Gruszczyński committed 2026-10-25 17:45:57 +01:00
1 parent bd077754a9
commit ed7e3377c1
40 files changed
+1764 -365

No files matched your search

+242
View File
@@ -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(&section)?;
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(&section)?;
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, &section, 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))
}