1mod 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#[derive(Clone)]
64pub struct EggressConfig {
65 source_toml: String,
66}
67
68impl EggressConfig {
69 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 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 pub fn source_toml(&self) -> &str {
105 &self.source_toml
106 }
107
108 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
123pub struct EggressService {
128 config: EggressConfig,
129}
130
131impl EggressService {
132 pub fn new(config: EggressConfig) -> Self {
134 Self { config }
135 }
136
137 pub fn from_toml_str(input: &str) -> Result<Self, EggressError> {
139 EggressConfig::from_toml_str(input).map(Self::new)
140 }
141
142 pub fn from_toml_file(path: impl AsRef<Path>) -> Result<Self, EggressError> {
144 EggressConfig::from_toml_file(path).map(Self::new)
145 }
146
147 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 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 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 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 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 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 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 #[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
391pub 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 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 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 pub fn metrics_text(&self) -> Result<String, EggressError> {
499 Ok(self.state.metrics.render_prometheus())
500 }
501
502 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
682pub struct BoundAddresses {
683 pub listeners: Vec<ListenerAddress>,
685 pub admin: Option<std::net::SocketAddr>,
687}
688
689impl BoundAddresses {
690 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#[derive(Debug, Clone)]
701pub struct ListenerAddress {
702 pub name: String,
704 pub addr: std::net::SocketAddr,
706}
707
708#[derive(Debug, Clone)]
710pub struct ListenerStatus {
711 pub name: String,
713 pub bind: String,
715 pub local_addr: std::net::SocketAddr,
717 pub protocols: Vec<String>,
719 pub udp_enabled: bool,
721}
722
723#[derive(Debug, Clone)]
725pub struct ServiceStatus {
726 pub generation: u64,
728 pub readiness: bool,
730 pub active_connections: u64,
732 pub uptime_secs: u64,
734 pub listener_count: usize,
736 pub listeners: Vec<ListenerStatus>,
738 pub udp_associations_active: u64,
740 pub upstream_count: usize,
742}
743
744#[derive(Debug)]
746pub enum ReloadOutcome {
747 Applied {
749 generation: u64,
751 upstreams: usize,
753 },
754}
755
756const 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
768fn 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
807fn 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
832fn redact_uri(uri: &str) -> String {
842 if let Some(scheme_end) = uri.find("://") {
843 let rest = &uri[scheme_end + 3..];
844 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
863fn 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}