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 = match eggress_runtime::ServiceSupervisor::start(&config_path_clone) {
165                Ok(sup) => sup,
166                Err(error) => {
167                    let _ = std::fs::remove_file(&config_path_clone);
168                    return Err(EggressError::Startup(error.to_string()));
169                }
170            };
171
172            let state = sup.state().clone();
173            let token = sup.shutdown_token();
174
175            let run_result = std::thread::Builder::new()
176                .name("eggress-embed-rt".into())
177                .spawn(move || sup.run())
178                .map_err(|error| {
179                    let _ = std::fs::remove_file(&config_path_clone);
180                    EggressError::Startup(error.to_string())
181                })?;
182
183            // Wait for readiness or failure
184            let started = std::time::Instant::now();
185            let timeout = Duration::from_secs(30);
186            loop {
187                if state.readiness.load(Ordering::Acquire) {
188                    let _ = ready_tx.send(Ok((state.clone(), token.clone())));
189                    break;
190                }
191                if started.elapsed() > timeout {
192                    token.cancel();
193                    let _ = std::fs::remove_file(&config_path_clone);
194                    let _ = ready_tx.send(Err(EggressError::Startup(
195                        "readiness timeout".to_string(),
196                    )));
197                    break;
198                }
199                std::thread::sleep(Duration::from_millis(5));
200            }
201
202            // Wait for the run thread to finish (shutdown)
203            match run_result.join() {
204                Ok(Ok(())) => {}
205                Ok(Err(e)) => tracing::debug!(%e, "runtime exited with error"),
206                Err(_) => tracing::debug!("runtime thread panicked"),
207            }
208
209            // Clean up temp config file
210            let _ = std::fs::remove_file(&config_path_clone);
211
212            Ok((state, token))
213        });
214
215        let (state, token) = ready_rx
216            .await
217            .map_err(|_| EggressError::Startup("startup channel dropped".into()))??;
218
219        let join = tokio::task::spawn(async move {
220            match join.await {
221                Ok(Ok(_)) => Ok(()),
222                Ok(Err(e)) => Err(EggressError::Startup(format!("startup failed: {e}"))),
223                Err(e) => Err(EggressError::Startup(format!("startup task panicked: {e}"))),
224            }
225        });
226
227        Ok(EggressHandle {
228            state,
229            token: Some(token),
230            _run_handle: None,
231            _config_path: Some(config_path),
232            _runtime_task: Some(join),
233            reload_mutex: std::sync::Mutex::new(()),
234        })
235    }
236
237    /// Start the service with a dedicated runtime thread (blocking).
238    ///
239    /// This spawns a background thread that creates a Tokio runtime and runs
240    /// the proxy. Blocks until readiness is achieved or startup fails.
241    /// Returns a handle that owns the runtime thread.
242    pub fn start_blocking(self) -> Result<EggressHandle, EggressError> {
243        let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
244        let config_path = write_temp_config(&self.config)?;
245        let config_path_clone = config_path.clone();
246
247        let _thread_handle = std::thread::Builder::new()
248            .name("eggress-embed-rt".into())
249            .spawn(move || {
250                let mut sup = match eggress_runtime::ServiceSupervisor::start(&config_path_clone) {
251                    Ok(s) => s,
252                    Err(e) => {
253                        let _ = std::fs::remove_file(&config_path_clone);
254                        let _ = ready_tx.send(Err(EggressError::Startup(e.to_string())));
255                        return;
256                    }
257                };
258
259                let state = sup.state().clone();
260                let token = sup.shutdown_token();
261
262                let run_handle = std::thread::Builder::new()
263                    .name("eggress-embed-run".into())
264                    .spawn(move || {
265                        if let Err(e) = sup.run() {
266                            tracing::error!("supervisor exited with error: {e}");
267                        }
268                    });
269
270                let run_handle = match run_handle {
271                    Ok(h) => h,
272                    Err(e) => {
273                        let _ = std::fs::remove_file(&config_path_clone);
274                        let _ = ready_tx.send(Err(EggressError::Startup(e.to_string())));
275                        return;
276                    }
277                };
278
279                // Wait for readiness
280                let started = std::time::Instant::now();
281                let timeout = Duration::from_secs(30);
282                loop {
283                    if state.readiness.load(Ordering::Acquire) {
284                        let _ = ready_tx.send(Ok((state, token, run_handle, config_path_clone)));
285                        break;
286                    }
287                    if started.elapsed() > timeout {
288                        // On timeout, cancel and clean up immediately
289                        token.cancel();
290                        let _ = std::fs::remove_file(&config_path_clone);
291                        match run_handle.join() {
292                            Ok(()) => {}
293                            Err(_) => tracing::debug!("runtime thread panicked"),
294                        }
295                        let _ =
296                            ready_tx.send(Err(EggressError::Startup("readiness timeout".into())));
297                        break;
298                    }
299                    std::thread::sleep(Duration::from_millis(5));
300                }
301            })
302            .map_err(|e| {
303                let _ = std::fs::remove_file(&config_path);
304                EggressError::Startup(e.to_string())
305            })?;
306
307        let (state, token, run_handle, config_path) = ready_rx
308            .recv()
309            .map_err(|_| EggressError::Startup("startup channel dropped".into()))??;
310
311        Ok(EggressHandle {
312            state,
313            token: Some(token),
314            _run_handle: Some(run_handle),
315            _config_path: Some(config_path),
316            _runtime_task: None,
317            reload_mutex: std::sync::Mutex::new(()),
318        })
319    }
320
321    /// Start a compatibility service from the validated in-memory config.
322    ///
323    /// This variant is used by the Python `pproxy` entry point so compatibility
324    /// options such as `--auth`, `--sys`, `-d`, and `-v` reach the runtime
325    /// without going through a temporary config file or the native defaults.
326    #[cfg(feature = "pproxy-compat")]
327    pub fn start_blocking_with_compatibility_options(
328        self,
329        compatibility_options: eggress_runtime::CompatibilityOptions,
330    ) -> Result<EggressHandle, EggressError> {
331        let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
332        let source_toml = self.config.source_toml.clone();
333        let rt_config = eggress_config::validate_and_compile_toml_with_warnings(&source_toml)
334            .map(|(config, _)| config)
335            .map_err(|e| EggressError::Config(e.to_string()))?;
336
337        std::thread::Builder::new()
338            .name("eggress-embed-rt".into())
339            .spawn(move || {
340                let mut supervisor =
341                    match eggress_runtime::ServiceSupervisor::start_from_config_with_options(
342                        rt_config,
343                        None,
344                        compatibility_options,
345                    ) {
346                        Ok(supervisor) => supervisor,
347                        Err(error) => {
348                            let _ = ready_tx.send(Err(EggressError::Startup(error.to_string())));
349                            return;
350                        }
351                    };
352
353                let state = supervisor.state().clone();
354                let token = supervisor.shutdown_token();
355                let run_handle = std::thread::Builder::new()
356                    .name("eggress-embed-run".into())
357                    .spawn(move || {
358                        if let Err(error) = supervisor.run() {
359                            tracing::error!("supervisor exited with error: {error}");
360                        }
361                    });
362
363                let run_handle = match run_handle {
364                    Ok(handle) => handle,
365                    Err(error) => {
366                        let _ = ready_tx.send(Err(EggressError::Startup(error.to_string())));
367                        return;
368                    }
369                };
370
371                let started = std::time::Instant::now();
372                let timeout = Duration::from_secs(30);
373                loop {
374                    if state.readiness.load(Ordering::Acquire) {
375                        let _ = ready_tx.send(Ok((state, token, run_handle)));
376                        break;
377                    }
378                    if started.elapsed() > timeout {
379                        token.cancel();
380                        let _ = run_handle.join();
381                        let _ =
382                            ready_tx.send(Err(EggressError::Startup("readiness timeout".into())));
383                        break;
384                    }
385                    std::thread::sleep(Duration::from_millis(5));
386                }
387            })
388            .map_err(|e| EggressError::Startup(e.to_string()))?;
389
390        let (state, token, run_handle) = ready_rx
391            .recv()
392            .map_err(|_| EggressError::Startup("startup channel dropped".into()))??;
393
394        Ok(EggressHandle {
395            state,
396            token: Some(token),
397            _run_handle: Some(run_handle),
398            _config_path: None,
399            _runtime_task: None,
400            reload_mutex: std::sync::Mutex::new(()),
401        })
402    }
403}
404
405/// Handle to a running eggress service.
406///
407/// Provides access to bound addresses, status, metrics, reload, and shutdown.
408/// Dropping the handle cancels the shutdown token, initiating graceful shutdown.
409///
410/// # Thread ownership
411///
412/// The handle owns exactly one of two mutually exclusive thread models:
413///
414/// **Async path** (`start()`):
415/// - A Tokio blocking-pool thread runs the startup sequence and then blocks on
416///   `run_result.join()` for the lifetime of the service.
417/// - A dedicated OS thread (`"eggress-embed-rt"`) owns `ServiceSupervisor::run()`.
418/// - `_runtime_task` wraps the blocking task's JoinHandle as a Tokio task.
419///
420/// **Blocking path** (`start_blocking()`):
421/// - An outer OS thread (`"eggress-embed-rt"`) handles startup, sends results
422///   through a channel, and terminates.
423/// - An inner OS thread (`"eggress-embed-run"`) owns `ServiceSupervisor::run()`.
424/// - `_run_handle` holds the inner thread's JoinHandle directly.
425///
426/// # Drop behavior
427///
428/// Dropping the handle cancels the shutdown token and performs a best-effort
429/// join: the blocking path joins the run thread directly; the async path
430/// creates a throwaway Tokio runtime and awaits the task with a 5-second
431/// timeout. Explicit `shutdown()` or `shutdown_blocking()` is preferred to
432/// guarantee orderly teardown.
433pub struct EggressHandle {
434    state: Arc<eggress_runtime::RuntimeState>,
435    token: Option<tokio_util::sync::CancellationToken>,
436    _run_handle: Option<std::thread::JoinHandle<()>>,
437    _config_path: Option<String>,
438    _runtime_task: Option<tokio::task::JoinHandle<Result<(), EggressError>>>,
439    reload_mutex: std::sync::Mutex<()>,
440}
441
442impl EggressHandle {
443    /// Get the addresses the service is listening on.
444    pub fn bound_addresses(&self) -> BoundAddresses {
445        let addrs = self
446            .state
447            .listener_addrs
448            .lock()
449            .unwrap_or_else(|e| e.into_inner());
450        let admin = self
451            .state
452            .admin_local_addr
453            .lock()
454            .unwrap_or_else(|e| e.into_inner());
455        let snap = self.state.snapshot.load();
456        let listeners: Vec<ListenerAddress> = snap
457            .listeners
458            .iter()
459            .enumerate()
460            .map(|(idx, lcfg)| ListenerAddress {
461                name: lcfg.name.clone(),
462                addr: listener_addr_or_configured(&addrs, idx, &lcfg.bind),
463            })
464            .collect();
465        BoundAddresses {
466            listeners,
467            admin: *admin,
468        }
469    }
470
471    /// Get the current service status.
472    pub fn status(&self) -> ServiceStatus {
473        let snap = self.state.snapshot.load();
474        let addrs = self
475            .state
476            .listener_addrs
477            .lock()
478            .unwrap_or_else(|e| e.into_inner());
479
480        let listeners: Vec<ListenerStatus> = snap
481            .listeners
482            .iter()
483            .enumerate()
484            .map(|(idx, lcfg)| ListenerStatus {
485                name: lcfg.name.clone(),
486                bind: lcfg.bind.clone(),
487                local_addr: listener_addr_or_configured(&addrs, idx, &lcfg.bind),
488                protocols: lcfg.protocols.iter().map(|p| format!("{p}")).collect(),
489                udp_enabled: lcfg.udp.as_ref().is_some_and(|u| u.enabled),
490            })
491            .collect();
492
493        let udp_active = self
494            .state
495            .udp_metrics
496            .associations_active
497            .load(Ordering::Relaxed);
498
499        ServiceStatus {
500            generation: snap.generation,
501            readiness: self.state.readiness.load(Ordering::Relaxed),
502            active_connections: self.state.active_connections.load(Ordering::Relaxed),
503            uptime_secs: self.state.start_time.elapsed().as_secs(),
504            listener_count: snap.listeners.len(),
505            listeners,
506            udp_associations_active: udp_active,
507            upstream_count: snap.upstreams.len(),
508        }
509    }
510
511    /// Render Prometheus metrics text.
512    pub fn metrics_text(&self) -> Result<String, EggressError> {
513        Ok(self.state.metrics.render_prometheus())
514    }
515
516    /// Reload configuration from a TOML string.
517    ///
518    /// Returns the outcome of the reload attempt. On success, the generation
519    /// is incremented. On rejection, the old configuration remains active.
520    pub fn reload_toml_str(&self, input: &str) -> Result<ReloadOutcome, EggressError> {
521        let _guard = self
522            .reload_mutex
523            .lock()
524            .map_err(|_| EggressError::Reload("concurrent reload in progress".to_string()))?;
525
526        // Parse and validate the new config
527        let config: eggress_config::model::ConfigFile =
528            toml::from_str(input).map_err(|e| EggressError::Reload(e.to_string()))?;
529
530        if let Some(version) = config.version {
531            if version != 1 {
532                return Err(EggressError::Reload(format!(
533                    "unsupported config version: {version}"
534                )));
535            }
536        }
537
538        eggress_config::validate::validate_config(&config).map_err(|errors| {
539            let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
540            EggressError::Reload(messages.join("; "))
541        })?;
542
543        let new_rt_config = eggress_config::compile::compile_config(&config)
544            .map_err(|e| EggressError::Reload(e.to_string()))?;
545
546        // Classify reload using the same topology and endpoint rules as the
547        // file-backed runtime reload path.
548        let prev_snapshot = self.state.snapshot.load();
549        eggress_runtime::classify_reload_config(
550            &prev_snapshot.listeners,
551            &prev_snapshot.timeouts,
552            prev_snapshot.admin.as_ref(),
553            &new_rt_config,
554        )
555        .map_err(EggressError::Reload)?;
556
557        let prev_ref: Option<&eggress_runtime::CompiledRuntimeSnapshot> = Some(&prev_snapshot);
558        let new_snapshot =
559            eggress_runtime::snapshot::compile_runtime_snapshot(&new_rt_config, prev_ref)
560                .map_err(|e| EggressError::Reload(format!("snapshot build: {e}")))?;
561
562        let gen = new_snapshot.generation;
563        let upstreams = new_snapshot.upstreams.len();
564
565        // Snapshot must be published before the router swap. Readers that observe
566        // the new generation via `snapshot.load()` pull the router from that
567        // same snapshot Arc, so any reader seeing the new generation also sees
568        // the router that belongs to it.
569        let new_snapshot = Arc::new(new_snapshot);
570        self.state.snapshot.store(new_snapshot.clone());
571        self.state.routing.swap_arc(new_snapshot.router.clone());
572        self.state.restart_health_probes();
573
574        self.state.metrics.set_config_generation(gen);
575        self.state.metrics.record_reload(true);
576
577        Ok(ReloadOutcome::Applied {
578            generation: gen,
579            upstreams,
580        })
581    }
582
583    /// Reload configuration from a file.
584    pub fn reload_toml_file(&self, path: impl AsRef<Path>) -> Result<ReloadOutcome, EggressError> {
585        let path = path.as_ref();
586        let contents = std::fs::read_to_string(path)
587            .map_err(|e| EggressError::Reload(format!("failed to read {path:?}: {e}")))?;
588        self.reload_toml_str(&contents)
589    }
590
591    /// Cancel the runtime's shutdown token without joining the supervisor.
592    ///
593    /// Best-effort teardown for finalizers that must not synchronously join
594    /// the service thread: listeners and background tasks stop in the
595    /// background while the handle itself may be abandoned.
596    pub fn cancel(&self) {
597        if let Some(token) = self.token.as_ref() {
598            token.cancel();
599        }
600    }
601
602    /// Cancel the runtime and remove the temporary config without joining it.
603    ///
604    /// This is intended for finalizers that must abandon the handle after
605    /// cancellation without retaining credentials in a temporary file.
606    pub fn cancel_and_cleanup(&mut self) {
607        self.cancel();
608        if let Some(path) = self._config_path.take() {
609            let _ = std::fs::remove_file(&path);
610        }
611    }
612
613    /// Initiate graceful shutdown.
614    pub async fn shutdown(mut self) -> Result<(), EggressError> {
615        if let Some(token) = self.token.take() {
616            token.cancel();
617        }
618        if let Some(task) = self._runtime_task.take() {
619            let _ = task.await;
620        }
621        if let Some(jh) = self._run_handle.take() {
622            let _ = tokio::task::spawn_blocking(move || {
623                let _ = jh.join();
624            })
625            .await;
626        }
627        if let Some(path) = self._config_path.take() {
628            let _ = std::fs::remove_file(&path);
629        }
630        Ok(())
631    }
632
633    /// Initiate graceful shutdown (blocking).
634    pub fn shutdown_blocking(mut self) -> Result<(), EggressError> {
635        if let Some(token) = self.token.take() {
636            token.cancel();
637        }
638        if let Some(jh) = self._run_handle.take() {
639            let _ = jh.join();
640        }
641        if let Some(task) = self._runtime_task.take() {
642            let rt = tokio::runtime::Runtime::new()
643                .map_err(|e| EggressError::Shutdown(e.to_string()))?;
644            rt.block_on(async {
645                let _ = task.await;
646            });
647        }
648        if let Some(path) = self._config_path.take() {
649            let _ = std::fs::remove_file(&path);
650        }
651        Ok(())
652    }
653}
654
655impl Drop for EggressHandle {
656    /// Cancel the shutdown token and best-effort join the supervisor.
657    ///
658    /// This is a fallback for callers who do not call `shutdown()` explicitly.
659    /// The async path creates a throwaway Tokio runtime to await the task with
660    /// a 5-second timeout; if the timeout expires, the task is abandoned.
661    /// Prefer explicit `shutdown()` or `shutdown_blocking()` for guaranteed
662    /// orderly teardown.
663    fn drop(&mut self) {
664        if let Some(token) = self.token.take() {
665            token.cancel();
666        }
667        if let Some(jh) = self._run_handle.take() {
668            let _ = jh.join();
669        }
670        if let Some(task) = self._runtime_task.take() {
671            let rt = tokio::runtime::Runtime::new().ok();
672            if let Some(rt) = rt {
673                rt.block_on(async {
674                    let _ = tokio::time::timeout(Duration::from_secs(5), task).await;
675                });
676            }
677        }
678        if let Some(path) = self._config_path.take() {
679            let _ = std::fs::remove_file(&path);
680        }
681    }
682}
683
684fn listener_addr_or_configured(
685    bound_addrs: &[Option<SocketAddr>],
686    idx: usize,
687    configured_bind: &str,
688) -> SocketAddr {
689    bound_addrs
690        .get(idx)
691        .and_then(|a| *a)
692        .or_else(|| configured_bind.parse().ok())
693        .unwrap_or_else(default_listener_addr)
694}
695
696fn default_listener_addr() -> SocketAddr {
697    SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)
698}
699
700/// Addresses the service is listening on.
701#[derive(Debug, Clone)]
702pub struct BoundAddresses {
703    /// TCP listener addresses.
704    pub listeners: Vec<ListenerAddress>,
705    /// Admin server address (if enabled).
706    pub admin: Option<std::net::SocketAddr>,
707}
708
709impl BoundAddresses {
710    /// Look up a listener by name.
711    pub fn listener(&self, name: &str) -> Option<std::net::SocketAddr> {
712        self.listeners
713            .iter()
714            .find(|l| l.name == name)
715            .map(|l| l.addr)
716    }
717}
718
719/// A single listener's bound address.
720#[derive(Debug, Clone)]
721pub struct ListenerAddress {
722    /// Listener name from config.
723    pub name: String,
724    /// Bound socket address.
725    pub addr: std::net::SocketAddr,
726}
727
728/// Detailed status of a single listener.
729#[derive(Debug, Clone)]
730pub struct ListenerStatus {
731    /// Listener name from config.
732    pub name: String,
733    /// Configured bind address.
734    pub bind: String,
735    /// Actual bound socket address (reflects port-0 resolution).
736    pub local_addr: std::net::SocketAddr,
737    /// Protocols served by this listener.
738    pub protocols: Vec<String>,
739    /// Whether UDP relay is enabled on this listener.
740    pub udp_enabled: bool,
741}
742
743/// Current service status.
744#[derive(Debug, Clone)]
745pub struct ServiceStatus {
746    /// Current configuration generation (increments on reload).
747    pub generation: u64,
748    /// Whether the service is ready to accept connections.
749    pub readiness: bool,
750    /// Number of active connections.
751    pub active_connections: u64,
752    /// Uptime in seconds since the service started.
753    pub uptime_secs: u64,
754    /// Number of configured listeners.
755    pub listener_count: usize,
756    /// Detailed status for each listener.
757    pub listeners: Vec<ListenerStatus>,
758    /// Number of active UDP associations.
759    pub udp_associations_active: u64,
760    /// Number of configured upstreams.
761    pub upstream_count: usize,
762}
763
764/// Outcome of a configuration reload attempt.
765#[derive(Debug)]
766pub enum ReloadOutcome {
767    /// Reload was applied successfully.
768    Applied {
769        /// New generation number.
770        generation: u64,
771        /// Number of upstreams in the new config.
772        upstreams: usize,
773    },
774}
775
776/// Well-known keys that hold raw secrets and must always be redacted.
777const REDACTED_SECRET_KEYS: &[&str] = &[
778    "password",
779    "password_env",
780    "secret",
781    "secret_ref",
782    "token",
783    "api_key",
784    "apikey",
785    "credentials",
786];
787
788/// Redact credential fields in a dynamic TOML value tree.
789///
790/// Walks the tree generically rather than only enumerating known paths:
791/// - Any string whose key matches a known credential-bearing name is
792///   replaced with `****`.
793/// - Any string that looks like a proxy URI (`scheme://...`) is passed
794///   through [`redact_uri`] so `user:pass@` and `user@` authorities are
795///   stripped. This covers `upstreams[].uri`, per-hop credentials, PAC
796///   fields, and any future field that embeds a proxy URI.
797fn redact_toml_value(value: &mut toml::Value) {
798    redact_toml_value_inner(value);
799}
800
801fn redact_toml_value_inner(value: &mut toml::Value) {
802    match value {
803        toml::Value::Table(table) => {
804            for (key, val) in table.iter_mut() {
805                let lkey = key.to_ascii_lowercase();
806                if REDACTED_SECRET_KEYS.iter().any(|k| lkey == *k) {
807                    if let toml::Value::String(_) = val {
808                        *val = toml::Value::String("****".to_string());
809                        continue;
810                    }
811                }
812                redact_toml_value_inner(val);
813            }
814        }
815        toml::Value::Array(items) => {
816            for item in items.iter_mut() {
817                redact_toml_value_inner(item);
818            }
819        }
820        toml::Value::String(s) if looks_like_proxy_uri(s) => {
821            *s = redact_uri(s);
822        }
823        _ => {}
824    }
825}
826
827/// Heuristic: a string is treated as a proxy URI if it starts with
828/// `scheme://` where `scheme` is one of the eggress-supported schemes.
829fn looks_like_proxy_uri(s: &str) -> bool {
830    let Some(colon) = s.find("://") else {
831        return false;
832    };
833    let scheme = &s[..colon];
834    matches!(
835        scheme,
836        "socks5"
837            | "socks4"
838            | "http"
839            | "https"
840            | "ss"
841            | "trojan"
842            | "h2"
843            | "ws"
844            | "wss"
845            | "raw"
846            | "tunnel"
847            | "redir"
848            | "unix"
849    )
850}
851
852/// Redact credentials embedded in a proxy URI.
853///
854/// Transforms `proto://user:pass@host:port` into `proto://****:****@host:port`.
855/// Also redacts username-only authorities (`proto://user@host:port`) so that
856/// bare usernames never leak into diagnostic or `redacted_*` output.
857/// If no `userinfo` is present, the URI is returned unchanged.
858///
859/// The userinfo separator is the LAST unbracketed `@` after the scheme;
860/// a raw password containing `@` must not be treated as a separator.
861fn redact_uri(uri: &str) -> String {
862    if let Some(scheme_end) = uri.find("://") {
863        let rest = &uri[scheme_end + 3..];
864        // Find LAST unbracketed '@' so a raw '@' in the password is preserved.
865        let mut last_at: Option<usize> = None;
866        let mut bracket_depth = 0u32;
867        for (i, c) in rest.char_indices() {
868            match c {
869                '[' => bracket_depth += 1,
870                ']' => bracket_depth = bracket_depth.saturating_sub(1),
871                '@' if bracket_depth == 0 => last_at = Some(i),
872                _ => {}
873            }
874        }
875        if let Some(at_pos) = last_at {
876            let authority_after = &rest[at_pos + 1..];
877            return format!("{}://****:****@{}", &uri[..scheme_end], authority_after);
878        }
879    }
880    uri.to_string()
881}
882
883/// Write config to a temporary file for the supervisor.
884///
885/// The TOML may carry plaintext upstream credentials, so the file is created
886/// without following a pre-existing path. On Unix it is also owner-only
887/// (0600) instead of world-readable.
888fn write_temp_config(config: &EggressConfig) -> Result<String, EggressError> {
889    let dir = std::env::temp_dir();
890    use std::io::Write;
891    let mut file = tempfile::Builder::new()
892        .prefix("eggress-embed-")
893        .suffix(".toml")
894        .tempfile_in(dir)
895        .map_err(|e| EggressError::Config(format!("failed to create temp config: {e}")))?;
896    file.write_all(config.source_toml.as_bytes())
897        .and_then(|_| file.flush())
898        .map_err(|e| EggressError::Config(format!("failed to write temp config: {e}")))?;
899    file.into_temp_path()
900        .keep()
901        .map(|path| path.to_string_lossy().into_owned())
902        .map_err(|e| EggressError::Config(format!("failed to retain temp config: {e}")))
903}
904
905#[cfg(test)]
906mod tests {
907    use std::net::SocketAddr;
908
909    use super::{default_listener_addr, listener_addr_or_configured};
910
911    #[test]
912    fn listener_addr_prefers_bound_address() {
913        let bound: SocketAddr = "127.0.0.1:1234".parse().unwrap();
914
915        assert_eq!(
916            listener_addr_or_configured(&[Some(bound)], 0, "127.0.0.1:5678"),
917            bound
918        );
919    }
920
921    #[test]
922    fn listener_addr_falls_back_to_configured_bind() {
923        let configured: SocketAddr = "127.0.0.1:5678".parse().unwrap();
924
925        assert_eq!(
926            listener_addr_or_configured(&[], 0, "127.0.0.1:5678"),
927            configured
928        );
929    }
930
931    #[test]
932    fn listener_addr_uses_default_for_invalid_configured_bind() {
933        assert_eq!(
934            listener_addr_or_configured(&[], 0, "not an address"),
935            default_listener_addr()
936        );
937    }
938
939    #[cfg(unix)]
940    #[test]
941    fn temp_config_file_is_owner_only() {
942        use std::os::unix::fs::PermissionsExt;
943
944        let config = super::EggressConfig::from_toml_str("version = 1").unwrap();
945        let path = super::write_temp_config(&config).unwrap();
946        let metadata = std::fs::metadata(&path).unwrap();
947        assert_eq!(
948            metadata.permissions().mode() & 0o777,
949            0o600,
950            "temp config carries plaintext credentials and must not be group/world readable"
951        );
952        let _ = std::fs::remove_file(&path);
953    }
954
955    #[test]
956    fn temp_config_files_use_distinct_random_names() {
957        let config = super::EggressConfig::from_toml_str("version = 1").unwrap();
958        let first = super::write_temp_config(&config).unwrap();
959        let second = super::write_temp_config(&config).unwrap();
960        assert_ne!(first, second);
961        let _ = std::fs::remove_file(first);
962        let _ = std::fs::remove_file(second);
963    }
964
965    #[test]
966    fn cancel_removes_temp_config_file() {
967        let config = super::EggressConfig::from_toml_str("version = 1").unwrap();
968        let mut handle = super::EggressService::new(config).start_blocking().unwrap();
969        let path = handle._config_path.clone().unwrap();
970
971        assert!(std::path::Path::new(&path).exists());
972        handle.cancel_and_cleanup();
973        assert!(!std::path::Path::new(&path).exists());
974    }
975}