Skip to content

Commit 9ba34df

Browse files
committed
Classify nested managed service reloads
1 parent fe715d7 commit 9ba34df

8 files changed

Lines changed: 377 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ behavior when the change improves security or project direction.
3232
- Make reload safety allowlist-based and require process replacement for
3333
startup-owned TLS, listener, stream, UDP, ACME, cache-purger, and tracing
3434
changes.
35+
- Classify nested managed ACME targets and managed vhost/route PHP-FPM process
36+
definitions as startup-owned while retaining snapshot reload for ordinary
37+
routing and request-time PHP policy.
3538
- Enforce trusted ownership and non-writable permissions across complete config
3639
source and sensitive-path ancestor chains, with descriptor identity and
3740
mid-read modification checks for TOML sources.

crates/fluxheim-config/src/reload.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ use crate::config::{ProxyConfig, VhostConfig};
44
#[cfg(feature = "load-balancer")]
55
use std::path::PathBuf;
66

7+
#[path = "reload_service_signatures.rs"]
8+
mod reload_service_signatures;
9+
use reload_service_signatures::{managed_acme_services, managed_php_services};
10+
711
#[derive(Debug, Clone, Eq, PartialEq)]
812
pub enum ReloadImpact {
913
Noop,
@@ -43,6 +47,7 @@ pub enum ReloadReason {
4347
TlsClientAuthChanged,
4448
ComplianceModeChanged,
4549
AcmeServiceChanged,
50+
ManagedPhpServiceChanged,
4651
AdminServiceChanged,
4752
MetricsServiceChanged,
4853
TracingServiceChanged,
@@ -88,6 +93,7 @@ impl std::fmt::Display for ReloadReason {
8893
Self::TlsClientAuthChanged => "tls-client-auth-changed",
8994
Self::ComplianceModeChanged => "compliance-mode-changed",
9095
Self::AcmeServiceChanged => "acme-service-changed",
96+
Self::ManagedPhpServiceChanged => "managed-php-service-changed",
9197
Self::AdminServiceChanged => "admin-service-changed",
9298
Self::MetricsServiceChanged => "metrics-service-changed",
9399
Self::TracingServiceChanged => "tracing-service-changed",
@@ -147,10 +153,14 @@ pub fn classify_reload(old: &Config, new: &Config) -> ReloadImpact {
147153
reasons.push(ReloadReason::ComplianceModeChanged);
148154
}
149155

150-
if old.tls.acme != new.tls.acme {
156+
if old.tls.acme != new.tls.acme || managed_acme_services(old) != managed_acme_services(new) {
151157
reasons.push(ReloadReason::AcmeServiceChanged);
152158
}
153159

160+
if managed_php_services(old) != managed_php_services(new) {
161+
reasons.push(ReloadReason::ManagedPhpServiceChanged);
162+
}
163+
154164
if old.tls.profile != new.tls.profile
155165
|| old.tls.min_protocol != new.tls.min_protocol
156166
|| old.tls.alpn != new.tls.alpn
@@ -206,8 +216,9 @@ pub fn classify_reload(old: &Config, new: &Config) -> ReloadImpact {
206216
}
207217

208218
fn only_snapshot_safe_fields_changed(old: &Config, new: &Config) -> bool {
209-
// These exhaustive destructures make future schema fields fail compilation until their
210-
// reload ownership is classified explicitly.
219+
// This exhaustive top-level destructure makes future Config fields fail compilation until
220+
// their reload ownership is classified explicitly. Nested startup-owned fields are audited
221+
// by the service-signature module.
211222
let Config {
212223
server: _,
213224
admin: _,

crates/fluxheim-config/src/reload_security_tests.rs

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ use crate::config::{Config, DownstreamProxyProtocol, TlsClientAuthConfig, TlsCli
22
use crate::reload::{ReloadImpact, ReloadReason, classify_reload};
33

44
fn assert_process_upgrade(new: Config, expected: ReloadReason) {
5+
assert_transition_requires_process_upgrade(&Config::default(), &new, expected);
6+
}
7+
8+
fn assert_transition_requires_process_upgrade(old: &Config, new: &Config, expected: ReloadReason) {
59
assert_eq!(
6-
classify_reload(&Config::default(), &new),
10+
classify_reload(old, new),
711
ReloadImpact::ProcessUpgrade {
812
reasons: vec![expected],
913
}
@@ -90,3 +94,160 @@ fn certificate_lookup_change_remains_snapshot_safe() {
9094
ReloadImpact::Snapshot
9195
);
9296
}
97+
98+
fn managed_acme_config() -> Config {
99+
toml::from_str(
100+
r#"
101+
[[vhosts]]
102+
name = "site"
103+
hosts = ["site.example.test"]
104+
105+
[vhosts.tls]
106+
enabled = true
107+
108+
[vhosts.tls.acme]
109+
enabled = true
110+
issuer = "primary"
111+
"#,
112+
)
113+
.unwrap()
114+
}
115+
116+
#[test]
117+
fn nested_acme_enablement_requires_process_upgrade() {
118+
let new = managed_acme_config();
119+
let mut old = new.clone();
120+
old.vhosts[0].tls.acme.enabled = false;
121+
122+
assert_transition_requires_process_upgrade(&old, &new, ReloadReason::AcmeServiceChanged);
123+
assert_transition_requires_process_upgrade(&new, &old, ReloadReason::AcmeServiceChanged);
124+
}
125+
126+
#[test]
127+
fn nested_acme_identity_and_targets_require_process_upgrade() {
128+
let old = managed_acme_config();
129+
let mut changes = Vec::new();
130+
131+
let mut issuer = old.clone();
132+
issuer.vhosts[0].tls.acme.issuer = Some("secondary".to_owned());
133+
changes.push(issuer);
134+
135+
let mut domains = old.clone();
136+
domains.vhosts[0].tls.acme.domains = vec!["certificate.example.test".to_owned()];
137+
changes.push(domains);
138+
139+
let mut inherited_hosts = old.clone();
140+
inherited_hosts.vhosts[0].hosts = vec!["renamed.example.test".to_owned()];
141+
changes.push(inherited_hosts);
142+
143+
let mut vhost_name = old.clone();
144+
vhost_name.vhosts[0].name = "renamed".to_owned();
145+
changes.push(vhost_name);
146+
147+
for new in changes {
148+
assert_transition_requires_process_upgrade(&old, &new, ReloadReason::AcmeServiceChanged);
149+
}
150+
}
151+
152+
fn managed_php_config() -> Config {
153+
toml::from_str(
154+
r#"
155+
[[vhosts]]
156+
name = "php"
157+
hosts = ["php.example.test"]
158+
159+
[vhosts.php]
160+
enabled = true
161+
162+
[vhosts.php.fpm]
163+
mode = "managed"
164+
php_fpm_binary = "/usr/sbin/php-fpm"
165+
socket_dir = "/run/fluxheim/php"
166+
"#,
167+
)
168+
.unwrap()
169+
}
170+
171+
#[test]
172+
fn managed_php_add_remove_requires_process_upgrade() {
173+
let new = managed_php_config();
174+
let old = Config::default();
175+
176+
assert_transition_requires_process_upgrade(&old, &new, ReloadReason::ManagedPhpServiceChanged);
177+
assert_transition_requires_process_upgrade(&new, &old, ReloadReason::ManagedPhpServiceChanged);
178+
}
179+
180+
#[test]
181+
fn managed_php_process_policy_changes_require_process_upgrade() {
182+
let old = managed_php_config();
183+
let mut changes = Vec::new();
184+
185+
let mut binary = old.clone();
186+
binary.vhosts[0].php.fpm.php_fpm_binary = Some("/opt/php/sbin/php-fpm".into());
187+
changes.push(binary);
188+
189+
let mut identity = old.clone();
190+
identity.vhosts[0].php.fpm.user = Some("www-data".to_owned());
191+
identity.vhosts[0].php.fpm.group = Some("www-data".to_owned());
192+
changes.push(identity);
193+
194+
let mut environment = old.clone();
195+
environment.vhosts[0].php.fpm.clear_env = false;
196+
changes.push(environment);
197+
198+
let mut socket_dir = old.clone();
199+
socket_dir.vhosts[0].php.fpm.socket_dir = Some("/run/fluxheim/php-next".into());
200+
changes.push(socket_dir);
201+
202+
let mut workers = old.clone();
203+
workers.vhosts[0].php.fpm.process_manager = crate::config::PhpFpmProcessManager::Dynamic;
204+
workers.vhosts[0].php.fpm.workers = 8;
205+
workers.vhosts[0].php.fpm.min_spare_servers = Some(2);
206+
workers.vhosts[0].php.fpm.max_spare_servers = Some(4);
207+
changes.push(workers);
208+
209+
for new in changes {
210+
assert_transition_requires_process_upgrade(
211+
&old,
212+
&new,
213+
ReloadReason::ManagedPhpServiceChanged,
214+
);
215+
}
216+
}
217+
218+
#[test]
219+
fn route_managed_php_addition_requires_process_upgrade() {
220+
let old = Config::default();
221+
let new: Config = toml::from_str(
222+
r#"
223+
[[vhosts]]
224+
name = "php"
225+
hosts = ["php.example.test"]
226+
227+
[[vhosts.routes]]
228+
name = "application"
229+
path_prefix = "/app"
230+
231+
[vhosts.routes.php]
232+
enabled = true
233+
234+
[vhosts.routes.php.fpm]
235+
mode = "managed"
236+
php_fpm_binary = "/usr/sbin/php-fpm"
237+
socket_dir = "/run/fluxheim/php-route"
238+
"#,
239+
)
240+
.unwrap();
241+
242+
assert_transition_requires_process_upgrade(&old, &new, ReloadReason::ManagedPhpServiceChanged);
243+
}
244+
245+
#[test]
246+
fn managed_php_request_policy_change_remains_snapshot_safe() {
247+
let old = managed_php_config();
248+
let mut new = old.clone();
249+
new.vhosts[0].php.request_timeout_secs += 1;
250+
new.vhosts[0].php.fpm.read_timeout_secs = Some(15);
251+
252+
assert_eq!(classify_reload(&old, &new), ReloadImpact::Snapshot);
253+
}

0 commit comments

Comments
 (0)