1use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
2use std::sync::{Arc, OnceLock};
3use std::time::Duration;
4
5static PY_CONNECTION_LIVE_COUNT: AtomicUsize = AtomicUsize::new(0);
6static PY_CONNECTION_TOTAL_CREATED: AtomicUsize = AtomicUsize::new(0);
7static PY_OUTBOUND_RUNTIME: OnceLock<Result<Arc<tokio::runtime::Runtime>, String>> =
8 OnceLock::new();
9
10fn outbound_runtime() -> Result<Arc<tokio::runtime::Runtime>, String> {
11 PY_OUTBOUND_RUNTIME
12 .get_or_init(|| {
13 tokio::runtime::Builder::new_multi_thread()
14 .enable_all()
15 .build()
16 .map(Arc::new)
17 .map_err(|e| format!("runtime setup failed: {e}"))
18 })
19 .as_ref()
20 .map(Arc::clone)
21 .map_err(Clone::clone)
22}
23
24use pyo3::exceptions::{PyException, PyValueError};
25use pyo3::prelude::*;
26use pyo3::types::{PyDict, PyList, PyModule, PyModuleMethods, PySequence, PyString};
27use tokio::io::{AsyncReadExt, AsyncWriteExt};
28
29#[pyclass]
30struct PyAppliedSystemProxy {
31 inner: Option<eggress_system_proxy::AppliedProxy>,
32}
33
34#[pymethods]
35impl PyAppliedSystemProxy {
36 fn restore(&mut self, py: Python<'_>) -> PyResult<()> {
37 if let Some(mut applied) = self.inner.take() {
38 py.detach(|| applied.restore())
39 .map_err(PyValueError::new_err)?;
40 }
41 Ok(())
42 }
43
44 fn __enter__(slf: Py<Self>) -> Py<Self> {
45 slf
46 }
47
48 fn __exit__(
49 &mut self,
50 py: Python<'_>,
51 _exc_type: &Bound<'_, PyAny>,
52 _exc_value: &Bound<'_, PyAny>,
53 _traceback: &Bound<'_, PyAny>,
54 ) -> PyResult<bool> {
55 self.restore(py)?;
56 Ok(false)
57 }
58}
59
60#[pyfunction]
61fn apply_system_proxy(py: Python<'_>, kind: &str, address: &str) -> PyResult<PyAppliedSystemProxy> {
62 let kind = match kind {
63 "http" => eggress_system_proxy::CompatibilityProxyKind::Http,
64 "socks5" => eggress_system_proxy::CompatibilityProxyKind::Socks5,
65 other => {
66 return Err(PyValueError::new_err(format!(
67 "unknown proxy kind: {other}"
68 )))
69 }
70 };
71 let address = address
72 .parse()
73 .map_err(|e| PyValueError::new_err(format!("invalid proxy address: {e}")))?;
74 let inner = py
75 .detach(|| eggress_system_proxy::apply_compatibility_proxy(kind, address))
76 .map_err(PyValueError::new_err)?;
77 Ok(PyAppliedSystemProxy { inner: Some(inner) })
78}
79
80pyo3::create_exception!(_eggress, EggressError, PyException);
81pyo3::create_exception!(_eggress, ConfigError, EggressError);
82pyo3::create_exception!(_eggress, StartupError, EggressError);
83pyo3::create_exception!(_eggress, ReloadError, EggressError);
84pyo3::create_exception!(_eggress, ShutdownError, EggressError);
85pyo3::create_exception!(_eggress, UnsupportedFeatureError, EggressError);
86pyo3::create_exception!(_eggress, InternalError, EggressError);
87pyo3::create_exception!(_eggress, ConnectionError, EggressError);
88pyo3::create_exception!(_eggress, ConnectionClosedError, EggressError);
89pyo3::create_exception!(_eggress, TimeoutError, EggressError);
90pyo3::create_exception!(_eggress, DnsError, EggressError);
91pyo3::create_exception!(_eggress, AuthError, EggressError);
92pyo3::create_exception!(_eggress, TlsError, EggressError);
93pyo3::create_exception!(_eggress, LoopMismatchError, EggressError);
94pyo3::create_exception!(_eggress, ConnectionCancelledError, EggressError);
95pyo3::create_exception!(_eggress, UseAfterCloseError, EggressError);
96pyo3::create_exception!(_eggress, UdpAssociationError, EggressError);
97pyo3::create_exception!(_eggress, UnsupportedCompositionError, EggressError);
98
99fn map_error(_py: Python<'_>, err: eggress_embed::EggressError) -> PyErr {
100 use eggress_embed::EggressError as E;
101 let msg = err.to_string();
102 match err {
103 E::Config(_) => ConfigError::new_err(msg),
104 E::Runtime(_) => InternalError::new_err(msg),
105 E::Startup(_) => StartupError::new_err(msg),
106 E::Reload(_) => ReloadError::new_err(msg),
107 E::Shutdown(_) => ShutdownError::new_err(msg),
108 E::UnsupportedFeature { .. } => UnsupportedFeatureError::new_err(msg),
109 E::Internal(_) => InternalError::new_err(msg),
110 }
111}
112
113#[pyclass]
114struct PyEggressConfig {
115 inner: eggress_embed::EggressConfig,
116}
117
118#[pymethods]
119impl PyEggressConfig {
120 #[staticmethod]
121 fn from_toml(py: Python<'_>, toml_str: &str) -> PyResult<Self> {
122 let config = py
123 .detach(|| eggress_embed::EggressConfig::from_toml_str(toml_str))
124 .map_err(|e| map_error(py, e))?;
125 Ok(Self { inner: config })
126 }
127
128 #[staticmethod]
129 fn from_file(py: Python<'_>, path: &str) -> PyResult<Self> {
130 let config = py
131 .detach(|| eggress_embed::EggressConfig::from_toml_file(path))
132 .map_err(|e| map_error(py, e))?;
133 Ok(Self { inner: config })
134 }
135
136 fn redacted_toml(&self, py: Python<'_>) -> PyResult<String> {
137 py.detach(|| self.inner.to_redacted_toml())
138 .map_err(|e| map_error(py, e))
139 }
140}
141
142#[pyclass]
143struct PyEggressService {
144 inner: Option<eggress_embed::EggressService>,
145}
146
147#[pymethods]
148impl PyEggressService {
149 #[new]
150 fn new(_py: Python<'_>, config: &PyEggressConfig) -> Self {
151 Self {
152 inner: Some(eggress_embed::EggressService::new(config.inner.clone())),
153 }
154 }
155
156 #[staticmethod]
157 fn from_toml(py: Python<'_>, toml_str: &str) -> PyResult<Self> {
158 let svc = py
159 .detach(|| eggress_embed::EggressService::from_toml_str(toml_str))
160 .map_err(|e| map_error(py, e))?;
161 Ok(Self { inner: Some(svc) })
162 }
163
164 #[staticmethod]
165 fn from_file(py: Python<'_>, path: &str) -> PyResult<Self> {
166 let svc = py
167 .detach(|| eggress_embed::EggressService::from_toml_file(path))
168 .map_err(|e| map_error(py, e))?;
169 Ok(Self { inner: Some(svc) })
170 }
171
172 fn start(&mut self, py: Python<'_>) -> PyResult<PyEggressHandle> {
173 let svc = self
174 .inner
175 .take()
176 .ok_or_else(|| EggressError::new_err("service already started"))?;
177 let handle = py
178 .detach(|| svc.start_blocking())
179 .map_err(|e| map_error(py, e))?;
180 Ok(PyEggressHandle {
181 inner: Some(handle),
182 })
183 }
184
185 fn start_with_compatibility_options(
188 &mut self,
189 py: Python<'_>,
190 auth_timeout_seconds: u64,
191 system_proxy: bool,
192 debug: bool,
193 verbose_level: u8,
194 ) -> PyResult<PyEggressHandle> {
195 let svc = self
196 .inner
197 .take()
198 .ok_or_else(|| EggressError::new_err("service already started"))?;
199 let options = eggress_runtime::CompatibilityOptions {
200 auth_timeout: Some(Duration::from_secs(auth_timeout_seconds)),
201 system_proxy,
202 debug,
203 verbose_level,
204 };
205 let handle = py
206 .detach(|| svc.start_blocking_with_compatibility_options(options))
207 .map_err(|e| map_error(py, e))?;
208 Ok(PyEggressHandle {
209 inner: Some(handle),
210 })
211 }
212}
213
214#[pyclass]
215struct PyEggressHandle {
216 inner: Option<eggress_embed::EggressHandle>,
217}
218
219#[pymethods]
220impl PyEggressHandle {
221 fn bound_addresses(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
222 let handle = self
223 .inner
224 .as_ref()
225 .ok_or_else(|| EggressError::new_err("handle consumed"))?;
226 let addrs = py.detach(|| handle.bound_addresses());
227 let dict = PyDict::new(py);
228 for la in &addrs.listeners {
229 dict.set_item(&la.name, la.addr.to_string())?;
230 }
231 if let Some(admin) = addrs.admin {
232 dict.set_item("_admin", admin.to_string())?;
233 }
234 Ok(dict.into())
235 }
236
237 fn status(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
238 let handle = self
239 .inner
240 .as_ref()
241 .ok_or_else(|| EggressError::new_err("handle consumed"))?;
242 let st = py.detach(|| handle.status());
243 let dict = PyDict::new(py);
244 dict.set_item("generation", st.generation)?;
245 dict.set_item("readiness", st.readiness)?;
246 dict.set_item("active_connections", st.active_connections)?;
247 dict.set_item("uptime_secs", st.uptime_secs)?;
248 dict.set_item("listener_count", st.listener_count)?;
249 dict.set_item("udp_associations_active", st.udp_associations_active)?;
250 dict.set_item("upstream_count", st.upstream_count)?;
251 let py_listeners = PyList::empty(py);
252 for ls in &st.listeners {
253 let ldict = PyDict::new(py);
254 ldict.set_item("name", &ls.name)?;
255 ldict.set_item("bind", &ls.bind)?;
256 ldict.set_item("local_addr", ls.local_addr.to_string())?;
257 ldict.set_item("protocols", &ls.protocols)?;
258 ldict.set_item("udp_enabled", ls.udp_enabled)?;
259 py_listeners.append(ldict)?;
260 }
261 dict.set_item("listeners", py_listeners)?;
262 Ok(dict.into())
263 }
264
265 fn metrics_text(&self, py: Python<'_>) -> PyResult<String> {
266 let handle = self
267 .inner
268 .as_ref()
269 .ok_or_else(|| EggressError::new_err("handle consumed"))?;
270 py.detach(|| handle.metrics_text())
271 .map_err(|e| map_error(py, e))
272 }
273
274 fn reload_toml(&self, py: Python<'_>, toml_str: &str) -> PyResult<Py<PyDict>> {
275 let handle = self
276 .inner
277 .as_ref()
278 .ok_or_else(|| EggressError::new_err("handle consumed"))?;
279 let outcome = py
280 .detach(|| handle.reload_toml_str(toml_str))
281 .map_err(|e| map_error(py, e))?;
282 let dict = PyDict::new(py);
283 match outcome {
284 eggress_embed::ReloadOutcome::Applied {
285 generation,
286 upstreams,
287 } => {
288 dict.set_item("generation", generation)?;
289 dict.set_item("upstreams", upstreams)?;
290 }
291 }
292 Ok(dict.into())
293 }
294
295 fn shutdown(&mut self, py: Python<'_>) -> PyResult<()> {
296 if let Some(handle) = self.inner.take() {
297 py.detach(|| handle.shutdown_blocking())
298 .map_err(|e| map_error(py, e))?;
299 }
300 Ok(())
301 }
302
303 fn __enter__(slf: Py<Self>) -> Py<Self> {
304 slf
305 }
306
307 fn __exit__(
308 &mut self,
309 py: Python<'_>,
310 _exc_type: &Bound<'_, PyAny>,
311 _exc_value: &Bound<'_, PyAny>,
312 _traceback: &Bound<'_, PyAny>,
313 ) -> PyResult<bool> {
314 if let Some(handle) = self.inner.take() {
315 if let Err(e) = py.detach(|| handle.shutdown_blocking()) {
316 eprintln!("shutdown error in __exit__: {e}");
317 }
318 }
319 Ok(false)
320 }
321}
322
323const STATE_CREATED: u8 = 0;
324const STATE_CONNECTING: u8 = 1;
325const STATE_CONNECTED: u8 = 2;
326const STATE_CLOSING: u8 = 3;
327const STATE_CLOSED: u8 = 4;
328const STATE_FAILED: u8 = 5;
329
330#[pyclass]
331struct PyConnection {
332 state: Arc<AtomicU8>,
333 handle: Option<eggress_embed::EggressHandle>,
334 config_toml: String,
335 bound_addr: Option<String>,
336 remote_addr: Option<String>,
337 peername: Option<String>,
338 sockname: Option<String>,
339 error: Option<String>,
340}
341
342#[pymethods]
343impl PyConnection {
344 #[new]
345 #[pyo3(signature = (uris, /, *args))]
346 fn new(py: Python<'_>, uris: &Bound<'_, PySequence>, args: Vec<String>) -> PyResult<Self> {
347 let mut all_args: Vec<String> = Vec::new();
348 for i in 0..uris.len()? {
349 all_args.push(uris.get_item(i)?.extract::<String>()?);
350 }
351 all_args.extend(args);
352
353 if all_args.is_empty() {
354 return Err(ConnectionError::new_err(
355 "at least one URI argument is required",
356 ));
357 }
358
359 let parsed = eggress_pproxy_compat::PproxyArgs::parse(&all_args)
360 .map_err(|e| ConnectionError::new_err(format!("argument parse error: {e}")))?;
361
362 let output = py
363 .detach(|| eggress_pproxy_compat::translate_pproxy_args(&parsed))
364 .map_err(|e| ConnectionError::new_err(format!("translation failed: {e}")))?;
365
366 if output.has_unsupported() {
367 let features: Vec<_> = output.unsupported.iter().map(|u| u.feature).collect();
368 return Err(UnsupportedFeatureError::new_err(format!(
369 "unsupported features: {}",
370 features.join(", ")
371 )));
372 }
373
374 let config = eggress_embed::EggressConfig::from_toml_str(&output.toml)
375 .map_err(|e| ConnectionError::new_err(format!("config error: {e}")))?;
376 let service = eggress_embed::EggressService::new(config);
377 let handle = py
378 .detach(|| service.start_blocking())
379 .map_err(|e| ConnectionError::new_err(format!("startup failed: {e}")))?;
380
381 let addrs = handle.bound_addresses();
382 let bound = addrs.listeners.first().map(|l| l.addr.to_string());
383
384 PY_CONNECTION_TOTAL_CREATED.fetch_add(1, Ordering::Relaxed);
385 PY_CONNECTION_LIVE_COUNT.fetch_add(1, Ordering::Relaxed);
386
387 Ok(Self {
388 state: Arc::new(AtomicU8::new(STATE_CREATED)),
389 handle: Some(handle),
390 config_toml: output.toml,
391 bound_addr: bound,
392 remote_addr: None,
393 peername: None,
394 sockname: None,
395 error: None,
396 })
397 }
398
399 #[getter]
400 fn state(&self) -> &str {
401 match self.state.load(Ordering::Acquire) {
402 STATE_CREATED => "created",
403 STATE_CONNECTING => "connecting",
404 STATE_CONNECTED => "connected",
405 STATE_CLOSING => "closing",
406 STATE_CLOSED => "closed",
407 STATE_FAILED => "failed",
408 _ => "unknown",
409 }
410 }
411
412 #[getter]
413 fn closed(&self) -> bool {
414 matches!(
415 self.state.load(Ordering::Acquire),
416 STATE_CLOSED | STATE_FAILED
417 )
418 }
419
420 #[getter]
421 fn config(&self) -> &str {
422 &self.config_toml
423 }
424
425 #[getter]
426 fn peername(&self) -> Option<&str> {
427 self.peername.as_deref()
428 }
429
430 #[getter]
431 fn sockname(&self) -> Option<&str> {
432 self.sockname.as_deref().or(self.bound_addr.as_deref())
433 }
434
435 #[getter]
436 fn extra_info(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
437 let dict = PyDict::new(py);
438 dict.set_item("state", self.state())?;
439 if let Some(ref addr) = self.bound_addr {
440 dict.set_item("bound_addr", addr)?;
441 }
442 if let Some(ref addr) = self.remote_addr {
443 dict.set_item("remote_addr", addr)?;
444 }
445 if let Some(ref err) = self.error {
446 dict.set_item("error", err)?;
447 }
448 Ok(dict.into())
449 }
450
451 fn close(&mut self, py: Python<'_>) -> PyResult<()> {
452 if !begin_close(&self.state) {
453 return Ok(());
454 }
455 if let Some(handle) = self.handle.take() {
456 py.detach(|| handle.shutdown_blocking())
457 .map_err(|e| ConnectionError::new_err(format!("shutdown error: {e}")))?;
458 }
459 self.state.store(STATE_CLOSED, Ordering::Release);
460 PY_CONNECTION_LIVE_COUNT.fetch_sub(1, Ordering::Relaxed);
461 Ok(())
462 }
463
464 fn wait_closed(&mut self, py: Python<'_>) -> PyResult<()> {
465 let current = self.state.load(Ordering::Acquire);
466 if current == STATE_CLOSED || current == STATE_FAILED {
467 return Ok(());
468 }
469 self.close(py)?;
470 Ok(())
471 }
472
473 fn __enter__(slf: Py<Self>) -> Py<Self> {
474 slf
475 }
476
477 fn __exit__(
478 &mut self,
479 py: Python<'_>,
480 _exc_type: &Bound<'_, PyAny>,
481 _exc_value: &Bound<'_, PyAny>,
482 _traceback: &Bound<'_, PyAny>,
483 ) -> PyResult<bool> {
484 self.close(py)?;
485 Ok(false)
486 }
487
488 fn __del__(&mut self, py: Python<'_>) {
489 if !begin_close(&self.state) {
490 return;
491 }
492 eprintln!(
493 "Warning: Connection object was not properly closed. Calling close() in __del__."
494 );
495 if let Some(handle) = self.handle.take() {
496 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
497 py.detach(|| handle.shutdown_blocking())
498 }));
499 match result {
500 Ok(Ok(())) => {}
501 Ok(Err(error)) => eprintln!("shutdown error in __del__: {error}"),
502 Err(_) => eprintln!("shutdown panicked in __del__"),
503 }
504 }
505 self.state.store(STATE_CLOSED, Ordering::Release);
506 PY_CONNECTION_LIVE_COUNT.fetch_sub(1, Ordering::Relaxed);
507 }
508
509 fn __repr__(&self) -> String {
510 format!(
511 "Connection(state='{}', bound='{}')",
512 self.state(),
513 self.bound_addr.as_deref().unwrap_or("None")
514 )
515 }
516
517 #[staticmethod]
518 fn connection_stats(py: Python<'_>) -> PyResult<Py<PyDict>> {
519 let dict = PyDict::new(py);
520 dict.set_item("live", PY_CONNECTION_LIVE_COUNT.load(Ordering::Relaxed))?;
521 dict.set_item(
522 "total_created",
523 PY_CONNECTION_TOTAL_CREATED.load(Ordering::Relaxed),
524 )?;
525 Ok(dict.into())
526 }
527
528 #[staticmethod]
529 fn reset_connection_stats() {
530 PY_CONNECTION_LIVE_COUNT.store(0, Ordering::Relaxed);
531 PY_CONNECTION_TOTAL_CREATED.store(0, Ordering::Relaxed);
532 }
533}
534
535fn begin_close(state: &AtomicU8) -> bool {
536 loop {
537 let current = state.load(Ordering::Acquire);
538 if current == STATE_CLOSED || current == STATE_FAILED || current == STATE_CLOSING {
539 return false;
540 }
541 match state.compare_exchange(current, STATE_CLOSING, Ordering::AcqRel, Ordering::Acquire) {
542 Ok(_) => return true,
543 Err(_) => continue,
544 }
545 }
546}
547
548#[pyclass(skip_from_py_object)]
551#[derive(Clone)]
552struct PyUriInfo {
553 scheme: String,
554 host: String,
555 port: u16,
556 tls: bool,
557 ssl: bool,
558 inbound: bool,
559 backward_num: u32,
560 has_auth: bool,
561 has_rule: bool,
562 is_reverse_listener: bool,
563 redacted_display: String,
564 error: Option<String>,
565}
566
567#[pymethods]
568impl PyUriInfo {
569 #[getter]
570 fn scheme(&self) -> &str {
571 &self.scheme
572 }
573 #[getter]
574 fn host(&self) -> &str {
575 &self.host
576 }
577 #[getter]
578 fn port(&self) -> u16 {
579 self.port
580 }
581 #[getter]
582 fn tls(&self) -> bool {
583 self.tls
584 }
585 #[getter]
586 fn ssl(&self) -> bool {
587 self.ssl
588 }
589 #[getter]
590 fn inbound(&self) -> bool {
591 self.inbound
592 }
593 #[getter]
594 fn backward_num(&self) -> u32 {
595 self.backward_num
596 }
597 #[getter]
598 fn has_auth(&self) -> bool {
599 self.has_auth
600 }
601 #[getter]
602 fn has_rule(&self) -> bool {
603 self.has_rule
604 }
605 #[getter]
606 fn is_reverse_listener(&self) -> bool {
607 self.is_reverse_listener
608 }
609 #[getter]
610 fn redacted_display(&self) -> &str {
611 &self.redacted_display
612 }
613 #[getter]
614 fn error(&self) -> Option<&str> {
615 self.error.as_deref()
616 }
617
618 fn __repr__(&self) -> String {
619 match &self.error {
620 Some(e) => format!("UriInfo(error='{}')", e),
621 None => format!(
622 "UriInfo(scheme='{}', host='{}', port={}, tls={})",
623 self.scheme, self.host, self.port, self.tls
624 ),
625 }
626 }
627}
628
629#[pyfunction]
630fn check_pproxy_uri(uri: &str) -> PyUriInfo {
631 match eggress_pproxy_compat::uri::parse_pproxy_uri(uri) {
632 Ok(parsed) => PyUriInfo {
633 scheme: parsed.scheme.clone(),
634 host: parsed.host.clone(),
635 port: parsed.port,
636 tls: parsed.tls,
637 ssl: parsed.ssl,
638 inbound: parsed.inbound,
639 backward_num: parsed.backward_num,
640 has_auth: parsed.username.is_some(),
641 has_rule: parsed.rule.is_some(),
642 is_reverse_listener: parsed.is_reverse_listener(),
643 redacted_display: parsed.redacted_display(),
644 error: None,
645 },
646 Err(e) => PyUriInfo {
647 scheme: String::new(),
648 host: String::new(),
649 port: 0,
650 tls: false,
651 ssl: false,
652 inbound: false,
653 backward_num: 0,
654 has_auth: false,
655 has_rule: false,
656 is_reverse_listener: false,
657 redacted_display: String::new(),
658 error: Some(e.to_string()),
659 },
660 }
661}
662
663#[pyfunction]
664fn redact_pproxy_uri(uri: &str) -> PyResult<String> {
665 let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri(uri)
666 .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid pproxy URI: {e}")))?;
667 Ok(parsed.redacted_display())
668}
669
670#[pyclass(skip_from_py_object)]
673#[derive(Clone)]
674struct PyDiagnostic {
675 code: String,
676 feature_id: Option<String>,
677 tier: Option<String>,
678 message: String,
679 suggestion: Option<String>,
680}
681
682#[pymethods]
683impl PyDiagnostic {
684 #[getter]
685 fn code(&self) -> &str {
686 &self.code
687 }
688 #[getter]
689 fn feature_id(&self) -> Option<&str> {
690 self.feature_id.as_deref()
691 }
692 #[getter]
693 fn tier(&self) -> Option<&str> {
694 self.tier.as_deref()
695 }
696 #[getter]
697 fn message(&self) -> &str {
698 &self.message
699 }
700 #[getter]
701 fn suggestion(&self) -> Option<&str> {
702 self.suggestion.as_deref()
703 }
704
705 fn __repr__(&self) -> String {
706 format!("[{}] {}", self.code, self.message)
707 }
708}
709
710#[pyfunction]
711fn diagnostics_for_uri(py: Python<'_>, uri: &str) -> PyResult<Vec<PyDiagnostic>> {
712 let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri(uri)
713 .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid pproxy URI: {e}")))?;
714
715 let mut diagnostics: Vec<PyDiagnostic> = Vec::new();
716
717 let output = py
718 .detach(|| {
719 eggress_pproxy_compat::translate_from_uris(
720 &eggress_pproxy_compat::PproxyArgs::default_args(),
721 &[parsed],
722 &[],
723 )
724 })
725 .map_err(|e| UnsupportedFeatureError::new_err(format!("translation failed: {e}")))?;
726
727 for warn in &output.warnings {
728 let sd = eggress_pproxy_compat::StructuredDiagnostic::from(warn);
729 diagnostics.push(PyDiagnostic {
730 code: sd.code.to_string(),
731 feature_id: sd.feature_id,
732 tier: sd.tier,
733 message: sd.message,
734 suggestion: sd.suggestion,
735 });
736 }
737 for u in &output.unsupported {
738 diagnostics.push(PyDiagnostic {
739 code: "unsupported_protocol".to_string(),
740 feature_id: Some(u.feature.to_string()),
741 tier: Some("unsupported".to_string()),
742 message: u.detail.clone(),
743 suggestion: None,
744 });
745 }
746
747 Ok(diagnostics)
748}
749
750#[pyfunction]
751fn supported_features() -> Vec<&'static str> {
752 vec![
753 "http",
754 "socks4",
755 "socks4a",
756 "socks5",
757 "shadowsocks",
758 "trojan",
759 "redir",
760 "unix",
761 "bind",
762 "listen",
763 "backward",
764 "rebind",
765 "direct",
766 ]
767}
768
769fn parse_toml_config(py: Python<'_>, toml_str: &str) -> PyResult<Py<PyDict>> {
772 let parsed: toml::Value = py
773 .detach(|| toml::from_str(toml_str))
774 .map_err(|e| ConfigError::new_err(format!("failed to parse TOML: {e}")))?;
775
776 let dict = PyDict::new(py);
777
778 let listeners_list = PyList::empty(py);
780 if let Some(listeners) = parsed.get("listeners").and_then(|v| v.as_array()) {
781 for l in listeners {
782 let ldict = PyDict::new(py);
783 if let Some(name) = l.get("name").and_then(|v| v.as_str()) {
784 ldict.set_item("name", name)?;
785 }
786 if let Some(bind) = l.get("bind").and_then(|v| v.as_str()) {
787 ldict.set_item("bind", bind)?;
788 }
789 if let Some(protocols) = l.get("protocols").and_then(|v| v.as_array()) {
790 let py_protos = PyList::empty(py);
791 for p in protocols {
792 if let Some(s) = p.as_str() {
793 py_protos.append(s)?;
794 }
795 }
796 ldict.set_item("protocols", py_protos)?;
797 }
798 if l.get("udp").is_some() {
799 ldict.set_item("udp_enabled", true)?;
800 }
801 if l.get("tls").is_some() {
802 ldict.set_item("tls", true)?;
803 }
804 if l.get("transparent").is_some() {
805 ldict.set_item("transparent", true)?;
806 }
807 if let Some(unix) = l.get("unix") {
808 ldict.set_item("unix_socket", true)?;
809 if let Some(path) = unix.get("path").and_then(|v| v.as_str()) {
810 ldict.set_item("unix_path", path)?;
811 }
812 }
813 listeners_list.append(ldict)?;
814 }
815 }
816 dict.set_item("listeners", listeners_list)?;
817
818 let upstreams_list = PyList::empty(py);
820 if let Some(upstreams) = parsed.get("upstreams").and_then(|v| v.as_array()) {
821 for u in upstreams {
822 let udict = PyDict::new(py);
823 if let Some(id) = u.get("id").and_then(|v| v.as_str()) {
824 udict.set_item("id", id)?;
825 }
826 if let Some(uri) = u.get("uri").and_then(|v| v.as_str()) {
827 let redacted = redact_config_uri(uri);
829 udict.set_item("uri", redacted)?;
830 }
831 upstreams_list.append(udict)?;
832 }
833 }
834 dict.set_item("upstreams", upstreams_list)?;
835
836 let groups_list = PyList::empty(py);
838 if let Some(groups) = parsed.get("upstream_groups").and_then(|v| v.as_array()) {
839 for g in groups {
840 let gdict = PyDict::new(py);
841 if let Some(id) = g.get("id").and_then(|v| v.as_str()) {
842 gdict.set_item("id", id)?;
843 }
844 if let Some(scheduler) = g.get("scheduler").and_then(|v| v.as_str()) {
845 gdict.set_item("scheduler", scheduler)?;
846 }
847 if let Some(members) = g.get("members").and_then(|v| v.as_array()) {
848 let py_members = PyList::empty(py);
849 for m in members {
850 if let Some(s) = m.as_str() {
851 py_members.append(s)?;
852 }
853 }
854 gdict.set_item("members", py_members)?;
855 }
856 groups_list.append(gdict)?;
857 }
858 }
859 dict.set_item("upstream_groups", groups_list)?;
860
861 let rules_list = PyList::empty(py);
863 if let Some(rules) = parsed.get("rules").and_then(|v| v.as_array()) {
864 for r in rules {
865 let rdict = PyDict::new(py);
866 if let Some(id) = r.get("id").and_then(|v| v.as_str()) {
867 rdict.set_item("id", id)?;
868 }
869 if let Some(ug) = r.get("upstream_group").and_then(|v| v.as_str()) {
870 rdict.set_item("upstream_group", ug)?;
871 }
872 if r.get("direct").and_then(|v| v.as_bool()) == Some(true) {
873 rdict.set_item("action", "direct")?;
874 } else if let Some(reject) = r.get("reject").and_then(|v| v.as_str()) {
875 rdict.set_item("action", format!("reject({})", reject))?;
876 } else if r.get("upstream_group").is_some() {
877 rdict.set_item("action", "upstream")?;
878 }
879 if r.get("match").is_some() {
880 rdict.set_item("has_match", true)?;
881 } else if r.get("any").and_then(|v| v.as_bool()) == Some(true) {
882 rdict.set_item("match_all", true)?;
883 }
884 rules_list.append(rdict)?;
885 }
886 }
887 dict.set_item("rules", rules_list)?;
888
889 let reverse_servers_list = PyList::empty(py);
891 if let Some(servers) = parsed.get("reverse_servers").and_then(|v| v.as_array()) {
892 for s in servers {
893 let sdict = PyDict::new(py);
894 if let Some(id) = s.get("id").and_then(|v| v.as_str()) {
895 sdict.set_item("id", id)?;
896 }
897 if let Some(bind) = s.get("control_bind").and_then(|v| v.as_str()) {
898 sdict.set_item("control_bind", bind)?;
899 }
900 reverse_servers_list.append(sdict)?;
901 }
902 }
903 dict.set_item("reverse_servers", reverse_servers_list)?;
904
905 let reverse_clients_list = PyList::empty(py);
907 if let Some(clients) = parsed.get("reverse_clients").and_then(|v| v.as_array()) {
908 for c in clients {
909 let cdict = PyDict::new(py);
910 if let Some(id) = c.get("id").and_then(|v| v.as_str()) {
911 cdict.set_item("id", id)?;
912 }
913 if let Some(addr) = c.get("server_addr").and_then(|v| v.as_str()) {
914 cdict.set_item("server_addr", addr)?;
915 }
916 reverse_clients_list.append(cdict)?;
917 }
918 }
919 dict.set_item("reverse_clients", reverse_clients_list)?;
920
921 let security_list = PyList::empty(py);
923 if let Some(listeners) = parsed.get("listeners").and_then(|v| v.as_array()) {
925 for l in listeners {
926 if l.get("auth").is_some() {
927 security_list.append("listener has plaintext auth credentials in TOML")?;
928 }
929 if l.get("shadowsocks").is_some() {
930 security_list.append("listener has Shadowsocks credentials in TOML")?;
931 }
932 }
933 }
934 if let Some(servers) = parsed.get("reverse_servers").and_then(|v| v.as_array()) {
935 for s in servers {
936 if s.get("auth_password").is_some() {
937 security_list.append("reverse server has plaintext credentials in TOML")?;
938 }
939 }
940 }
941 if let Some(clients) = parsed.get("reverse_clients").and_then(|v| v.as_array()) {
942 for c in clients {
943 if c.get("auth_password").is_some() {
944 security_list.append("reverse client has plaintext credentials in TOML")?;
945 }
946 }
947 }
948 if let Some(listeners) = parsed.get("listeners").and_then(|v| v.as_array()) {
949 for l in listeners {
950 if l.get("transparent").is_some() {
951 security_list.append("transparent proxy listener requires elevated privileges")?;
952 }
953 }
954 }
955 dict.set_item("security_notes", security_list)?;
956
957 Ok(dict.into())
958}
959
960fn redact_config_uri(uri: &str) -> String {
965 let Some(scheme_end) = uri.find("://") else {
966 return uri.to_string();
967 };
968 let after_scheme = &uri[scheme_end + 3..];
969 let mut last_at: Option<usize> = None;
970 let mut bracket_depth = 0u32;
971 for (i, c) in after_scheme.char_indices() {
972 match c {
973 '[' => bracket_depth += 1,
974 ']' => bracket_depth = bracket_depth.saturating_sub(1),
975 '@' if bracket_depth == 0 => last_at = Some(i),
976 _ => {}
977 }
978 }
979 if let Some(at_pos) = last_at {
980 return format!(
981 "{}****@{}",
982 &uri[..scheme_end + 3],
983 &after_scheme[at_pos + 1..]
984 );
985 }
986 uri.to_string()
987}
988
989#[pyfunction]
990fn explain_config_toml(py: Python<'_>, toml_str: &str) -> PyResult<Py<PyDict>> {
991 parse_toml_config(py, toml_str)
992}
993
994#[pyfunction]
995fn explain_pproxy_args(py: Python<'_>, args: &Bound<'_, PySequence>) -> PyResult<Py<PyDict>> {
996 let result = translate_pproxy_args(py, args)?;
997 let toml_str = result.output.toml.clone();
998 let warnings: Vec<(String, String)> = result
999 .output
1000 .warnings
1001 .iter()
1002 .map(|w| (w.category.to_string(), w.message.clone()))
1003 .collect();
1004 let unsupported: Vec<(String, String)> = result
1005 .output
1006 .unsupported
1007 .iter()
1008 .map(|u| (u.feature.to_string(), u.detail.clone()))
1009 .collect();
1010 let is_ok = !result.output.has_unsupported();
1011
1012 let dict = parse_toml_config(py, &toml_str)?;
1013
1014 let warnings_list = PyList::empty(py);
1015 for (cat, msg) in &warnings {
1016 let wdict = PyDict::new(py);
1017 wdict.set_item("category", cat.as_str())?;
1018 wdict.set_item("message", msg.as_str())?;
1019 warnings_list.append(wdict)?;
1020 }
1021 dict.bind(py).set_item("warnings", warnings_list)?;
1022
1023 let unsupported_list = PyList::empty(py);
1024 for (feat, detail) in &unsupported {
1025 let udict = PyDict::new(py);
1026 udict.set_item("feature", feat.as_str())?;
1027 udict.set_item("detail", detail.as_str())?;
1028 unsupported_list.append(udict)?;
1029 }
1030 dict.bind(py).set_item("unsupported", unsupported_list)?;
1031
1032 dict.bind(py).set_item("toml", &toml_str)?;
1033 dict.bind(py).set_item("ok", is_ok)?;
1034
1035 Ok(dict)
1036}
1037
1038#[pyfunction]
1039fn explain_pproxy_uri(py: Python<'_>, uri: &str) -> PyResult<Py<PyDict>> {
1040 let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri(uri)
1041 .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid pproxy URI: {e}")))?;
1042
1043 let output = py
1044 .detach(|| {
1045 eggress_pproxy_compat::translate_from_uris(
1046 &eggress_pproxy_compat::PproxyArgs::default_args(),
1047 &[parsed],
1048 &[],
1049 )
1050 })
1051 .map_err(|e| UnsupportedFeatureError::new_err(format!("translation failed: {e}")))?;
1052
1053 let dict = parse_toml_config(py, &output.toml)?;
1054
1055 let warnings_list = PyList::empty(py);
1056 for w in &output.warnings {
1057 let wdict = PyDict::new(py);
1058 wdict.set_item("category", w.category)?;
1059 wdict.set_item("message", &w.message)?;
1060 warnings_list.append(wdict)?;
1061 }
1062 dict.bind(py).set_item("warnings", warnings_list)?;
1063
1064 let unsupported_list = PyList::empty(py);
1065 for u in &output.unsupported {
1066 let udict = PyDict::new(py);
1067 udict.set_item("feature", u.feature)?;
1068 udict.set_item("detail", &u.detail)?;
1069 unsupported_list.append(udict)?;
1070 }
1071 dict.bind(py).set_item("unsupported", unsupported_list)?;
1072
1073 dict.bind(py).set_item("toml", &output.toml)?;
1074 dict.bind(py).set_item("ok", !output.has_unsupported())?;
1075
1076 Ok(dict)
1077}
1078
1079#[pyfunction]
1080fn route_explain(py: Python<'_>, toml_str: &str, target: &str) -> PyResult<Py<PyDict>> {
1081 use eggress_config::compile::compile_config;
1082 use eggress_config::model::ConfigFile;
1083 use eggress_core::{ClientIdentity, ProtocolId, TargetAddr};
1084 use eggress_routing::{RouteRequest, Router, TransportKind};
1085
1086 let target_addr: TargetAddr = target
1087 .parse()
1088 .map_err(|e: String| PyValueError::new_err(format!("invalid target: {e}")))?;
1089
1090 let explanation = py
1091 .detach(|| -> Result<_, String> {
1092 let config: ConfigFile =
1093 toml::from_str(toml_str).map_err(|e| format!("failed to parse TOML: {e}"))?;
1094
1095 let runtime_config =
1096 compile_config(&config).map_err(|e| format!("failed to compile config: {e}"))?;
1097
1098 let router =
1099 Router::with_groups(runtime_config.rules, runtime_config.default_action, vec![]);
1100
1101 let request = RouteRequest {
1102 target: &target_addr,
1103 source: None,
1104 listener: "",
1105 inbound_protocol: ProtocolId::Socks5,
1106 identity: &ClientIdentity::Anonymous,
1107 transport: TransportKind::Tcp,
1108 };
1109
1110 Ok(router.explain(&request, 0))
1111 })
1112 .map_err(ConfigError::new_err)?;
1113
1114 let dict = PyDict::new(py);
1115 dict.set_item("target", &explanation.target)?;
1116 dict.set_item("listener", &explanation.listener)?;
1117 dict.set_item("protocol", &explanation.protocol)?;
1118 dict.set_item("transport", &explanation.transport)?;
1119 dict.set_item("matched_rule", explanation.matched_rule)?;
1120 dict.set_item("action", &explanation.action)?;
1121 dict.set_item("upstream_group", explanation.upstream_group)?;
1122 dict.set_item("scheduler", explanation.scheduler)?;
1123 let eligible_list = PyList::empty(py);
1124 for u in &explanation.eligible_upstreams {
1125 let udict = PyDict::new(py);
1126 udict.set_item("id", &u.id)?;
1127 udict.set_item("health", &u.health)?;
1128 udict.set_item("eligible", u.eligible)?;
1129 udict.set_item("active", u.active)?;
1130 udict.set_item("in_flight", u.in_flight)?;
1131 eligible_list.append(udict)?;
1132 }
1133 dict.set_item("eligible_upstreams", eligible_list)?;
1134 dict.set_item("selected_upstream", explanation.selected_upstream)?;
1135 dict.set_item("chain", explanation.chain)?;
1136 dict.set_item("generation", explanation.generation)?;
1137
1138 Ok(dict.into())
1139}
1140
1141#[pyfunction]
1142fn test_upstream_connect(py: Python<'_>, uri: &str, timeout_secs: f64) -> PyResult<Py<PyDict>> {
1143 use std::net::ToSocketAddrs;
1144
1145 let dict = PyDict::new(py);
1146
1147 let url =
1149 url::Url::parse(uri).map_err(|e| PyValueError::new_err(format!("invalid URI: {e}")))?;
1150
1151 let host = url
1152 .host_str()
1153 .ok_or_else(|| PyValueError::new_err("URI has no host"))?
1154 .to_string();
1155 let port = url.port().unwrap_or(match url.scheme() {
1156 "socks5" => 1080,
1157 "socks4" | "socks4a" => 1080,
1158 "http" | "https" => 80,
1159 "ss" => 8388,
1160 "trojan" => 443,
1161 _ => 0,
1162 });
1163
1164 dict.set_item("host", &host)?;
1165 dict.set_item("port", port)?;
1166 dict.set_item("scheme", url.scheme())?;
1167
1168 let has_auth = !url.username().is_empty() || url.password().is_some();
1170 dict.set_item("has_auth", has_auth)?;
1171
1172 let redacted = if has_auth {
1174 format!("{}://****@{}:{}", url.scheme(), host, port)
1175 } else {
1176 format!("{}://{}:{}", url.scheme(), host, port)
1177 };
1178 dict.set_item("redacted_uri", &redacted)?;
1179
1180 let addr_str = format!("{}:{}", host, port);
1182 let (connected, latency_us, last_error): (bool, Option<u64>, Option<String>) =
1183 py.detach(|| {
1184 let std_duration = std::time::Duration::from_secs_f64(timeout_secs);
1185 let socket_addrs = match addr_str.to_socket_addrs() {
1186 Ok(addrs) => addrs,
1187 Err(e) => {
1188 return (false, None, Some(format!("DNS resolution failed: {e}")));
1189 }
1190 };
1191
1192 let mut last_error: Option<String> = None;
1193 for addr in socket_addrs {
1194 let start = std::time::Instant::now();
1195 match std::net::TcpStream::connect_timeout(&addr, std_duration) {
1196 Ok(_stream) => {
1197 return (true, Some(start.elapsed().as_micros() as u64), None);
1198 }
1199 Err(e) => {
1200 last_error = Some(format!("connect to {addr} failed: {e}"));
1201 }
1202 }
1203 }
1204 (false, None, last_error)
1205 });
1206
1207 dict.set_item("connected", connected)?;
1208 dict.set_item("latency_us", latency_us)?;
1209 dict.set_item("error", last_error)?;
1210
1211 Ok(dict.into())
1212}
1213
1214#[pyclass(skip_from_py_object)]
1217#[derive(Clone)]
1218struct PyTranslationWarning {
1219 inner: eggress_pproxy_compat::CompatWarning,
1220}
1221
1222#[pymethods]
1223impl PyTranslationWarning {
1224 #[getter]
1225 fn category(&self) -> &str {
1226 self.inner.category
1227 }
1228
1229 #[getter]
1230 fn message(&self) -> &str {
1231 &self.inner.message
1232 }
1233
1234 #[getter]
1235 fn tier(&self) -> &str {
1236 eggress_pproxy_compat::manifest_tier_for_category(self.inner.category).as_str()
1237 }
1238
1239 fn __repr__(&self) -> String {
1240 format!("[{}] {}", self.inner.category, self.inner.message)
1241 }
1242}
1243
1244#[pyclass(skip_from_py_object)]
1245#[derive(Clone)]
1246struct PyUnsupportedFeature {
1247 inner: eggress_pproxy_compat::UnsupportedFeature,
1248}
1249
1250#[pymethods]
1251impl PyUnsupportedFeature {
1252 #[getter]
1253 fn feature(&self) -> &str {
1254 self.inner.feature
1255 }
1256
1257 #[getter]
1258 fn message(&self) -> &str {
1259 &self.inner.detail
1260 }
1261
1262 #[getter]
1263 fn tier(&self) -> &str {
1264 eggress_pproxy_compat::classify_unsupported_feature_tier(self.inner.feature)
1265 }
1266
1267 fn __repr__(&self) -> String {
1268 format!("unsupported {}: {}", self.inner.feature, self.inner.detail)
1269 }
1270}
1271
1272#[pyclass]
1273struct PyTranslationResult {
1274 output: eggress_pproxy_compat::TranslationOutput,
1275}
1276
1277#[pymethods]
1278impl PyTranslationResult {
1279 #[getter]
1280 fn toml(&self) -> &str {
1281 &self.output.toml
1282 }
1283
1284 #[getter]
1285 fn warnings(&self, py: Python<'_>) -> PyResult<Py<PyList>> {
1286 let list = PyList::empty(py);
1287 for w in &self.output.warnings {
1288 list.append(PyTranslationWarning { inner: w.clone() })?;
1289 }
1290 Ok(list.into())
1291 }
1292
1293 #[getter]
1294 fn unsupported(&self, py: Python<'_>) -> PyResult<Py<PyList>> {
1295 let list = PyList::empty(py);
1296 for u in &self.output.unsupported {
1297 list.append(PyUnsupportedFeature { inner: u.clone() })?;
1298 }
1299 Ok(list.into())
1300 }
1301
1302 #[getter]
1303 fn ok(&self) -> bool {
1304 !self.output.has_unsupported()
1305 }
1306
1307 #[getter]
1314 fn tier(&self) -> &'static str {
1315 eggress_pproxy_compat::classify_aggregate_tier(
1316 &self.output.warnings,
1317 &self.output.unsupported,
1318 )
1319 .as_str()
1320 }
1321
1322 fn config(&self, py: Python<'_>) -> PyResult<PyEggressConfig> {
1323 let config = py
1324 .detach(|| eggress_embed::EggressConfig::from_toml_str(&self.output.toml))
1325 .map_err(|e| map_error(py, e))?;
1326 Ok(PyEggressConfig { inner: config })
1327 }
1328
1329 fn __repr__(&self) -> String {
1330 format!(
1331 "TranslationResult(warnings={}, unsupported={})",
1332 self.output.warnings.len(),
1333 self.output.unsupported.len()
1334 )
1335 }
1336}
1337
1338#[pyfunction]
1339fn translate_pproxy_args(
1340 py: Python<'_>,
1341 args: &Bound<'_, PySequence>,
1342) -> PyResult<PyTranslationResult> {
1343 let len = args.len()?;
1344 let raw: Vec<String> = (0..len)
1345 .map(|i| args.get_item(i)?.extract::<String>())
1346 .collect::<PyResult<_>>()?;
1347
1348 let parsed = eggress_pproxy_compat::PproxyArgs::parse(&raw).map_err(|e| {
1349 UnsupportedFeatureError::new_err(format!("failed to parse pproxy args: {e}"))
1350 })?;
1351
1352 let output = py
1353 .detach(|| eggress_pproxy_compat::translate_pproxy_args(&parsed))
1354 .map_err(|e| UnsupportedFeatureError::new_err(format!("translation failed: {e}")))?;
1355
1356 Ok(PyTranslationResult { output })
1357}
1358
1359#[pyfunction]
1360fn translate_pproxy_uri(
1361 py: Python<'_>,
1362 local: &str,
1363 remotes: Option<&Bound<'_, PySequence>>,
1364) -> PyResult<PyTranslationResult> {
1365 let local_uri = eggress_pproxy_compat::uri::parse_pproxy_uri(local)
1366 .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid local URI: {e}")))?;
1367
1368 let remote_chains: Vec<eggress_pproxy_compat::PproxyChain> = match remotes {
1369 Some(seq) => {
1370 let len = seq.len()?;
1371 (0..len)
1372 .map(|i| {
1373 let s: String = seq.get_item(i)?.extract()?;
1374 eggress_pproxy_compat::uri::parse_pproxy_chain(&s).map_err(|e| {
1375 UnsupportedFeatureError::new_err(format!("invalid remote URI: {e}"))
1376 })
1377 })
1378 .collect::<PyResult<_>>()?
1379 }
1380 None => Vec::new(),
1381 };
1382
1383 let output = py
1384 .detach(|| {
1385 eggress_pproxy_compat::translate_from_uris(
1386 &eggress_pproxy_compat::PproxyArgs::default_args(),
1387 &[local_uri],
1388 &remote_chains,
1389 )
1390 })
1391 .map_err(|e| UnsupportedFeatureError::new_err(format!("translation failed: {e}")))?;
1392
1393 Ok(PyTranslationResult { output })
1394}
1395
1396#[pyfunction]
1397fn check_pproxy_args(
1398 py: Python<'_>,
1399 args: &Bound<'_, PySequence>,
1400) -> PyResult<PyTranslationResult> {
1401 translate_pproxy_args(py, args)
1402}
1403
1404#[pyfunction]
1408fn validate_pproxy_args(args: &Bound<'_, PySequence>) -> PyResult<()> {
1409 let len = args.len()?;
1410 let raw: Vec<String> = (0..len)
1411 .map(|i| args.get_item(i)?.extract::<String>())
1412 .collect::<PyResult<_>>()?;
1413 let parsed = if raw.is_empty() {
1414 eggress_pproxy_compat::PproxyArgs::default_args()
1415 } else {
1416 eggress_pproxy_compat::PproxyArgs::parse(&raw)
1417 .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))?
1418 };
1419 if let Some(flag) = parsed.strict_parser_violations().first() {
1420 return Err(PyValueError::new_err(format!(
1421 "pproxy: unknown option or positional argument '{flag}'"
1422 )));
1423 }
1424 parsed
1425 .validate_strict_values()
1426 .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))
1427}
1428
1429#[pyfunction]
1433fn pproxy_runtime_options(args: &Bound<'_, PySequence>) -> PyResult<Py<PyDict>> {
1434 let len = args.len()?;
1435 let raw: Vec<String> = (0..len)
1436 .map(|i| args.get_item(i)?.extract::<String>())
1437 .collect::<PyResult<_>>()?;
1438 let parsed = if raw.is_empty() {
1439 eggress_pproxy_compat::PproxyArgs::default_args()
1440 } else {
1441 eggress_pproxy_compat::PproxyArgs::parse(&raw)
1442 .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))?
1443 };
1444 let dict = PyDict::new(args.py());
1445 dict.set_item(
1446 "auth_timeout_seconds",
1447 parsed.effective_auth_timeout().as_secs(),
1448 )?;
1449 dict.set_item("system_proxy", parsed.system_proxy)?;
1450 dict.set_item("debug", parsed.debug)?;
1451 dict.set_item("verbose_level", parsed.verbose_level)?;
1452 dict.set_item("default_log_level", parsed.default_log_level())?;
1453 Ok(dict.into())
1454}
1455
1456#[pyfunction]
1459fn init_pproxy_logging(default_level: &str) -> PyResult<()> {
1460 let filter = tracing_subscriber::EnvFilter::try_from_default_env()
1461 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level));
1462 let _ = tracing_subscriber::fmt()
1463 .with_env_filter(filter)
1464 .compact()
1465 .try_init();
1466 Ok(())
1467}
1468
1469#[pyfunction]
1471fn run_pproxy_test(py: Python<'_>, args: &Bound<'_, PySequence>, target: &str) -> PyResult<i32> {
1472 let len = args.len()?;
1473 let raw: Vec<String> = (0..len)
1474 .map(|i| args.get_item(i)?.extract::<String>())
1475 .collect::<PyResult<_>>()?;
1476 let parsed = if raw.is_empty() {
1477 eggress_pproxy_compat::PproxyArgs::default_args()
1478 } else {
1479 eggress_pproxy_compat::PproxyArgs::parse(&raw)
1480 .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))?
1481 };
1482 if let Some(flag) = parsed.strict_parser_violations().first() {
1483 return Err(PyValueError::new_err(format!(
1484 "pproxy: unknown option or positional argument '{flag}'"
1485 )));
1486 }
1487 parsed
1488 .validate_strict_values()
1489 .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))?;
1490 let output = eggress_pproxy_compat::translate_pproxy_args(&parsed)
1491 .map_err(|e| PyValueError::new_err(format!("pproxy translation error: {e}")))?;
1492 let gate = eggress_pproxy_compat::evaluate_execution_gate(&parsed, &output);
1493 if !gate.allows_start() {
1494 return Err(UnsupportedFeatureError::new_err(gate.blocker_summary()));
1495 }
1496 let (config, _) = eggress_config::validate_and_compile_toml_with_warnings(&output.toml)
1497 .map_err(|e| ConfigError::new_err(format!("pproxy config error: {e}")))?;
1498 let target = eggress_cli::parse_pproxy_test_target(target)
1499 .map_err(PyValueError::new_err)?
1500 .to_string();
1501 if config.upstreams.is_empty() {
1502 return Ok(0);
1503 }
1504 Ok(py.detach(|| {
1505 eggress_cli::run_upstream_test(&config, Some(&target), Duration::from_secs(10), false)
1506 }))
1507}
1508
1509#[pyclass]
1510struct PyReverseUriSummary {
1511 role: String,
1513 scheme: String,
1514 target: String,
1516 has_auth: bool,
1517 toml_section: String,
1519 tls: bool,
1520 modifiers: Vec<String>,
1522}
1523
1524#[pymethods]
1525impl PyReverseUriSummary {
1526 #[getter]
1527 fn role(&self) -> &str {
1528 &self.role
1529 }
1530 #[getter]
1531 fn scheme(&self) -> &str {
1532 &self.scheme
1533 }
1534 #[getter]
1535 fn target(&self) -> &str {
1536 &self.target
1537 }
1538 #[getter]
1539 fn has_auth(&self) -> bool {
1540 self.has_auth
1541 }
1542 #[getter]
1543 fn toml_section(&self) -> &str {
1544 &self.toml_section
1545 }
1546 #[getter]
1547 fn tls(&self) -> bool {
1548 self.tls
1549 }
1550 #[getter]
1551 fn modifiers(&self) -> Vec<String> {
1552 self.modifiers.clone()
1553 }
1554 fn __repr__(&self) -> String {
1555 format!(
1556 "ReverseUriSummary(role={}, target={}, toml_section={}, has_auth={})",
1557 self.role, self.target, self.toml_section, self.has_auth
1558 )
1559 }
1560}
1561
1562#[pyfunction]
1563fn describe_reverse_pproxy_uri(uri: &str) -> PyResult<PyReverseUriSummary> {
1564 let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri(uri)
1565 .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid pproxy URI: {e}")))?;
1566
1567 let (role, toml_section) = if parsed.is_reverse_listener() {
1568 ("server", "reverse_servers")
1569 } else if parsed.is_backward() {
1570 ("client", "reverse_clients")
1571 } else {
1572 ("unknown", "unknown")
1573 };
1574
1575 let target = parsed.redacted_display();
1576
1577 let mut modifiers: Vec<String> = Vec::new();
1579 if parsed.tls {
1580 modifiers.push("+tls".to_string());
1581 }
1582 if parsed.ssl {
1583 modifiers.push("+ssl".to_string());
1584 }
1585 for _ in 0..parsed.backward_num {
1586 modifiers.push("+in".to_string());
1587 }
1588
1589 Ok(PyReverseUriSummary {
1590 role: role.to_string(),
1591 scheme: parsed.scheme.clone(),
1592 target,
1593 has_auth: parsed.username.is_some(),
1594 toml_section: toml_section.to_string(),
1595 tls: parsed.tls,
1596 modifiers,
1597 })
1598}
1599
1600#[pyclass]
1610struct PyOutboundConnector {
1611 inner: eggress_embed::outbound::OutboundConnector,
1612}
1613
1614struct OutboundStreamState {
1615 stream: Option<eggress_core::BoxStream>,
1616}
1617
1618#[pyclass]
1626struct PyOutboundStream {
1627 runtime: Arc<tokio::runtime::Runtime>,
1628 state: std::sync::Mutex<OutboundStreamState>,
1629 peer_addr: Option<String>,
1630 local_addr: Option<String>,
1631 hop_count: usize,
1632}
1633
1634impl PyOutboundStream {
1635 fn with_stream<T>(
1636 &self,
1637 operation: impl FnOnce(&mut eggress_core::BoxStream) -> T,
1638 ) -> PyResult<T> {
1639 let mut state = self
1640 .state
1641 .lock()
1642 .map_err(|_| ConnectionError::new_err("outbound stream lock poisoned"))?;
1643 let stream = state
1644 .stream
1645 .as_mut()
1646 .ok_or_else(|| ConnectionClosedError::new_err("outbound stream is closed"))?;
1647 Ok(operation(stream))
1648 }
1649
1650 fn closed_inner(&self) -> bool {
1651 self.state
1652 .lock()
1653 .map(|state| state.stream.is_none())
1654 .unwrap_or(true)
1655 }
1656}
1657
1658impl Drop for PyOutboundStream {
1659 fn drop(&mut self) {
1660 if let Ok(mut state) = self.state.lock() {
1664 state.stream.take();
1665 }
1666 }
1667}
1668
1669#[pymethods]
1670impl PyOutboundStream {
1671 #[getter]
1672 fn closed(&self) -> bool {
1673 self.closed_inner()
1674 }
1675
1676 fn is_closing(&self) -> bool {
1677 self.closed_inner()
1678 }
1679
1680 #[getter]
1681 fn peername(&self) -> Option<&str> {
1682 self.peer_addr.as_deref()
1683 }
1684
1685 #[getter]
1686 fn sockname(&self) -> Option<&str> {
1687 self.local_addr.as_deref()
1688 }
1689
1690 fn get_extra_info(
1691 &self,
1692 py: Python<'_>,
1693 name: &str,
1694 default: Option<Py<PyAny>>,
1695 ) -> PyResult<Py<PyAny>> {
1696 match name {
1697 "peername" => match self.peer_addr.as_ref() {
1698 Some(value) => Ok(PyString::new(py, value).into_any().unbind()),
1699 None => Ok(default.unwrap_or_else(|| py.None())),
1700 },
1701 "sockname" => match self.local_addr.as_ref() {
1702 Some(value) => Ok(PyString::new(py, value).into_any().unbind()),
1703 None => Ok(default.unwrap_or_else(|| py.None())),
1704 },
1705 "hop_count" => {
1706 let hop_count = self.hop_count.to_string();
1707 Ok(PyString::new(py, &hop_count).into_any().unbind())
1708 }
1709 _ => Ok(default.unwrap_or_else(|| py.None())),
1710 }
1711 }
1712
1713 fn read(&self, py: Python<'_>, n: i64) -> PyResult<Vec<u8>> {
1714 if n < -1 {
1715 return Err(PyValueError::new_err(
1716 "read length must be -1 or non-negative",
1717 ));
1718 }
1719 let runtime = self.runtime.clone();
1720 self.with_stream(|stream| {
1721 py.detach(|| {
1722 runtime.block_on(async {
1723 if n == -1 {
1724 let mut data = Vec::new();
1725 stream.read_to_end(&mut data).await.map(|_| data)
1726 } else {
1727 let mut data = vec![0_u8; n as usize];
1728 let count = stream.read(&mut data).await?;
1729 data.truncate(count);
1730 Ok(data)
1731 }
1732 })
1733 })
1734 })?
1735 .map_err(|e: std::io::Error| ConnectionError::new_err(format!("read failed: {e}")))
1736 }
1737
1738 fn readexactly(&self, py: Python<'_>, n: usize) -> PyResult<Vec<u8>> {
1739 let runtime = self.runtime.clone();
1740 self.with_stream(|stream| {
1741 py.detach(|| {
1742 runtime.block_on(async {
1743 let mut data = vec![0_u8; n];
1744 stream.read_exact(&mut data).await.map(|_| data)
1745 })
1746 })
1747 })?
1748 .map_err(|e: std::io::Error| ConnectionError::new_err(format!("readexactly failed: {e}")))
1749 }
1750
1751 fn write(&self, py: Python<'_>, data: &[u8]) -> PyResult<usize> {
1752 let runtime = self.runtime.clone();
1753 self.with_stream(|stream| {
1754 py.detach(|| runtime.block_on(async { stream.write(data).await }))
1755 })?
1756 .map_err(|e: std::io::Error| ConnectionError::new_err(format!("write failed: {e}")))
1757 }
1758
1759 fn sendall(&self, py: Python<'_>, data: &[u8]) -> PyResult<()> {
1760 let runtime = self.runtime.clone();
1761 self.with_stream(|stream| {
1762 py.detach(|| runtime.block_on(async { stream.write_all(data).await }))
1763 })?
1764 .map_err(|e: std::io::Error| ConnectionError::new_err(format!("sendall failed: {e}")))
1765 }
1766
1767 fn drain(&self, py: Python<'_>) -> PyResult<()> {
1768 let runtime = self.runtime.clone();
1769 self.with_stream(|stream| py.detach(|| runtime.block_on(async { stream.flush().await })))?
1770 .map_err(|e: std::io::Error| ConnectionError::new_err(format!("drain failed: {e}")))
1771 }
1772
1773 fn write_eof(&self, py: Python<'_>) -> PyResult<()> {
1774 let runtime = self.runtime.clone();
1775 self.with_stream(|stream| {
1776 py.detach(|| runtime.block_on(async { stream.shutdown().await }))
1777 })?
1778 .map_err(|e: std::io::Error| ConnectionError::new_err(format!("write_eof failed: {e}")))
1779 }
1780
1781 fn close(&self) -> PyResult<()> {
1782 let mut state = self
1783 .state
1784 .lock()
1785 .map_err(|_| ConnectionError::new_err("outbound stream lock poisoned"))?;
1786 state.stream.take();
1787 Ok(())
1788 }
1789
1790 fn wait_closed(&self) -> PyResult<()> {
1791 self.close()
1792 }
1793
1794 fn __enter__(slf: Py<Self>) -> Py<Self> {
1795 slf
1796 }
1797
1798 fn __exit__(
1799 &self,
1800 _py: Python<'_>,
1801 _exc_type: &Bound<'_, PyAny>,
1802 _exc_value: &Bound<'_, PyAny>,
1803 _traceback: &Bound<'_, PyAny>,
1804 ) -> PyResult<bool> {
1805 self.close()?;
1806 Ok(false)
1807 }
1808
1809 fn __del__(&self) {
1810 if !self.closed_inner() {
1811 eprintln!("Warning: outbound stream was not explicitly closed; cleaning up");
1812 let _ = self.close();
1813 }
1814 }
1815
1816 fn __repr__(&self) -> String {
1817 format!(
1818 "OutboundStream(peername={:?}, hop_count={}, closed={})",
1819 self.peer_addr,
1820 self.hop_count,
1821 self.closed_inner()
1822 )
1823 }
1824}
1825
1826#[pymethods]
1827impl PyOutboundConnector {
1828 #[staticmethod]
1830 fn from_pproxy_uri(uri: &str) -> PyResult<Self> {
1831 let inner = eggress_embed::outbound::OutboundConnector::from_pproxy_uri(uri)
1832 .map_err(|e| ConnectionError::new_err(format!("failed to create connector: {e}")))?;
1833 Ok(Self { inner })
1834 }
1835
1836 #[staticmethod]
1838 fn from_toml(config_toml: &str) -> PyResult<Self> {
1839 let inner = eggress_embed::outbound::OutboundConnector::from_toml(config_toml)
1840 .map_err(|e| ConnectionError::new_err(format!("failed to create connector: {e}")))?;
1841 Ok(Self { inner })
1842 }
1843
1844 #[staticmethod]
1848 fn validate_config(config_toml: &str) -> PyResult<usize> {
1849 eggress_embed::outbound::OutboundConnector::validate_outbound_config(config_toml)
1850 .map_err(|e| ConnectionError::new_err(format!("config validation failed: {e}")))
1851 }
1852
1853 fn upstream_count(&self) -> usize {
1855 self.inner.upstream_count()
1856 }
1857
1858 #[pyo3(signature = (host, port, timeout=None))]
1860 fn connect_tcp(
1861 &self,
1862 py: Python<'_>,
1863 host: &str,
1864 port: u16,
1865 timeout: Option<f64>,
1866 ) -> PyResult<PyOutboundStream> {
1867 if host.is_empty() {
1868 return Err(PyValueError::new_err("host must not be empty"));
1869 }
1870 let timeout = timeout
1871 .map(|seconds| {
1872 if !seconds.is_finite() || seconds <= 0.0 {
1873 Err(PyValueError::new_err("timeout must be finite and positive"))
1874 } else {
1875 Ok(Duration::from_secs_f64(seconds))
1876 }
1877 })
1878 .transpose()?;
1879 let runtime = outbound_runtime().map_err(ConnectionError::new_err)?;
1880 let inner = &self.inner;
1881 let result = py.detach(|| {
1882 runtime.block_on(async {
1883 match timeout {
1884 Some(timeout) => inner.connect_tcp_timeout(host, port, timeout).await,
1885 None => inner.connect_tcp(host, port).await,
1886 }
1887 })
1888 });
1889 let (stream, info) = result
1890 .map_err(|e| ConnectionError::new_err(format!("outbound connect failed: {e}")))?;
1891 Ok(PyOutboundStream {
1892 runtime,
1893 state: std::sync::Mutex::new(OutboundStreamState {
1894 stream: Some(stream),
1895 }),
1896 peer_addr: info.peer_addr.map(|addr| addr.to_string()),
1897 local_addr: info.local_addr.map(|addr| addr.to_string()),
1898 hop_count: info.hop_count,
1899 })
1900 }
1901
1902 fn preview_connect(&self, py: Python<'_>, host: &str, port: u16) -> PyResult<Py<PyDict>> {
1907 let dict = PyDict::new(py);
1908 dict.set_item("target_host", host)?;
1909 dict.set_item("target_port", port)?;
1910 dict.set_item("hop_count", self.inner.upstream_count())?;
1911 Ok(dict.into())
1912 }
1913}
1914
1915#[pymodule]
1916fn _eggress(m: &Bound<'_, PyModule>) -> PyResult<()> {
1917 m.add_class::<PyEggressConfig>()?;
1918 m.add_class::<PyEggressService>()?;
1919 m.add_class::<PyEggressHandle>()?;
1920 m.add_class::<PyAppliedSystemProxy>()?;
1921 m.add_class::<PyTranslationWarning>()?;
1922 m.add_class::<PyUnsupportedFeature>()?;
1923 m.add_class::<PyTranslationResult>()?;
1924 m.add_class::<PyReverseUriSummary>()?;
1925 m.add_class::<PyUriInfo>()?;
1926 m.add_class::<PyDiagnostic>()?;
1927 m.add_class::<PyConnection>()?;
1928 m.add_class::<PyOutboundConnector>()?;
1929 m.add_class::<PyOutboundStream>()?;
1930 m.add_function(wrap_pyfunction!(translate_pproxy_args, m)?)?;
1931 m.add_function(wrap_pyfunction!(translate_pproxy_uri, m)?)?;
1932 m.add_function(wrap_pyfunction!(check_pproxy_args, m)?)?;
1933 m.add_function(wrap_pyfunction!(validate_pproxy_args, m)?)?;
1934 m.add_function(wrap_pyfunction!(pproxy_runtime_options, m)?)?;
1935 m.add_function(wrap_pyfunction!(init_pproxy_logging, m)?)?;
1936 m.add_function(wrap_pyfunction!(run_pproxy_test, m)?)?;
1937 m.add_function(wrap_pyfunction!(describe_reverse_pproxy_uri, m)?)?;
1938 m.add_function(wrap_pyfunction!(check_pproxy_uri, m)?)?;
1939 m.add_function(wrap_pyfunction!(redact_pproxy_uri, m)?)?;
1940 m.add_function(wrap_pyfunction!(diagnostics_for_uri, m)?)?;
1941 m.add_function(wrap_pyfunction!(supported_features, m)?)?;
1942 m.add_function(wrap_pyfunction!(explain_config_toml, m)?)?;
1943 m.add_function(wrap_pyfunction!(explain_pproxy_args, m)?)?;
1944 m.add_function(wrap_pyfunction!(explain_pproxy_uri, m)?)?;
1945 m.add_function(wrap_pyfunction!(route_explain, m)?)?;
1946 m.add_function(wrap_pyfunction!(test_upstream_connect, m)?)?;
1947 m.add_function(wrap_pyfunction!(apply_system_proxy, m)?)?;
1948 m.add("EggressError", m.py().get_type::<EggressError>())?;
1949 m.add("ConfigError", m.py().get_type::<ConfigError>())?;
1950 m.add("StartupError", m.py().get_type::<StartupError>())?;
1951 m.add("ReloadError", m.py().get_type::<ReloadError>())?;
1952 m.add("ShutdownError", m.py().get_type::<ShutdownError>())?;
1953 m.add(
1954 "UnsupportedFeatureError",
1955 m.py().get_type::<UnsupportedFeatureError>(),
1956 )?;
1957 m.add("InternalError", m.py().get_type::<InternalError>())?;
1958 m.add("ConnectionError", m.py().get_type::<ConnectionError>())?;
1959 m.add(
1960 "ConnectionClosedError",
1961 m.py().get_type::<ConnectionClosedError>(),
1962 )?;
1963 m.add("TimeoutError", m.py().get_type::<TimeoutError>())?;
1964 m.add("DnsError", m.py().get_type::<DnsError>())?;
1965 m.add("AuthError", m.py().get_type::<AuthError>())?;
1966 m.add("TlsError", m.py().get_type::<TlsError>())?;
1967 m.add("LoopMismatchError", m.py().get_type::<LoopMismatchError>())?;
1968 m.add(
1969 "ConnectionCancelledError",
1970 m.py().get_type::<ConnectionCancelledError>(),
1971 )?;
1972 m.add(
1973 "UseAfterCloseError",
1974 m.py().get_type::<UseAfterCloseError>(),
1975 )?;
1976 m.add(
1977 "UdpAssociationError",
1978 m.py().get_type::<UdpAssociationError>(),
1979 )?;
1980 m.add(
1981 "UnsupportedCompositionError",
1982 m.py().get_type::<UnsupportedCompositionError>(),
1983 )?;
1984 m.add("__version__", env!("CARGO_PKG_VERSION"))?;
1985 Ok(())
1986}