Skip to main content

eggress_runtime/supervisor/
reload.rs

1//! Reload classification: which config changes are hot-swappable.
2//!
3//! The running accept loops capture listener behavior (protocols, auth, TLS,
4//! Shadowsocks/Trojan material, connection limits, fixed targets, UDP
5//! settings, transparent/unix topology) at startup from prepared listener
6//! state and never re-read it from the snapshot. Classification therefore
7//! rejects any material listener change so a reload never publishes a
8//! snapshot generation the data plane is known not to use.
9
10/// Result of a reload attempt.
11#[derive(Debug)]
12pub enum ReloadResult {
13    /// Reload was applied successfully.
14    Applied { generation: u64, upstreams: usize },
15    /// Reload was rejected due to unsupported changes.
16    Rejected { reason: String },
17    /// Reload failed due to a compile or build error.
18    Failed { error: String },
19}
20
21/// What is and isn't reloaded on SIGHUP:
22///
23/// **Reloaded (hot-swap, no downtime):**
24/// - Upstream chains and health config (with Arc reuse for unchanged upstreams)
25/// - Upstream groups, schedulers, and fallback policies
26/// - Routing rules and default action
27/// - Admin PAC and static content configuration
28///
29/// **NOT reloaded (requires full restart):**
30/// - Listener socket bindings (bound before readiness)
31/// - Listener socket options (`reuse_port`)
32/// - Listener protocol lists, auth material, TLS material, Shadowsocks/Trojan
33///   config, `connection_limit`, `fixed_target`, `local_bind`, and all UDP
34///   listener settings: the running accept loops and per-connection tasks
35///   clone these values from startup-prepared listener state and never
36///   re-read them from the snapshot.
37/// - Transparent/unix listener configuration
38/// - Process-level settings (log format, log level, shutdown grace)
39/// - Timeout configuration
40/// - Admin bind address
41///
42/// **UDP-specific reload semantics:**
43/// - UDP limits apply to new associations only; existing associations keep their limits.
44/// - UDP bind changes are restart-required.
45/// - UDP advertise address changes are restart-required if socket bind changes.
46/// - Route changes apply immediately to future UDP packets.
47///
48/// Classify whether a reload is supported given old and new listener
49/// configs. Returns `Ok(())` if the reload is safe, or `Err(reason)`
50/// if it should be rejected.
51pub(crate) fn classify_listeners(
52    old_listeners: &[eggress_config::compile::ListenerConfig],
53    new_listeners: &[eggress_config::compile::ListenerConfig],
54) -> Result<(), String> {
55    if old_listeners.len() != new_listeners.len() {
56        return Err(format!(
57            "listener count changed ({} -> {}); restart required",
58            old_listeners.len(),
59            new_listeners.len()
60        ));
61    }
62
63    for (old, new) in old_listeners.iter().zip(new_listeners.iter()) {
64        if old.name != new.name {
65            return Err(format!(
66                "listener name changed ('{}' -> '{}'); restart required",
67                old.name, new.name
68            ));
69        }
70        if old.bind != new.bind {
71            return Err(format!(
72                "listener bind address changed for '{}': '{}' -> '{}'; restart required",
73                old.name, old.bind, new.bind
74            ));
75        }
76        match (&old.udp, &new.udp) {
77            (Some(old_udp), Some(new_udp)) => {
78                // All UDP listener settings are captured into startup-prepared
79                // listener/relay state (`PreparedListener.udp`,
80                // `RuntimeUdpService.udp_config`, standalone relay sockets).
81                // Per-association relay tasks clone that startup state, so any
82                // material UDP change requires a restart.
83                // Socket topology group.
84                if old_udp.bind != new_udp.bind
85                    || old_udp.enabled != new_udp.enabled
86                    || old_udp.mode != new_udp.mode
87                    || old_udp.upstream_udp_bind != new_udp.upstream_udp_bind
88                {
89                    return Err(format!(
90                        "UDP listener configuration changed for '{}'; restart required",
91                        old.name
92                    ));
93                }
94                // Association limit group.
95                if old_udp.max_associations != new_udp.max_associations
96                    || old_udp.max_associations_global != new_udp.max_associations_global
97                    || old_udp.max_targets_per_association != new_udp.max_targets_per_association
98                    || old_udp.max_datagram_size != new_udp.max_datagram_size
99                {
100                    return Err(format!(
101                        "UDP listener limits changed for '{}'; restart required",
102                        old.name
103                    ));
104                }
105                // Timeout/behavior group.
106                if old_udp.idle_timeout != new_udp.idle_timeout
107                    || old_udp.target_idle_timeout != new_udp.target_idle_timeout
108                    || old_udp.upstream_connect_timeout != new_udp.upstream_connect_timeout
109                    || old_udp.client_pin != new_udp.client_pin
110                    || old_udp.allow_private_egress != new_udp.allow_private_egress
111                    || old_udp.advertise != new_udp.advertise
112                    || old_udp.fixed_target != new_udp.fixed_target
113                {
114                    return Err(format!(
115                        "UDP listener settings changed for '{}'; restart required",
116                        old.name
117                    ));
118                }
119            }
120            (None, Some(new_udp)) => {
121                return Err(format!(
122                    "UDP configuration added for '{}': '{}'; restart required",
123                    new.name, new_udp.bind
124                ));
125            }
126            (Some(_old_udp), None) => {
127                return Err(format!(
128                    "UDP configuration removed for '{}'; restart required",
129                    old.name
130                ));
131            }
132            (None, None) => {}
133        }
134
135        match (&old.transparent, &new.transparent) {
136            (Some(old_t), Some(new_t)) => {
137                // Transparent accept loops capture their configuration at
138                // startup; protocol selection included.
139                if old_t.enabled != new_t.enabled || old_t.protocol != new_t.protocol {
140                    return Err(format!(
141                        "transparent config changed for '{}': enabled {} -> {}; restart required",
142                        old.name, old_t.enabled, new_t.enabled
143                    ));
144                }
145            }
146            (None, Some(new_t)) => {
147                if new_t.enabled {
148                    return Err(format!(
149                        "transparent proxy enabled for '{}'; restart required",
150                        new.name
151                    ));
152                }
153            }
154            (Some(old_t), None) if old_t.enabled => {
155                return Err(format!(
156                    "transparent proxy configuration removed for '{}'; restart required",
157                    old.name
158                ));
159            }
160            (Some(_old_t), None) => {}
161            (None, None) => {}
162        }
163
164        match (&old.unix, &new.unix) {
165            (Some(old_u), Some(new_u)) => {
166                // Unix socket setup (bind, ownership, mode) happens once at
167                // startup; any change requires a restart.
168                if old_u.path != new_u.path
169                    || old_u.unlink_existing != new_u.unlink_existing
170                    || old_u.mode != new_u.mode
171                {
172                    return Err(format!(
173                        "unix socket path changed for '{}': '{}' -> '{}'; restart required",
174                        old.name,
175                        old_u.path.display(),
176                        new_u.path.display()
177                    ));
178                }
179            }
180            (None, Some(_new_u)) => {
181                return Err(format!(
182                    "unix socket added for '{}'; restart required",
183                    new.name
184                ));
185            }
186            (Some(_old_u), None) => {
187                return Err(format!(
188                    "unix socket removed for '{}'; restart required",
189                    old.name
190                ));
191            }
192            (None, None) => {}
193        }
194
195        // Startup-captured listener behavior: the running accept loops clone
196        // these values from `PreparedListener` at startup and never re-read
197        // them from the snapshot, so any material change requires a restart.
198        // Comparison groups are explicit (not whole-struct equality) so a
199        // future field addition gets a deliberate classification review.
200        // Socket-option group.
201        if old.reuse_port != new.reuse_port {
202            return Err(format!(
203                "listener socket options changed for '{}'; restart required",
204                old.name
205            ));
206        }
207        // Protocol dispatch group.
208        if old.protocols != new.protocols {
209            return Err(format!(
210                "listener protocols changed for '{}'; restart required",
211                old.name
212            ));
213        }
214        // Auth material group: compare presence and non-secret fields plus
215        // resolved secret presence. Values themselves are never logged.
216        match (&old.auth, &new.auth) {
217            (None, None) => {}
218            (Some(_), None) | (None, Some(_)) => {
219                return Err(format!(
220                    "listener auth presence changed for '{}'; restart required",
221                    old.name
222                ));
223            }
224            (Some(old_a), Some(new_a)) => {
225                if old_a.auth_type != new_a.auth_type
226                    || old_a.username != new_a.username
227                    || old_a.password != new_a.password
228                    || old_a.password_env != new_a.password_env
229                {
230                    return Err(format!(
231                        "listener auth material changed for '{}'; restart required",
232                        old.name
233                    ));
234                }
235            }
236        }
237        // TLS material group: certificate/key/ALPN feed the per-connection
238        // TLS acceptor built from startup state.
239        match (&old.tls, &new.tls) {
240            (None, None) => {}
241            (Some(_), None) | (None, Some(_)) => {
242                return Err(format!(
243                    "listener TLS presence changed for '{}'; restart required",
244                    old.name
245                ));
246            }
247            (Some(old_t), Some(new_t)) => {
248                if old_t.cert_pem != new_t.cert_pem
249                    || old_t.key_pem != new_t.key_pem
250                    || old_t.alpn != new_t.alpn
251                {
252                    return Err(format!(
253                        "listener TLS material changed for '{}'; restart required",
254                        old.name
255                    ));
256                }
257            }
258        }
259        // Shadowsocks/Trojan group: cloned into per-connection inbound config.
260        match (&old.shadowsocks, &new.shadowsocks) {
261            (None, None) => {}
262            (Some(_), None) | (None, Some(_)) => {
263                return Err(format!(
264                    "listener shadowsocks presence changed for '{}'; restart required",
265                    old.name
266                ));
267            }
268            (Some(old_s), Some(new_s)) => {
269                if old_s.method != new_s.method
270                    || old_s.password != new_s.password
271                    || old_s.auth_prefix != new_s.auth_prefix
272                    || old_s.plugins != new_s.plugins
273                {
274                    return Err(format!(
275                        "listener shadowsocks configuration changed for '{}'; restart required",
276                        old.name
277                    ));
278                }
279            }
280        }
281        match (&old.trojan, &new.trojan) {
282            (None, None) => {}
283            (Some(_), None) | (None, Some(_)) => {
284                return Err(format!(
285                    "listener trojan presence changed for '{}'; restart required",
286                    old.name
287                ));
288            }
289            (Some(old_t), Some(new_t)) => {
290                if old_t.password != new_t.password || old_t.fallback != new_t.fallback {
291                    return Err(format!(
292                        "listener trojan configuration changed for '{}'; restart required",
293                        old.name
294                    ));
295                }
296            }
297        }
298        // Connection-behavior group.
299        if old.connection_limit != new.connection_limit
300            || old.fixed_target != new.fixed_target
301            || old.local_bind != new.local_bind
302        {
303            return Err(format!(
304                "listener connection_limit/fixed_target/local_bind changed for '{}'; restart required",
305                old.name
306            ));
307        }
308    }
309
310    Ok(())
311}
312
313pub fn classify_reload_config(
314    old_listeners: &[eggress_config::compile::ListenerConfig],
315    old_timeouts: &eggress_config::compile::TimeoutConfig,
316    old_admin: Option<&eggress_config::compile::AdminConfig>,
317    new_config: &eggress_config::compile::RuntimeConfig,
318) -> Result<(), String> {
319    classify_listeners(old_listeners, &new_config.listeners)?;
320    if old_timeouts != &new_config.timeouts {
321        return Err("timeout configuration changed; restart required".to_string());
322    }
323
324    let old_admin_endpoint = old_admin.map(|admin| (admin.enabled, admin.bind.as_str()));
325    let new_admin_endpoint = new_config
326        .admin
327        .as_ref()
328        .map(|admin| (admin.enabled, admin.bind.as_str()));
329    if old_admin_endpoint != new_admin_endpoint {
330        return Err("admin endpoint bind configuration changed; restart required".to_string());
331    }
332
333    Ok(())
334}