1use std::time::{Duration, Instant};
2
3pub const EXIT_SUCCESS: i32 = 0;
6pub const EXIT_RUNTIME_FAILURE: i32 = 1;
7pub const EXIT_CLI_PARSE_ERROR: i32 = 2;
8pub const EXIT_CONFIG_VALIDATION: i32 = 3;
9pub const EXIT_UNSUPPORTED_FEATURE: i32 = 5;
10pub const EXIT_SIGINT: i32 = 130;
11pub const EXIT_SIGTERM: i32 = 143;
12
13#[cfg(feature = "pproxy-daemon")]
18pub fn maybe_daemonize(requested: bool) -> Result<(), String> {
19 const CHILD_MARKER: &str = "EGGRESS_PPROXY_DAEMON_CHILD";
20 if !requested || std::env::var_os(CHILD_MARKER).is_some() {
21 return Ok(());
22 }
23 #[cfg(target_os = "linux")]
24 {
25 let executable = std::env::current_exe()
26 .map_err(|error| format!("cannot resolve executable for --daemon: {error}"))?;
27 std::process::Command::new(executable)
28 .args(std::env::args_os().skip(1))
29 .env(CHILD_MARKER, "1")
30 .current_dir("/")
31 .stdin(std::process::Stdio::null())
32 .stdout(std::process::Stdio::null())
33 .stderr(std::process::Stdio::null())
34 .spawn()
35 .map_err(|error| format!("cannot start --daemon child: {error}"))?;
36 std::process::exit(0);
37 }
38 #[cfg(not(target_os = "linux"))]
39 {
40 Err("--daemon compatibility is only available on Linux".to_string())
41 }
42}
43
44use eggress_core::chain::{ChainExecutor, HopHandler};
45use eggress_core::{BoxStream, TargetAddr, TargetHost};
46
47#[derive(serde::Serialize)]
48pub struct UpstreamTestResult {
49 pub id: String,
50 pub host: String,
51 pub port: u16,
52 pub target: String,
53 pub mode: String,
54 pub reachable: bool,
55 pub latency_ms: Option<u64>,
56 pub error: Option<String>,
57 pub failure: Option<String>,
58 pub failed_hop: Option<usize>,
59}
60
61pub fn parse_pproxy_test_target(value: &str) -> Result<TargetAddr, String> {
65 if let Ok(target) = value.parse::<TargetAddr>() {
66 return Ok(target);
67 }
68
69 let uri: http::Uri = value
70 .parse()
71 .map_err(|e| format!("invalid test URL '{value}': {e}"))?;
72 let scheme = uri
73 .scheme_str()
74 .ok_or_else(|| format!("invalid test URL '{value}': missing scheme"))?;
75 if !matches!(scheme, "http" | "https") {
76 return Err(format!(
77 "invalid test URL '{value}': unsupported scheme '{scheme}'"
78 ));
79 }
80 let authority = uri
81 .authority()
82 .ok_or_else(|| format!("invalid test URL '{value}': missing host"))?;
83 let host = authority.host();
84 if host.is_empty() {
85 return Err(format!("invalid test URL '{value}': missing host"));
86 }
87 let port = authority
88 .port_u16()
89 .unwrap_or_else(|| if scheme == "https" { 443 } else { 80 });
90 let host = if host.contains(':') {
91 TargetHost::Ip(
92 host.parse()
93 .map_err(|e| format!("invalid test URL '{value}': invalid IPv6 host: {e}"))?,
94 )
95 } else if let Ok(ip) = host.parse() {
96 TargetHost::Ip(ip)
97 } else {
98 TargetHost::Domain(host.to_string())
99 };
100 Ok(TargetAddr { host, port })
101}
102
103pub fn run_upstream_test(
108 rt: &eggress_config::compile::RuntimeConfig,
109 target: Option<&str>,
110 timeout: Duration,
111 json_output: bool,
112) -> i32 {
113 run_upstream_test_with_mode(rt, target, "proxy", timeout, json_output)
114}
115
116pub fn run_upstream_test_with_mode(
118 rt: &eggress_config::compile::RuntimeConfig,
119 target: Option<&str>,
120 mode: &str,
121 timeout: Duration,
122 json_output: bool,
123) -> i32 {
124 let target = match target {
125 Some(t) => match t.parse::<TargetAddr>() {
126 Ok(addr) => addr,
127 Err(e) => {
128 eprintln!("invalid target: {e}");
129 return 2;
130 }
131 },
132 None => TargetAddr {
133 host: TargetHost::Domain("example.com".to_string()),
134 port: 443,
135 },
136 };
137
138 let target_string = target.to_string();
139 let is_proxy_mode = mode == "proxy";
140 let mut results = Vec::new();
141
142 for upstream in &rt.upstreams {
143 let chain = &upstream.chain;
144 let first_hop = &chain.hops[0];
145 let host = &first_hop.endpoint.host;
146 let port = first_hop.endpoint.port;
147
148 let result = if is_proxy_mode {
149 let hops = chain.hops.clone();
150 let target_for_closure = target.clone();
151 let (reachable, latency_ms, error) = run_async_test(move || {
152 let target = target_for_closure.clone();
153 let hops = hops.clone();
154 Box::pin(async move {
155 let executor = build_test_chain_executor();
156 test_upstream_proxy(&executor, &hops, &target, timeout).await
157 })
158 });
159 UpstreamTestResult {
160 id: upstream.id.clone(),
161 host: host.clone(),
162 port,
163 target: target_string.clone(),
164 mode: "proxy".to_string(),
165 reachable,
166 latency_ms,
167 error,
168 failure: None,
169 failed_hop: None,
170 }
171 } else {
172 let host_owned = host.clone();
173 let target_result = run_async_test(move || {
174 let host = host_owned.clone();
175 Box::pin(async move { test_upstream_tcp(&host, port, timeout).await })
176 });
177 UpstreamTestResult {
178 id: upstream.id.clone(),
179 host: host.clone(),
180 port,
181 target: target_string.clone(),
182 ..target_result
183 }
184 };
185 results.push(result);
186 }
187
188 if results.is_empty() {
189 eprintln!("no upstreams found matching criteria");
190 return 3;
191 }
192
193 if json_output {
194 match serde_json::to_string_pretty(&results) {
195 Ok(json) => println!("{json}"),
196 Err(e) => {
197 eprintln!("failed to serialize results: {e}");
198 return 1;
199 }
200 }
201 } else {
202 for result in &results {
203 print_upstream_test_result(result);
204 }
205 }
206
207 if results.iter().any(|r| r.reachable) {
208 0
209 } else {
210 1
211 }
212}
213
214pub fn print_upstream_test_result(result: &UpstreamTestResult) {
215 let status = if result.reachable {
216 "reachable"
217 } else {
218 "unreachable"
219 };
220 let latency = result
221 .latency_ms
222 .map(|ms| format!("{}ms", ms))
223 .unwrap_or_else(|| "n/a".to_string());
224 let error = result
225 .error
226 .as_deref()
227 .map(|e| format!(" ({e})"))
228 .unwrap_or_default();
229
230 println!(
231 "{} {}:{} [{}] latency={}{}",
232 result.id, result.host, result.port, status, latency, error
233 );
234}
235
236pub fn run_async_test<F, T>(make_future: F) -> T
237where
238 F: FnOnce() -> std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send>> + Send + 'static,
239 T: Send + 'static,
240{
241 if tokio::runtime::Handle::try_current().is_ok() {
242 std::thread::Builder::new()
243 .name("eggress-cli-test".to_string())
244 .spawn(move || -> T {
245 let rt = tokio::runtime::Builder::new_multi_thread()
246 .enable_all()
247 .build()
248 .expect("failed to build tokio runtime for cli test");
249 rt.block_on(make_future())
250 })
251 .expect("failed to spawn cli test thread")
252 .join()
253 .expect("cli test thread panicked")
254 } else {
255 let rt = tokio::runtime::Builder::new_current_thread()
256 .enable_all()
257 .build()
258 .expect("failed to build tokio runtime for cli test");
259 rt.block_on(make_future())
260 }
261}
262
263async fn test_upstream_proxy(
264 executor: &ChainExecutor,
265 chain: &[eggress_uri::ProxyHopSpec],
266 target: &TargetAddr,
267 timeout: Duration,
268) -> (bool, Option<u64>, Option<String>) {
269 let start = Instant::now();
270
271 match tokio::time::timeout(timeout, executor.execute(chain, target)).await {
272 Ok(Ok(_stream)) => {
273 let elapsed = start.elapsed().as_millis() as u64;
274 (true, Some(elapsed), None)
275 }
276 Ok(Err(e)) => (false, None, Some(e.to_string())),
277 Err(_) => (false, None, Some("connection timed out".to_string())),
278 }
279}
280
281struct HttpHopHandler;
282
283impl HopHandler for HttpHopHandler {
284 fn protocol(&self) -> eggress_uri::ProtocolSpec {
285 eggress_uri::ProtocolSpec::Http
286 }
287
288 fn handshake<'a>(
289 &'a self,
290 stream: BoxStream,
291 target: &'a TargetAddr,
292 hop: &'a eggress_uri::ProxyHopSpec,
293 _hop_index: usize,
294 ) -> std::pin::Pin<
295 Box<
296 dyn std::future::Future<
297 Output = Result<BoxStream, Box<dyn std::error::Error + Send + Sync>>,
298 > + Send
299 + 'a,
300 >,
301 > {
302 let auth = hop
303 .credentials
304 .as_ref()
305 .map(|c| (c.username.as_str(), c.password.as_str()));
306 Box::pin(async move {
307 eggress_protocol_http::http_connect(stream, target, auth, &Default::default())
308 .await
309 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
310 })
311 }
312}
313
314struct Socks5HopHandler;
315
316impl HopHandler for Socks5HopHandler {
317 fn protocol(&self) -> eggress_uri::ProtocolSpec {
318 eggress_uri::ProtocolSpec::Socks5
319 }
320
321 fn handshake<'a>(
322 &'a self,
323 stream: BoxStream,
324 target: &'a TargetAddr,
325 hop: &'a eggress_uri::ProxyHopSpec,
326 _hop_index: usize,
327 ) -> std::pin::Pin<
328 Box<
329 dyn std::future::Future<
330 Output = Result<BoxStream, Box<dyn std::error::Error + Send + Sync>>,
331 > + Send
332 + 'a,
333 >,
334 > {
335 let socks_addr = target_to_socks_addr(target);
336 let auth = hop
337 .credentials
338 .as_ref()
339 .map(|c| (c.username.as_str(), c.password.as_str()));
340 Box::pin(async move {
341 eggress_protocol_socks::socks5::client::socks5_connect(stream, &socks_addr, auth)
342 .await
343 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
344 })
345 }
346}
347
348struct Socks4HopHandler;
349
350impl HopHandler for Socks4HopHandler {
351 fn protocol(&self) -> eggress_uri::ProtocolSpec {
352 eggress_uri::ProtocolSpec::Socks4
353 }
354
355 fn handshake<'a>(
356 &'a self,
357 stream: BoxStream,
358 target: &'a TargetAddr,
359 hop: &'a eggress_uri::ProxyHopSpec,
360 _hop_index: usize,
361 ) -> std::pin::Pin<
362 Box<
363 dyn std::future::Future<
364 Output = Result<BoxStream, Box<dyn std::error::Error + Send + Sync>>,
365 > + Send
366 + 'a,
367 >,
368 > {
369 let user_id = hop.credentials.as_ref().map(|c| c.username.as_str());
370 Box::pin(async move {
371 eggress_protocol_socks::socks4_connect(stream, target, user_id)
372 .await
373 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
374 })
375 }
376}
377
378pub fn build_test_chain_executor() -> ChainExecutor {
379 let handlers: Vec<Box<dyn HopHandler>> = vec![
380 Box::new(HttpHopHandler),
381 Box::new(Socks5HopHandler),
382 Box::new(Socks4HopHandler),
383 ];
384 ChainExecutor::new(handlers)
385}
386
387fn target_to_socks_addr(target: &TargetAddr) -> eggress_protocol_socks::socks5::server::SocksAddr {
388 use eggress_protocol_socks::socks5::server::SocksAddr;
389 match &target.host {
390 TargetHost::Ip(std::net::IpAddr::V4(ip)) => SocksAddr::IPv4(ip.octets(), target.port),
391 TargetHost::Ip(std::net::IpAddr::V6(ip)) => SocksAddr::IPv6(ip.octets(), target.port),
392 TargetHost::Domain(d) => SocksAddr::Domain(d.clone(), target.port),
393 }
394}
395
396pub async fn test_upstream_tcp(host: &str, port: u16, timeout: Duration) -> UpstreamTestResult {
397 let addr = format!("{}:{}", host, port);
398 let start = Instant::now();
399
400 let result = tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await;
401
402 let elapsed = start.elapsed().as_millis() as u64;
403
404 match result {
405 Ok(Ok(_stream)) => UpstreamTestResult {
406 id: String::new(),
407 host: host.to_string(),
408 port,
409 target: String::new(),
410 mode: "tcp".to_string(),
411 reachable: true,
412 latency_ms: Some(elapsed),
413 error: None,
414 failure: None,
415 failed_hop: None,
416 },
417 Ok(Err(e)) => UpstreamTestResult {
418 id: String::new(),
419 host: host.to_string(),
420 port,
421 target: String::new(),
422 mode: "tcp".to_string(),
423 reachable: false,
424 latency_ms: None,
425 error: Some(e.to_string()),
426 failure: None,
427 failed_hop: None,
428 },
429 Err(_) => UpstreamTestResult {
430 id: String::new(),
431 host: host.to_string(),
432 port,
433 target: String::new(),
434 mode: "tcp".to_string(),
435 reachable: false,
436 latency_ms: None,
437 error: Some("connection timed out".to_string()),
438 failure: None,
439 failed_hop: None,
440 },
441 }
442}