mod error;
pub mod outbound;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::Path;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
pub use error::EggressError;
#[derive(Clone)]
pub struct EggressConfig {
source_toml: String,
}
impl EggressConfig {
pub fn from_toml_str(input: &str) -> Result<Self, EggressError> {
let config: eggress_config::model::ConfigFile =
toml::from_str(input).map_err(|e| EggressError::Config(e.to_string()))?;
if let Some(version) = config.version {
if version != 1 {
return Err(EggressError::Config(format!(
"unsupported config version: {version}"
)));
}
}
eggress_config::validate::validate_config(&config).map_err(|errors| {
let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
EggressError::Config(messages.join("; "))
})?;
let _inner = eggress_config::compile::compile_config(&config)
.map_err(|e| EggressError::Config(e.to_string()))?;
Ok(Self {
source_toml: input.to_string(),
})
}
pub fn from_toml_file(path: impl AsRef<Path>) -> Result<Self, EggressError> {
let path = path.as_ref();
let contents = std::fs::read_to_string(path)
.map_err(|e| EggressError::Config(format!("failed to read {path:?}: {e}")))?;
Self::from_toml_str(&contents)
}
pub fn source_toml(&self) -> &str {
&self.source_toml
}
pub fn to_redacted_toml(&self) -> Result<String, EggressError> {
let mut value: toml::Value =
toml::from_str(&self.source_toml).map_err(|e| EggressError::Config(e.to_string()))?;
redact_toml_value(&mut value);
toml::to_string_pretty(&value).map_err(|e| EggressError::Internal(e.to_string()))
}
}
pub struct EggressService {
config: EggressConfig,
}
impl EggressService {
pub fn new(config: EggressConfig) -> Self {
Self { config }
}
pub fn from_toml_str(input: &str) -> Result<Self, EggressError> {
EggressConfig::from_toml_str(input).map(Self::new)
}
pub fn from_toml_file(path: impl AsRef<Path>) -> Result<Self, EggressError> {
EggressConfig::from_toml_file(path).map(Self::new)
}
pub async fn start(self) -> Result<EggressHandle, EggressError> {
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
let config_path = write_temp_config(&self.config)?;
let config_path_clone = config_path.clone();
let join = tokio::task::spawn_blocking(move || -> Result<
(
Arc<eggress_runtime::RuntimeState>,
tokio_util::sync::CancellationToken,
),
EggressError,
> {
let mut sup = match eggress_runtime::ServiceSupervisor::start(&config_path_clone) {
Ok(sup) => sup,
Err(error) => {
let _ = std::fs::remove_file(&config_path_clone);
return Err(EggressError::Startup(error.to_string()));
}
};
let state = sup.state().clone();
let token = sup.shutdown_token();
let run_result = std::thread::Builder::new()
.name("eggress-embed-rt".into())
.spawn(move || sup.run())
.map_err(|error| {
let _ = std::fs::remove_file(&config_path_clone);
EggressError::Startup(error.to_string())
})?;
let started = std::time::Instant::now();
let timeout = Duration::from_secs(30);
loop {
if state.readiness.load(Ordering::Acquire) {
let _ = ready_tx.send(Ok((state.clone(), token.clone())));
break;
}
if started.elapsed() > timeout {
token.cancel();
let _ = std::fs::remove_file(&config_path_clone);
let _ = ready_tx.send(Err(EggressError::Startup(
"readiness timeout".to_string(),
)));
break;
}
std::thread::sleep(Duration::from_millis(5));
}
match run_result.join() {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::debug!(%e, "runtime exited with error"),
Err(_) => tracing::debug!("runtime thread panicked"),
}
let _ = std::fs::remove_file(&config_path_clone);
Ok((state, token))
});
let (state, token) = ready_rx
.await
.map_err(|_| EggressError::Startup("startup channel dropped".into()))??;
let join = tokio::task::spawn(async move {
match join.await {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err(EggressError::Startup(format!("startup failed: {e}"))),
Err(e) => Err(EggressError::Startup(format!("startup task panicked: {e}"))),
}
});
Ok(EggressHandle {
state,
token: Some(token),
_run_handle: None,
_config_path: Some(config_path),
_runtime_task: Some(join),
reload_mutex: std::sync::Mutex::new(()),
})
}
pub fn start_blocking(self) -> Result<EggressHandle, EggressError> {
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
let config_path = write_temp_config(&self.config)?;
let config_path_clone = config_path.clone();
let _thread_handle = std::thread::Builder::new()
.name("eggress-embed-rt".into())
.spawn(move || {
let mut sup = match eggress_runtime::ServiceSupervisor::start(&config_path_clone) {
Ok(s) => s,
Err(e) => {
let _ = std::fs::remove_file(&config_path_clone);
let _ = ready_tx.send(Err(EggressError::Startup(e.to_string())));
return;
}
};
let state = sup.state().clone();
let token = sup.shutdown_token();
let run_handle = std::thread::Builder::new()
.name("eggress-embed-run".into())
.spawn(move || {
if let Err(e) = sup.run() {
tracing::error!("supervisor exited with error: {e}");
}
});
let run_handle = match run_handle {
Ok(h) => h,
Err(e) => {
let _ = std::fs::remove_file(&config_path_clone);
let _ = ready_tx.send(Err(EggressError::Startup(e.to_string())));
return;
}
};
let started = std::time::Instant::now();
let timeout = Duration::from_secs(30);
loop {
if state.readiness.load(Ordering::Acquire) {
let _ = ready_tx.send(Ok((state, token, run_handle, config_path_clone)));
break;
}
if started.elapsed() > timeout {
token.cancel();
let _ = std::fs::remove_file(&config_path_clone);
match run_handle.join() {
Ok(()) => {}
Err(_) => tracing::debug!("runtime thread panicked"),
}
let _ =
ready_tx.send(Err(EggressError::Startup("readiness timeout".into())));
break;
}
std::thread::sleep(Duration::from_millis(5));
}
})
.map_err(|e| {
let _ = std::fs::remove_file(&config_path);
EggressError::Startup(e.to_string())
})?;
let (state, token, run_handle, config_path) = ready_rx
.recv()
.map_err(|_| EggressError::Startup("startup channel dropped".into()))??;
Ok(EggressHandle {
state,
token: Some(token),
_run_handle: Some(run_handle),
_config_path: Some(config_path),
_runtime_task: None,
reload_mutex: std::sync::Mutex::new(()),
})
}
#[cfg(feature = "pproxy-compat")]
pub fn start_blocking_with_compatibility_options(
self,
compatibility_options: eggress_runtime::CompatibilityOptions,
) -> Result<EggressHandle, EggressError> {
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
let source_toml = self.config.source_toml.clone();
let rt_config = eggress_config::validate_and_compile_toml_with_warnings(&source_toml)
.map(|(config, _)| config)
.map_err(|e| EggressError::Config(e.to_string()))?;
std::thread::Builder::new()
.name("eggress-embed-rt".into())
.spawn(move || {
let mut supervisor =
match eggress_runtime::ServiceSupervisor::start_from_config_with_options(
rt_config,
None,
compatibility_options,
) {
Ok(supervisor) => supervisor,
Err(error) => {
let _ = ready_tx.send(Err(EggressError::Startup(error.to_string())));
return;
}
};
let state = supervisor.state().clone();
let token = supervisor.shutdown_token();
let run_handle = std::thread::Builder::new()
.name("eggress-embed-run".into())
.spawn(move || {
if let Err(error) = supervisor.run() {
tracing::error!("supervisor exited with error: {error}");
}
});
let run_handle = match run_handle {
Ok(handle) => handle,
Err(error) => {
let _ = ready_tx.send(Err(EggressError::Startup(error.to_string())));
return;
}
};
let started = std::time::Instant::now();
let timeout = Duration::from_secs(30);
loop {
if state.readiness.load(Ordering::Acquire) {
let _ = ready_tx.send(Ok((state, token, run_handle)));
break;
}
if started.elapsed() > timeout {
token.cancel();
let _ = run_handle.join();
let _ =
ready_tx.send(Err(EggressError::Startup("readiness timeout".into())));
break;
}
std::thread::sleep(Duration::from_millis(5));
}
})
.map_err(|e| EggressError::Startup(e.to_string()))?;
let (state, token, run_handle) = ready_rx
.recv()
.map_err(|_| EggressError::Startup("startup channel dropped".into()))??;
Ok(EggressHandle {
state,
token: Some(token),
_run_handle: Some(run_handle),
_config_path: None,
_runtime_task: None,
reload_mutex: std::sync::Mutex::new(()),
})
}
}
pub struct EggressHandle {
state: Arc<eggress_runtime::RuntimeState>,
token: Option<tokio_util::sync::CancellationToken>,
_run_handle: Option<std::thread::JoinHandle<()>>,
_config_path: Option<String>,
_runtime_task: Option<tokio::task::JoinHandle<Result<(), EggressError>>>,
reload_mutex: std::sync::Mutex<()>,
}
impl EggressHandle {
pub fn bound_addresses(&self) -> BoundAddresses {
let addrs = self
.state
.listener_addrs
.lock()
.unwrap_or_else(|e| e.into_inner());
let admin = self
.state
.admin_local_addr
.lock()
.unwrap_or_else(|e| e.into_inner());
let snap = self.state.snapshot.load();
let listeners: Vec<ListenerAddress> = snap
.listeners
.iter()
.enumerate()
.map(|(idx, lcfg)| ListenerAddress {
name: lcfg.name.clone(),
addr: listener_addr_or_configured(&addrs, idx, &lcfg.bind),
})
.collect();
BoundAddresses {
listeners,
admin: *admin,
}
}
pub fn status(&self) -> ServiceStatus {
let snap = self.state.snapshot.load();
let addrs = self
.state
.listener_addrs
.lock()
.unwrap_or_else(|e| e.into_inner());
let listeners: Vec<ListenerStatus> = snap
.listeners
.iter()
.enumerate()
.map(|(idx, lcfg)| ListenerStatus {
name: lcfg.name.clone(),
bind: lcfg.bind.clone(),
local_addr: listener_addr_or_configured(&addrs, idx, &lcfg.bind),
protocols: lcfg.protocols.iter().map(|p| format!("{p}")).collect(),
udp_enabled: lcfg.udp.as_ref().is_some_and(|u| u.enabled),
})
.collect();
let udp_active = self
.state
.udp_metrics
.associations_active
.load(Ordering::Relaxed);
ServiceStatus {
generation: snap.generation,
readiness: self.state.readiness.load(Ordering::Relaxed),
active_connections: self.state.active_connections.load(Ordering::Relaxed),
uptime_secs: self.state.start_time.elapsed().as_secs(),
listener_count: snap.listeners.len(),
listeners,
udp_associations_active: udp_active,
upstream_count: snap.upstreams.len(),
}
}
pub fn metrics_text(&self) -> Result<String, EggressError> {
Ok(self.state.metrics.render_prometheus())
}
pub fn reload_toml_str(&self, input: &str) -> Result<ReloadOutcome, EggressError> {
let _guard = self
.reload_mutex
.lock()
.map_err(|_| EggressError::Reload("concurrent reload in progress".to_string()))?;
let config: eggress_config::model::ConfigFile =
toml::from_str(input).map_err(|e| EggressError::Reload(e.to_string()))?;
if let Some(version) = config.version {
if version != 1 {
return Err(EggressError::Reload(format!(
"unsupported config version: {version}"
)));
}
}
eggress_config::validate::validate_config(&config).map_err(|errors| {
let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
EggressError::Reload(messages.join("; "))
})?;
let new_rt_config = eggress_config::compile::compile_config(&config)
.map_err(|e| EggressError::Reload(e.to_string()))?;
let prev_snapshot = self.state.snapshot.load();
eggress_runtime::classify_reload_config(
&prev_snapshot.listeners,
&prev_snapshot.timeouts,
prev_snapshot.admin.as_ref(),
&new_rt_config,
)
.map_err(EggressError::Reload)?;
let prev_ref: Option<&eggress_runtime::CompiledRuntimeSnapshot> = Some(&prev_snapshot);
let new_snapshot =
eggress_runtime::snapshot::compile_runtime_snapshot(&new_rt_config, prev_ref)
.map_err(|e| EggressError::Reload(format!("snapshot build: {e}")))?;
let gen = new_snapshot.generation;
let upstreams = new_snapshot.upstreams.len();
let new_snapshot = Arc::new(new_snapshot);
self.state.snapshot.store(new_snapshot.clone());
self.state.routing.swap_arc(new_snapshot.router.clone());
self.state.restart_health_probes();
self.state.metrics.set_config_generation(gen);
self.state.metrics.record_reload(true);
Ok(ReloadOutcome::Applied {
generation: gen,
upstreams,
})
}
pub fn reload_toml_file(&self, path: impl AsRef<Path>) -> Result<ReloadOutcome, EggressError> {
let path = path.as_ref();
let contents = std::fs::read_to_string(path)
.map_err(|e| EggressError::Reload(format!("failed to read {path:?}: {e}")))?;
self.reload_toml_str(&contents)
}
pub fn cancel(&self) {
if let Some(token) = self.token.as_ref() {
token.cancel();
}
}
pub fn cancel_and_cleanup(&mut self) {
self.cancel();
if let Some(path) = self._config_path.take() {
let _ = std::fs::remove_file(&path);
}
}
pub async fn shutdown(mut self) -> Result<(), EggressError> {
if let Some(token) = self.token.take() {
token.cancel();
}
if let Some(task) = self._runtime_task.take() {
let _ = task.await;
}
if let Some(jh) = self._run_handle.take() {
let _ = tokio::task::spawn_blocking(move || {
let _ = jh.join();
})
.await;
}
if let Some(path) = self._config_path.take() {
let _ = std::fs::remove_file(&path);
}
Ok(())
}
pub fn shutdown_blocking(mut self) -> Result<(), EggressError> {
if let Some(token) = self.token.take() {
token.cancel();
}
if let Some(jh) = self._run_handle.take() {
let _ = jh.join();
}
if let Some(task) = self._runtime_task.take() {
let rt = tokio::runtime::Runtime::new()
.map_err(|e| EggressError::Shutdown(e.to_string()))?;
rt.block_on(async {
let _ = task.await;
});
}
if let Some(path) = self._config_path.take() {
let _ = std::fs::remove_file(&path);
}
Ok(())
}
}
impl Drop for EggressHandle {
fn drop(&mut self) {
if let Some(token) = self.token.take() {
token.cancel();
}
if let Some(jh) = self._run_handle.take() {
let _ = jh.join();
}
if let Some(task) = self._runtime_task.take() {
let rt = tokio::runtime::Runtime::new().ok();
if let Some(rt) = rt {
rt.block_on(async {
let _ = tokio::time::timeout(Duration::from_secs(5), task).await;
});
}
}
if let Some(path) = self._config_path.take() {
let _ = std::fs::remove_file(&path);
}
}
}
fn listener_addr_or_configured(
bound_addrs: &[Option<SocketAddr>],
idx: usize,
configured_bind: &str,
) -> SocketAddr {
bound_addrs
.get(idx)
.and_then(|a| *a)
.or_else(|| configured_bind.parse().ok())
.unwrap_or_else(default_listener_addr)
}
fn default_listener_addr() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)
}
#[derive(Debug, Clone)]
pub struct BoundAddresses {
pub listeners: Vec<ListenerAddress>,
pub admin: Option<std::net::SocketAddr>,
}
impl BoundAddresses {
pub fn listener(&self, name: &str) -> Option<std::net::SocketAddr> {
self.listeners
.iter()
.find(|l| l.name == name)
.map(|l| l.addr)
}
}
#[derive(Debug, Clone)]
pub struct ListenerAddress {
pub name: String,
pub addr: std::net::SocketAddr,
}
#[derive(Debug, Clone)]
pub struct ListenerStatus {
pub name: String,
pub bind: String,
pub local_addr: std::net::SocketAddr,
pub protocols: Vec<String>,
pub udp_enabled: bool,
}
#[derive(Debug, Clone)]
pub struct ServiceStatus {
pub generation: u64,
pub readiness: bool,
pub active_connections: u64,
pub uptime_secs: u64,
pub listener_count: usize,
pub listeners: Vec<ListenerStatus>,
pub udp_associations_active: u64,
pub upstream_count: usize,
}
#[derive(Debug)]
pub enum ReloadOutcome {
Applied {
generation: u64,
upstreams: usize,
},
}
const REDACTED_SECRET_KEYS: &[&str] = &[
"password",
"password_env",
"secret",
"secret_ref",
"token",
"api_key",
"apikey",
"credentials",
];
fn redact_toml_value(value: &mut toml::Value) {
redact_toml_value_inner(value);
}
fn redact_toml_value_inner(value: &mut toml::Value) {
match value {
toml::Value::Table(table) => {
for (key, val) in table.iter_mut() {
let lkey = key.to_ascii_lowercase();
if REDACTED_SECRET_KEYS.iter().any(|k| lkey == *k) {
if let toml::Value::String(_) = val {
*val = toml::Value::String("****".to_string());
continue;
}
}
redact_toml_value_inner(val);
}
}
toml::Value::Array(items) => {
for item in items.iter_mut() {
redact_toml_value_inner(item);
}
}
toml::Value::String(s) if looks_like_proxy_uri(s) => {
*s = redact_uri(s);
}
_ => {}
}
}
fn looks_like_proxy_uri(s: &str) -> bool {
let Some(colon) = s.find("://") else {
return false;
};
let scheme = &s[..colon];
matches!(
scheme,
"socks5"
| "socks4"
| "http"
| "https"
| "ss"
| "trojan"
| "h2"
| "ws"
| "wss"
| "raw"
| "tunnel"
| "redir"
| "unix"
)
}
fn redact_uri(uri: &str) -> String {
if let Some(scheme_end) = uri.find("://") {
let rest = &uri[scheme_end + 3..];
let mut last_at: Option<usize> = None;
let mut bracket_depth = 0u32;
for (i, c) in rest.char_indices() {
match c {
'[' => bracket_depth += 1,
']' => bracket_depth = bracket_depth.saturating_sub(1),
'@' if bracket_depth == 0 => last_at = Some(i),
_ => {}
}
}
if let Some(at_pos) = last_at {
let authority_after = &rest[at_pos + 1..];
return format!("{}://****:****@{}", &uri[..scheme_end], authority_after);
}
}
uri.to_string()
}
fn write_temp_config(config: &EggressConfig) -> Result<String, EggressError> {
let dir = std::env::temp_dir();
use std::io::Write;
let mut file = tempfile::Builder::new()
.prefix("eggress-embed-")
.suffix(".toml")
.tempfile_in(dir)
.map_err(|e| EggressError::Config(format!("failed to create temp config: {e}")))?;
file.write_all(config.source_toml.as_bytes())
.and_then(|_| file.flush())
.map_err(|e| EggressError::Config(format!("failed to write temp config: {e}")))?;
file.into_temp_path()
.keep()
.map(|path| path.to_string_lossy().into_owned())
.map_err(|e| EggressError::Config(format!("failed to retain temp config: {e}")))
}
#[cfg(test)]
mod tests {
use std::net::SocketAddr;
use super::{default_listener_addr, listener_addr_or_configured};
#[test]
fn listener_addr_prefers_bound_address() {
let bound: SocketAddr = "127.0.0.1:1234".parse().unwrap();
assert_eq!(
listener_addr_or_configured(&[Some(bound)], 0, "127.0.0.1:5678"),
bound
);
}
#[test]
fn listener_addr_falls_back_to_configured_bind() {
let configured: SocketAddr = "127.0.0.1:5678".parse().unwrap();
assert_eq!(
listener_addr_or_configured(&[], 0, "127.0.0.1:5678"),
configured
);
}
#[test]
fn listener_addr_uses_default_for_invalid_configured_bind() {
assert_eq!(
listener_addr_or_configured(&[], 0, "not an address"),
default_listener_addr()
);
}
#[cfg(unix)]
#[test]
fn temp_config_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let config = super::EggressConfig::from_toml_str("version = 1").unwrap();
let path = super::write_temp_config(&config).unwrap();
let metadata = std::fs::metadata(&path).unwrap();
assert_eq!(
metadata.permissions().mode() & 0o777,
0o600,
"temp config carries plaintext credentials and must not be group/world readable"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn temp_config_files_use_distinct_random_names() {
let config = super::EggressConfig::from_toml_str("version = 1").unwrap();
let first = super::write_temp_config(&config).unwrap();
let second = super::write_temp_config(&config).unwrap();
assert_ne!(first, second);
let _ = std::fs::remove_file(first);
let _ = std::fs::remove_file(second);
}
#[test]
fn cancel_removes_temp_config_file() {
let config = super::EggressConfig::from_toml_str("version = 1").unwrap();
let mut handle = super::EggressService::new(config).start_blocking().unwrap();
let path = handle._config_path.clone().unwrap();
assert!(std::path::Path::new(&path).exists());
handle.cancel_and_cleanup();
assert!(!std::path::Path::new(&path).exists());
}
}