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