Skip to main content

eggress_embed/
lib.rs

1//! # eggress-embed
2//!
3//! Stable Rust embed API for starting and controlling an eggress proxy in-process.
4//!
5//! This crate wraps the internal runtime, config, and server infrastructure behind
6//! a minimal, binding-friendly surface. Python bindings (PyO3) in later phases will
7//! wrap this API.
8//!
9//! ## Quick start (blocking)
10//!
11//! ```no_run
12//! use eggress_embed::{EggressService, EggressConfig};
13//!
14//! let config = EggressConfig::from_toml_str(r#"
15//!     version = 1
16//!     [[listeners]]
17//!     name = "socks"
18//!     bind = "127.0.0.1:0"
19//!     protocols = ["socks5"]
20//! "#).unwrap();
21//!
22//! let handle = EggressService::new(config).start_blocking().unwrap();
23//! let addrs = handle.bound_addresses();
24//! println!("listening on {:?}", addrs);
25//! handle.shutdown_blocking().unwrap();
26//! ```
27//!
28//! ## Quick start (async)
29//!
30//! ```no_run
31//! # tokio_test::block_on(async {
32//! use eggress_embed::{EggressService, EggressConfig};
33//!
34//! let config = EggressConfig::from_toml_str(r#"
35//!     version = 1
36//!     [[listeners]]
37//!     name = "http"
38//!     bind = "127.0.0.1:0"
39//!     protocols = ["http"]
40//! "#).unwrap();
41//!
42//! let handle = EggressService::new(config).start().await.unwrap();
43//! let status = handle.status();
44//! println!("generation: {}", status.generation);
45//! handle.shutdown().await.unwrap();
46//! # });
47//! ```
48
49mod error;
50pub mod outbound;
51
52use std::net::{IpAddr, Ipv4Addr, SocketAddr};
53use std::path::Path;
54use std::sync::atomic::Ordering;
55use std::sync::Arc;
56use std::time::Duration;
57
58pub use error::EggressError;
59
60/// Parsed and validated eggress configuration.
61///
62/// Construct via [`EggressConfig::from_toml_str`] or [`EggressConfig::from_toml_file`].
63#[derive(Clone)]
64pub struct EggressConfig {
65    source_toml: String,
66}
67
68impl EggressConfig {
69    /// Parse a TOML configuration string.
70    pub fn from_toml_str(input: &str) -> Result<Self, EggressError> {
71        let config: eggress_config::model::ConfigFile =
72            toml::from_str(input).map_err(|e| EggressError::Config(e.to_string()))?;
73
74        if let Some(version) = config.version {
75            if version != 1 {
76                return Err(EggressError::Config(format!(
77                    "unsupported config version: {version}"
78                )));
79            }
80        }
81
82        eggress_config::validate::validate_config(&config).map_err(|errors| {
83            let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
84            EggressError::Config(messages.join("; "))
85        })?;
86
87        let _inner = eggress_config::compile::compile_config(&config)
88            .map_err(|e| EggressError::Config(e.to_string()))?;
89
90        Ok(Self {
91            source_toml: input.to_string(),
92        })
93    }
94
95    /// Load and validate a TOML configuration file.
96    pub fn from_toml_file(path: impl AsRef<Path>) -> Result<Self, EggressError> {
97        let path = path.as_ref();
98        let contents = std::fs::read_to_string(path)
99            .map_err(|e| EggressError::Config(format!("failed to read {path:?}: {e}")))?;
100        Self::from_toml_str(&contents)
101    }
102
103    /// Return the original TOML source text.
104    pub fn source_toml(&self) -> &str {
105        &self.source_toml
106    }
107
108    /// Return the TOML source with credentials redacted.
109    ///
110    /// Listener auth passwords and upstream URI credentials are replaced with
111    /// `****` / `****:****@` placeholders. The result is suitable for logging
112    /// or display without leaking secrets.
113    pub fn to_redacted_toml(&self) -> Result<String, EggressError> {
114        let mut value: toml::Value =
115            toml::from_str(&self.source_toml).map_err(|e| EggressError::Config(e.to_string()))?;
116
117        redact_toml_value(&mut value);
118
119        toml::to_string_pretty(&value).map_err(|e| EggressError::Internal(e.to_string()))
120    }
121}
122
123/// Pre-start service builder.
124///
125/// Created from a validated config. Call [`.start()`](EggressService::start) (async) or
126/// [`.start_blocking()`](EggressService::start_blocking) to launch the proxy and obtain a handle.
127pub struct EggressService {
128    config: EggressConfig,
129}
130
131impl EggressService {
132    /// Create a new service from a validated config.
133    pub fn new(config: EggressConfig) -> Self {
134        Self { config }
135    }
136
137    /// Convenience: parse TOML and create a service.
138    pub fn from_toml_str(input: &str) -> Result<Self, EggressError> {
139        EggressConfig::from_toml_str(input).map(Self::new)
140    }
141
142    /// Convenience: load file and create a service.
143    pub fn from_toml_file(path: impl AsRef<Path>) -> Result<Self, EggressError> {
144        EggressConfig::from_toml_file(path).map(Self::new)
145    }
146
147    /// Start the service using a caller-provided Tokio runtime context.
148    ///
149    /// The caller must be inside a Tokio runtime. The service binds listeners,
150    /// starts health probes, and enters the event loop on a background task.
151    /// Returns once readiness is achieved or startup fails.
152    pub async fn start(self) -> Result<EggressHandle, EggressError> {
153        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
154        let config_path = write_temp_config(&self.config)?;
155        let config_path_clone = config_path.clone();
156
157        let join = tokio::task::spawn_blocking(move || -> Result<
158            (
159                Arc<eggress_runtime::RuntimeState>,
160                tokio_util::sync::CancellationToken,
161            ),
162            EggressError,
163        > {
164            let mut sup = eggress_runtime::ServiceSupervisor::start(&config_path_clone)
165                .map_err(|e| EggressError::Startup(e.to_string()))?;
166
167            let state = sup.state().clone();
168            let token = sup.shutdown_token();
169
170            let run_result = std::thread::Builder::new()
171                .name("eggress-embed-rt".into())
172                .spawn(move || sup.run())
173                .map_err(|e| EggressError::Startup(e.to_string()))?;
174
175            // Wait for readiness or failure
176            let started = std::time::Instant::now();
177            let timeout = Duration::from_secs(30);
178            loop {
179                if state.readiness.load(Ordering::Acquire) {
180                    let _ = ready_tx.send(Ok((state.clone(), token.clone())));
181                    break;
182                }
183                if started.elapsed() > timeout {
184                    token.cancel();
185                    let _ = ready_tx.send(Err(EggressError::Startup(
186                        "readiness timeout".to_string(),
187                    )));
188                    break;
189                }
190                std::thread::sleep(Duration::from_millis(5));
191            }
192
193            // Wait for the run thread to finish (shutdown)
194            match run_result.join() {
195                Ok(Ok(())) => {}
196                Ok(Err(e)) => tracing::debug!(%e, "runtime exited with error"),
197                Err(_) => tracing::debug!("runtime thread panicked"),
198            }
199
200            // Clean up temp config file
201            let _ = std::fs::remove_file(&config_path_clone);
202
203            Ok((state, token))
204        });
205
206        let (state, token) = ready_rx
207            .await
208            .map_err(|_| EggressError::Startup("startup channel dropped".into()))??;
209
210        let join = tokio::task::spawn(async move {
211            match join.await {
212                Ok(Ok(_)) => Ok(()),
213                Ok(Err(e)) => Err(EggressError::Startup(format!("startup failed: {e}"))),
214                Err(e) => Err(EggressError::Startup(format!("startup task panicked: {e}"))),
215            }
216        });
217
218        Ok(EggressHandle {
219            state,
220            token: Some(token),
221            _run_handle: None,
222            _config_path: Some(config_path),
223            _runtime_task: Some(join),
224            reload_mutex: std::sync::Mutex::new(()),
225        })
226    }
227
228    /// Start the service with a dedicated runtime thread (blocking).
229    ///
230    /// This spawns a background thread that creates a Tokio runtime and runs
231    /// the proxy. Blocks until readiness is achieved or startup fails.
232    /// Returns a handle that owns the runtime thread.
233    pub fn start_blocking(self) -> Result<EggressHandle, EggressError> {
234        let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
235        let config_path = write_temp_config(&self.config)?;
236        let config_path_clone = config_path.clone();
237
238        let _thread_handle = std::thread::Builder::new()
239            .name("eggress-embed-rt".into())
240            .spawn(move || {
241                let mut sup = match eggress_runtime::ServiceSupervisor::start(&config_path_clone) {
242                    Ok(s) => s,
243                    Err(e) => {
244                        let _ = ready_tx.send(Err(EggressError::Startup(e.to_string())));
245                        return;
246                    }
247                };
248
249                let state = sup.state().clone();
250                let token = sup.shutdown_token();
251
252                let run_handle = std::thread::Builder::new()
253                    .name("eggress-embed-run".into())
254                    .spawn(move || {
255                        if let Err(e) = sup.run() {
256                            tracing::error!("supervisor exited with error: {e}");
257                        }
258                    });
259
260                let run_handle = match run_handle {
261                    Ok(h) => h,
262                    Err(e) => {
263                        let _ = ready_tx.send(Err(EggressError::Startup(e.to_string())));
264                        return;
265                    }
266                };
267
268                // Wait for readiness
269                let started = std::time::Instant::now();
270                let timeout = Duration::from_secs(30);
271                loop {
272                    if state.readiness.load(Ordering::Acquire) {
273                        let _ = ready_tx.send(Ok((state, token, run_handle, config_path_clone)));
274                        break;
275                    }
276                    if started.elapsed() > timeout {
277                        // On timeout, cancel and clean up immediately
278                        token.cancel();
279                        match run_handle.join() {
280                            Ok(()) => {}
281                            Err(_) => tracing::debug!("runtime thread panicked"),
282                        }
283                        let _ = std::fs::remove_file(&config_path_clone);
284                        let _ =
285                            ready_tx.send(Err(EggressError::Startup("readiness timeout".into())));
286                        break;
287                    }
288                    std::thread::sleep(Duration::from_millis(5));
289                }
290            })
291            .map_err(|e| EggressError::Startup(e.to_string()))?;
292
293        let (state, token, run_handle, config_path) = ready_rx
294            .recv()
295            .map_err(|_| EggressError::Startup("startup channel dropped".into()))??;
296
297        Ok(EggressHandle {
298            state,
299            token: Some(token),
300            _run_handle: Some(run_handle),
301            _config_path: Some(config_path),
302            _runtime_task: None,
303            reload_mutex: std::sync::Mutex::new(()),
304        })
305    }
306
307    /// Start a compatibility service from the validated in-memory config.
308    ///
309    /// This variant is used by the Python `pproxy` entry point so compatibility
310    /// options such as `--auth`, `--sys`, `-d`, and `-v` reach the runtime
311    /// without going through a temporary config file or the native defaults.
312    #[cfg(feature = "pproxy-compat")]
313    pub fn start_blocking_with_compatibility_options(
314        self,
315        compatibility_options: eggress_runtime::CompatibilityOptions,
316    ) -> Result<EggressHandle, EggressError> {
317        let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
318        let source_toml = self.config.source_toml.clone();
319        let rt_config = eggress_config::validate_and_compile_toml_with_warnings(&source_toml)
320            .map(|(config, _)| config)
321            .map_err(|e| EggressError::Config(e.to_string()))?;
322
323        std::thread::Builder::new()
324            .name("eggress-embed-rt".into())
325            .spawn(move || {
326                let mut supervisor =
327                    match eggress_runtime::ServiceSupervisor::start_from_config_with_options(
328                        rt_config,
329                        None,
330                        compatibility_options,
331                    ) {
332                        Ok(supervisor) => supervisor,
333                        Err(error) => {
334                            let _ = ready_tx.send(Err(EggressError::Startup(error.to_string())));
335                            return;
336                        }
337                    };
338
339                let state = supervisor.state().clone();
340                let token = supervisor.shutdown_token();
341                let run_handle = std::thread::Builder::new()
342                    .name("eggress-embed-run".into())
343                    .spawn(move || {
344                        if let Err(error) = supervisor.run() {
345                            tracing::error!("supervisor exited with error: {error}");
346                        }
347                    });
348
349                let run_handle = match run_handle {
350                    Ok(handle) => handle,
351                    Err(error) => {
352                        let _ = ready_tx.send(Err(EggressError::Startup(error.to_string())));
353                        return;
354                    }
355                };
356
357                let started = std::time::Instant::now();
358                let timeout = Duration::from_secs(30);
359                loop {
360                    if state.readiness.load(Ordering::Acquire) {
361                        let _ = ready_tx.send(Ok((state, token, run_handle)));
362                        break;
363                    }
364                    if started.elapsed() > timeout {
365                        token.cancel();
366                        let _ = run_handle.join();
367                        let _ =
368                            ready_tx.send(Err(EggressError::Startup("readiness timeout".into())));
369                        break;
370                    }
371                    std::thread::sleep(Duration::from_millis(5));
372                }
373            })
374            .map_err(|e| EggressError::Startup(e.to_string()))?;
375
376        let (state, token, run_handle) = ready_rx
377            .recv()
378            .map_err(|_| EggressError::Startup("startup channel dropped".into()))??;
379
380        Ok(EggressHandle {
381            state,
382            token: Some(token),
383            _run_handle: Some(run_handle),
384            _config_path: None,
385            _runtime_task: None,
386            reload_mutex: std::sync::Mutex::new(()),
387        })
388    }
389}
390
391/// Handle to a running eggress service.
392///
393/// Provides access to bound addresses, status, metrics, reload, and shutdown.
394/// Dropping the handle cancels the shutdown token, initiating graceful shutdown.
395///
396/// # Thread ownership
397///
398/// The handle owns exactly one of two mutually exclusive thread models:
399///
400/// **Async path** (`start()`):
401/// - A Tokio blocking-pool thread runs the startup sequence and then blocks on
402///   `run_result.join()` for the lifetime of the service.
403/// - A dedicated OS thread (`"eggress-embed-rt"`) owns `ServiceSupervisor::run()`.
404/// - `_runtime_task` wraps the blocking task's JoinHandle as a Tokio task.
405///
406/// **Blocking path** (`start_blocking()`):
407/// - An outer OS thread (`"eggress-embed-rt"`) handles startup, sends results
408///   through a channel, and terminates.
409/// - An inner OS thread (`"eggress-embed-run"`) owns `ServiceSupervisor::run()`.
410/// - `_run_handle` holds the inner thread's JoinHandle directly.
411///
412/// # Drop behavior
413///
414/// Dropping the handle cancels the shutdown token and performs a best-effort
415/// join: the blocking path joins the run thread directly; the async path
416/// creates a throwaway Tokio runtime and awaits the task with a 5-second
417/// timeout. Explicit `shutdown()` or `shutdown_blocking()` is preferred to
418/// guarantee orderly teardown.
419pub struct EggressHandle {
420    state: Arc<eggress_runtime::RuntimeState>,
421    token: Option<tokio_util::sync::CancellationToken>,
422    _run_handle: Option<std::thread::JoinHandle<()>>,
423    _config_path: Option<String>,
424    _runtime_task: Option<tokio::task::JoinHandle<Result<(), EggressError>>>,
425    reload_mutex: std::sync::Mutex<()>,
426}
427
428impl EggressHandle {
429    /// Get the addresses the service is listening on.
430    pub fn bound_addresses(&self) -> BoundAddresses {
431        let addrs = self
432            .state
433            .listener_addrs
434            .lock()
435            .unwrap_or_else(|e| e.into_inner());
436        let admin = self
437            .state
438            .admin_local_addr
439            .lock()
440            .unwrap_or_else(|e| e.into_inner());
441        let snap = self.state.snapshot.load();
442        let listeners: Vec<ListenerAddress> = snap
443            .listeners
444            .iter()
445            .enumerate()
446            .map(|(idx, lcfg)| ListenerAddress {
447                name: lcfg.name.clone(),
448                addr: listener_addr_or_configured(&addrs, idx, &lcfg.bind),
449            })
450            .collect();
451        BoundAddresses {
452            listeners,
453            admin: *admin,
454        }
455    }
456
457    /// Get the current service status.
458    pub fn status(&self) -> ServiceStatus {
459        let snap = self.state.snapshot.load();
460        let addrs = self
461            .state
462            .listener_addrs
463            .lock()
464            .unwrap_or_else(|e| e.into_inner());
465
466        let listeners: Vec<ListenerStatus> = snap
467            .listeners
468            .iter()
469            .enumerate()
470            .map(|(idx, lcfg)| ListenerStatus {
471                name: lcfg.name.clone(),
472                bind: lcfg.bind.clone(),
473                local_addr: listener_addr_or_configured(&addrs, idx, &lcfg.bind),
474                protocols: lcfg.protocols.iter().map(|p| format!("{p}")).collect(),
475                udp_enabled: lcfg.udp.as_ref().is_some_and(|u| u.enabled),
476            })
477            .collect();
478
479        let udp_active = self
480            .state
481            .udp_metrics
482            .associations_active
483            .load(Ordering::Relaxed);
484
485        ServiceStatus {
486            generation: snap.generation,
487            readiness: self.state.readiness.load(Ordering::Relaxed),
488            active_connections: self.state.active_connections.load(Ordering::Relaxed),
489            uptime_secs: self.state.start_time.elapsed().as_secs(),
490            listener_count: snap.listeners.len(),
491            listeners,
492            udp_associations_active: udp_active,
493            upstream_count: snap.upstreams.len(),
494        }
495    }
496
497    /// Render Prometheus metrics text.
498    pub fn metrics_text(&self) -> Result<String, EggressError> {
499        Ok(self.state.metrics.render_prometheus())
500    }
501
502    /// Reload configuration from a TOML string.
503    ///
504    /// Returns the outcome of the reload attempt. On success, the generation
505    /// is incremented. On rejection, the old configuration remains active.
506    pub fn reload_toml_str(&self, input: &str) -> Result<ReloadOutcome, EggressError> {
507        let _guard = self
508            .reload_mutex
509            .lock()
510            .map_err(|_| EggressError::Reload("concurrent reload in progress".to_string()))?;
511
512        // Parse and validate the new config
513        let config: eggress_config::model::ConfigFile =
514            toml::from_str(input).map_err(|e| EggressError::Reload(e.to_string()))?;
515
516        if let Some(version) = config.version {
517            if version != 1 {
518                return Err(EggressError::Reload(format!(
519                    "unsupported config version: {version}"
520                )));
521            }
522        }
523
524        eggress_config::validate::validate_config(&config).map_err(|errors| {
525            let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
526            EggressError::Reload(messages.join("; "))
527        })?;
528
529        let new_rt_config = eggress_config::compile::compile_config(&config)
530            .map_err(|e| EggressError::Reload(e.to_string()))?;
531
532        // Classify reload
533        let prev_snapshot = self.state.snapshot.load();
534        let old_listeners = &prev_snapshot.listeners;
535        let new_listeners = &new_rt_config.listeners;
536
537        if old_listeners.len() != new_listeners.len() {
538            return Err(EggressError::Reload(format!(
539                "listener count changed ({} -> {}); restart required",
540                old_listeners.len(),
541                new_listeners.len()
542            )));
543        }
544
545        for (old, new) in old_listeners.iter().zip(new_listeners.iter()) {
546            if old.name != new.name {
547                return Err(EggressError::Reload(format!(
548                    "listener name changed ('{}' -> '{}'); restart required",
549                    old.name, new.name
550                )));
551            }
552            if old.bind != new.bind {
553                return Err(EggressError::Reload(format!(
554                    "listener bind changed for '{}'; restart required",
555                    old.name
556                )));
557            }
558        }
559
560        let prev_ref: Option<&eggress_runtime::CompiledRuntimeSnapshot> = Some(&prev_snapshot);
561        let new_snapshot =
562            eggress_runtime::snapshot::compile_runtime_snapshot(&new_rt_config, prev_ref)
563                .map_err(|e| EggressError::Reload(format!("snapshot build: {e}")))?;
564
565        let gen = new_snapshot.generation;
566        let upstreams = new_snapshot.upstreams.len();
567
568        // Snapshot must be published before the router swap. Readers that observe
569        // the new generation via `snapshot.load()` pull the router from that
570        // same snapshot Arc, so any reader seeing the new generation also sees
571        // the router that belongs to it.
572        let new_snapshot = Arc::new(new_snapshot);
573        self.state.snapshot.store(new_snapshot.clone());
574        self.state.routing.swap_arc(new_snapshot.router.clone());
575
576        self.state.metrics.set_config_generation(gen);
577        self.state.metrics.record_reload(true);
578
579        Ok(ReloadOutcome::Applied {
580            generation: gen,
581            upstreams,
582        })
583    }
584
585    /// Reload configuration from a file.
586    pub fn reload_toml_file(&self, path: impl AsRef<Path>) -> Result<ReloadOutcome, EggressError> {
587        let path = path.as_ref();
588        let contents = std::fs::read_to_string(path)
589            .map_err(|e| EggressError::Reload(format!("failed to read {path:?}: {e}")))?;
590        self.reload_toml_str(&contents)
591    }
592
593    /// Initiate graceful shutdown.
594    pub async fn shutdown(mut self) -> Result<(), EggressError> {
595        if let Some(token) = self.token.take() {
596            token.cancel();
597        }
598        if let Some(task) = self._runtime_task.take() {
599            let _ = task.await;
600        }
601        if let Some(jh) = self._run_handle.take() {
602            let _ = tokio::task::spawn_blocking(move || {
603                let _ = jh.join();
604            })
605            .await;
606        }
607        if let Some(path) = self._config_path.take() {
608            let _ = std::fs::remove_file(&path);
609        }
610        Ok(())
611    }
612
613    /// Initiate graceful shutdown (blocking).
614    pub fn shutdown_blocking(mut self) -> Result<(), EggressError> {
615        if let Some(token) = self.token.take() {
616            token.cancel();
617        }
618        if let Some(jh) = self._run_handle.take() {
619            let _ = jh.join();
620        }
621        if let Some(task) = self._runtime_task.take() {
622            let rt = tokio::runtime::Runtime::new()
623                .map_err(|e| EggressError::Shutdown(e.to_string()))?;
624            rt.block_on(async {
625                let _ = task.await;
626            });
627        }
628        if let Some(path) = self._config_path.take() {
629            let _ = std::fs::remove_file(&path);
630        }
631        Ok(())
632    }
633}
634
635impl Drop for EggressHandle {
636    /// Cancel the shutdown token and best-effort join the supervisor.
637    ///
638    /// This is a fallback for callers who do not call `shutdown()` explicitly.
639    /// The async path creates a throwaway Tokio runtime to await the task with
640    /// a 5-second timeout; if the timeout expires, the task is abandoned.
641    /// Prefer explicit `shutdown()` or `shutdown_blocking()` for guaranteed
642    /// orderly teardown.
643    fn drop(&mut self) {
644        if let Some(token) = self.token.take() {
645            token.cancel();
646        }
647        if let Some(jh) = self._run_handle.take() {
648            let _ = jh.join();
649        }
650        if let Some(task) = self._runtime_task.take() {
651            let rt = tokio::runtime::Runtime::new().ok();
652            if let Some(rt) = rt {
653                rt.block_on(async {
654                    let _ = tokio::time::timeout(Duration::from_secs(5), task).await;
655                });
656            }
657        }
658        if let Some(path) = self._config_path.take() {
659            let _ = std::fs::remove_file(&path);
660        }
661    }
662}
663
664fn listener_addr_or_configured(
665    bound_addrs: &[Option<SocketAddr>],
666    idx: usize,
667    configured_bind: &str,
668) -> SocketAddr {
669    bound_addrs
670        .get(idx)
671        .and_then(|a| *a)
672        .or_else(|| configured_bind.parse().ok())
673        .unwrap_or_else(default_listener_addr)
674}
675
676fn default_listener_addr() -> SocketAddr {
677    SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)
678}
679
680/// Addresses the service is listening on.
681#[derive(Debug, Clone)]
682pub struct BoundAddresses {
683    /// TCP listener addresses.
684    pub listeners: Vec<ListenerAddress>,
685    /// Admin server address (if enabled).
686    pub admin: Option<std::net::SocketAddr>,
687}
688
689impl BoundAddresses {
690    /// Look up a listener by name.
691    pub fn listener(&self, name: &str) -> Option<std::net::SocketAddr> {
692        self.listeners
693            .iter()
694            .find(|l| l.name == name)
695            .map(|l| l.addr)
696    }
697}
698
699/// A single listener's bound address.
700#[derive(Debug, Clone)]
701pub struct ListenerAddress {
702    /// Listener name from config.
703    pub name: String,
704    /// Bound socket address.
705    pub addr: std::net::SocketAddr,
706}
707
708/// Detailed status of a single listener.
709#[derive(Debug, Clone)]
710pub struct ListenerStatus {
711    /// Listener name from config.
712    pub name: String,
713    /// Configured bind address.
714    pub bind: String,
715    /// Actual bound socket address (reflects port-0 resolution).
716    pub local_addr: std::net::SocketAddr,
717    /// Protocols served by this listener.
718    pub protocols: Vec<String>,
719    /// Whether UDP relay is enabled on this listener.
720    pub udp_enabled: bool,
721}
722
723/// Current service status.
724#[derive(Debug, Clone)]
725pub struct ServiceStatus {
726    /// Current configuration generation (increments on reload).
727    pub generation: u64,
728    /// Whether the service is ready to accept connections.
729    pub readiness: bool,
730    /// Number of active connections.
731    pub active_connections: u64,
732    /// Uptime in seconds since the service started.
733    pub uptime_secs: u64,
734    /// Number of configured listeners.
735    pub listener_count: usize,
736    /// Detailed status for each listener.
737    pub listeners: Vec<ListenerStatus>,
738    /// Number of active UDP associations.
739    pub udp_associations_active: u64,
740    /// Number of configured upstreams.
741    pub upstream_count: usize,
742}
743
744/// Outcome of a configuration reload attempt.
745#[derive(Debug)]
746pub enum ReloadOutcome {
747    /// Reload was applied successfully.
748    Applied {
749        /// New generation number.
750        generation: u64,
751        /// Number of upstreams in the new config.
752        upstreams: usize,
753    },
754}
755
756/// Well-known keys that hold raw secrets and must always be redacted.
757const REDACTED_SECRET_KEYS: &[&str] = &[
758    "password",
759    "password_env",
760    "secret",
761    "secret_ref",
762    "token",
763    "api_key",
764    "apikey",
765    "credentials",
766];
767
768/// Redact credential fields in a dynamic TOML value tree.
769///
770/// Walks the tree generically rather than only enumerating known paths:
771/// - Any string whose key matches a known credential-bearing name is
772///   replaced with `****`.
773/// - Any string that looks like a proxy URI (`scheme://...`) is passed
774///   through [`redact_uri`] so `user:pass@` and `user@` authorities are
775///   stripped. This covers `upstreams[].uri`, per-hop credentials, PAC
776///   fields, and any future field that embeds a proxy URI.
777fn redact_toml_value(value: &mut toml::Value) {
778    redact_toml_value_inner(value);
779}
780
781fn redact_toml_value_inner(value: &mut toml::Value) {
782    match value {
783        toml::Value::Table(table) => {
784            for (key, val) in table.iter_mut() {
785                let lkey = key.to_ascii_lowercase();
786                if REDACTED_SECRET_KEYS.iter().any(|k| lkey == *k) {
787                    if let toml::Value::String(_) = val {
788                        *val = toml::Value::String("****".to_string());
789                        continue;
790                    }
791                }
792                redact_toml_value_inner(val);
793            }
794        }
795        toml::Value::Array(items) => {
796            for item in items.iter_mut() {
797                redact_toml_value_inner(item);
798            }
799        }
800        toml::Value::String(s) if looks_like_proxy_uri(s) => {
801            *s = redact_uri(s);
802        }
803        _ => {}
804    }
805}
806
807/// Heuristic: a string is treated as a proxy URI if it starts with
808/// `scheme://` where `scheme` is one of the eggress-supported schemes.
809fn looks_like_proxy_uri(s: &str) -> bool {
810    let Some(colon) = s.find("://") else {
811        return false;
812    };
813    let scheme = &s[..colon];
814    matches!(
815        scheme,
816        "socks5"
817            | "socks4"
818            | "http"
819            | "https"
820            | "ss"
821            | "trojan"
822            | "h2"
823            | "ws"
824            | "wss"
825            | "raw"
826            | "tunnel"
827            | "redir"
828            | "unix"
829    )
830}
831
832/// Redact credentials embedded in a proxy URI.
833///
834/// Transforms `proto://user:pass@host:port` into `proto://****:****@host:port`.
835/// Also redacts username-only authorities (`proto://user@host:port`) so that
836/// bare usernames never leak into diagnostic or `redacted_*` output.
837/// If no `userinfo` is present, the URI is returned unchanged.
838///
839/// The userinfo separator is the LAST unbracketed `@` after the scheme;
840/// a raw password containing `@` must not be treated as a separator.
841fn redact_uri(uri: &str) -> String {
842    if let Some(scheme_end) = uri.find("://") {
843        let rest = &uri[scheme_end + 3..];
844        // Find LAST unbracketed '@' so a raw '@' in the password is preserved.
845        let mut last_at: Option<usize> = None;
846        let mut bracket_depth = 0u32;
847        for (i, c) in rest.char_indices() {
848            match c {
849                '[' => bracket_depth += 1,
850                ']' => bracket_depth = bracket_depth.saturating_sub(1),
851                '@' if bracket_depth == 0 => last_at = Some(i),
852                _ => {}
853            }
854        }
855        if let Some(at_pos) = last_at {
856            let authority_after = &rest[at_pos + 1..];
857            return format!("{}://****:****@{}", &uri[..scheme_end], authority_after);
858        }
859    }
860    uri.to_string()
861}
862
863/// Write config to a temporary file for the supervisor.
864fn write_temp_config(config: &EggressConfig) -> Result<String, EggressError> {
865    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
866    let id = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
867    let dir = std::env::temp_dir();
868    let file_name = format!("eggress-embed-{}-{id}.toml", std::process::id());
869    let path = dir.join(&file_name);
870    std::fs::write(&path, &config.source_toml)
871        .map_err(|e| EggressError::Config(format!("failed to write temp config: {e}")))?;
872    Ok(path.to_string_lossy().into_owned())
873}
874
875#[cfg(test)]
876mod tests {
877    use std::net::SocketAddr;
878
879    use super::{default_listener_addr, listener_addr_or_configured};
880
881    #[test]
882    fn listener_addr_prefers_bound_address() {
883        let bound: SocketAddr = "127.0.0.1:1234".parse().unwrap();
884
885        assert_eq!(
886            listener_addr_or_configured(&[Some(bound)], 0, "127.0.0.1:5678"),
887            bound
888        );
889    }
890
891    #[test]
892    fn listener_addr_falls_back_to_configured_bind() {
893        let configured: SocketAddr = "127.0.0.1:5678".parse().unwrap();
894
895        assert_eq!(
896            listener_addr_or_configured(&[], 0, "127.0.0.1:5678"),
897            configured
898        );
899    }
900
901    #[test]
902    fn listener_addr_uses_default_for_invalid_configured_bind() {
903        assert_eq!(
904            listener_addr_or_configured(&[], 0, "not an address"),
905            default_listener_addr()
906        );
907    }
908}