Skip to main content

_eggress/
lib.rs

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    /// Start with the compatibility-only runtime options parsed from pproxy
186    /// arguments. Native Eggress service startup never uses this path.
187    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            compatibility_mode: true,
201            auth_timeout: Some(Duration::from_secs(auth_timeout_seconds)),
202            system_proxy,
203            debug,
204            verbose_level,
205        };
206        let handle = py
207            .detach(|| svc.start_blocking_with_compatibility_options(options))
208            .map_err(|e| map_error(py, e))?;
209        Ok(PyEggressHandle {
210            inner: Some(handle),
211        })
212    }
213}
214
215#[pyclass]
216struct PyEggressHandle {
217    inner: Option<eggress_embed::EggressHandle>,
218}
219
220#[pymethods]
221impl PyEggressHandle {
222    fn bound_addresses(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
223        let handle = self
224            .inner
225            .as_ref()
226            .ok_or_else(|| EggressError::new_err("handle consumed"))?;
227        let addrs = py.detach(|| handle.bound_addresses());
228        let dict = PyDict::new(py);
229        for la in &addrs.listeners {
230            dict.set_item(&la.name, la.addr.to_string())?;
231        }
232        if let Some(admin) = addrs.admin {
233            dict.set_item("_admin", admin.to_string())?;
234        }
235        Ok(dict.into())
236    }
237
238    fn status(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
239        let handle = self
240            .inner
241            .as_ref()
242            .ok_or_else(|| EggressError::new_err("handle consumed"))?;
243        let st = py.detach(|| handle.status());
244        let dict = PyDict::new(py);
245        dict.set_item("generation", st.generation)?;
246        dict.set_item("readiness", st.readiness)?;
247        dict.set_item("active_connections", st.active_connections)?;
248        dict.set_item("uptime_secs", st.uptime_secs)?;
249        dict.set_item("listener_count", st.listener_count)?;
250        dict.set_item("udp_associations_active", st.udp_associations_active)?;
251        dict.set_item("upstream_count", st.upstream_count)?;
252        let py_listeners = PyList::empty(py);
253        for ls in &st.listeners {
254            let ldict = PyDict::new(py);
255            ldict.set_item("name", &ls.name)?;
256            ldict.set_item("bind", &ls.bind)?;
257            ldict.set_item("local_addr", ls.local_addr.to_string())?;
258            ldict.set_item("protocols", &ls.protocols)?;
259            ldict.set_item("udp_enabled", ls.udp_enabled)?;
260            py_listeners.append(ldict)?;
261        }
262        dict.set_item("listeners", py_listeners)?;
263        Ok(dict.into())
264    }
265
266    fn metrics_text(&self, py: Python<'_>) -> PyResult<String> {
267        let handle = self
268            .inner
269            .as_ref()
270            .ok_or_else(|| EggressError::new_err("handle consumed"))?;
271        py.detach(|| handle.metrics_text())
272            .map_err(|e| map_error(py, e))
273    }
274
275    fn reload_toml(&self, py: Python<'_>, toml_str: &str) -> PyResult<Py<PyDict>> {
276        let handle = self
277            .inner
278            .as_ref()
279            .ok_or_else(|| EggressError::new_err("handle consumed"))?;
280        let outcome = py
281            .detach(|| handle.reload_toml_str(toml_str))
282            .map_err(|e| map_error(py, e))?;
283        let dict = PyDict::new(py);
284        match outcome {
285            eggress_embed::ReloadOutcome::Applied {
286                generation,
287                upstreams,
288            } => {
289                dict.set_item("generation", generation)?;
290                dict.set_item("upstreams", upstreams)?;
291            }
292        }
293        Ok(dict.into())
294    }
295
296    fn shutdown(&mut self, py: Python<'_>) -> PyResult<()> {
297        if let Some(handle) = self.inner.take() {
298            py.detach(|| handle.shutdown_blocking())
299                .map_err(|e| map_error(py, e))?;
300        }
301        Ok(())
302    }
303
304    fn __enter__(slf: Py<Self>) -> Py<Self> {
305        slf
306    }
307
308    fn __exit__(
309        &mut self,
310        py: Python<'_>,
311        _exc_type: &Bound<'_, PyAny>,
312        _exc_value: &Bound<'_, PyAny>,
313        _traceback: &Bound<'_, PyAny>,
314    ) -> PyResult<bool> {
315        if let Some(handle) = self.inner.take() {
316            if let Err(e) = py.detach(|| handle.shutdown_blocking()) {
317                eprintln!("shutdown error in __exit__: {e}");
318            }
319        }
320        Ok(false)
321    }
322}
323
324const STATE_CREATED: u8 = 0;
325const STATE_CONNECTING: u8 = 1;
326const STATE_CONNECTED: u8 = 2;
327const STATE_CLOSING: u8 = 3;
328const STATE_CLOSED: u8 = 4;
329const STATE_FAILED: u8 = 5;
330
331#[pyclass]
332struct PyConnection {
333    state: Arc<AtomicU8>,
334    handle: Option<eggress_embed::EggressHandle>,
335    config_toml: String,
336    bound_addr: Option<String>,
337    remote_addr: Option<String>,
338    peername: Option<String>,
339    sockname: Option<String>,
340    error: Option<String>,
341}
342
343#[pymethods]
344impl PyConnection {
345    #[new]
346    #[pyo3(signature = (uris, /, *args))]
347    fn new(py: Python<'_>, uris: &Bound<'_, PySequence>, args: Vec<String>) -> PyResult<Self> {
348        let mut all_args: Vec<String> = Vec::new();
349        for i in 0..uris.len()? {
350            all_args.push(uris.get_item(i)?.extract::<String>()?);
351        }
352        all_args.extend(args);
353
354        if all_args.is_empty() {
355            return Err(ConnectionError::new_err(
356                "at least one URI argument is required",
357            ));
358        }
359
360        let parsed = eggress_pproxy_compat::PproxyArgs::parse(&all_args)
361            .map_err(|e| ConnectionError::new_err(format!("argument parse error: {e}")))?;
362
363        let output = py
364            .detach(|| eggress_pproxy_compat::translate_pproxy_args(&parsed))
365            .map_err(|e| ConnectionError::new_err(format!("translation failed: {e}")))?;
366
367        if output.has_unsupported() {
368            let features: Vec<_> = output.unsupported.iter().map(|u| u.feature).collect();
369            return Err(UnsupportedFeatureError::new_err(format!(
370                "unsupported features: {}",
371                features.join(", ")
372            )));
373        }
374
375        let config = eggress_embed::EggressConfig::from_toml_str(&output.toml)
376            .map_err(|e| ConnectionError::new_err(format!("config error: {e}")))?;
377        let service = eggress_embed::EggressService::new(config);
378        let handle = py
379            .detach(|| service.start_blocking())
380            .map_err(|e| ConnectionError::new_err(format!("startup failed: {e}")))?;
381
382        let addrs = handle.bound_addresses();
383        let bound = addrs.listeners.first().map(|l| l.addr.to_string());
384
385        PY_CONNECTION_TOTAL_CREATED.fetch_add(1, Ordering::Relaxed);
386        PY_CONNECTION_LIVE_COUNT.fetch_add(1, Ordering::Relaxed);
387
388        Ok(Self {
389            state: Arc::new(AtomicU8::new(STATE_CREATED)),
390            handle: Some(handle),
391            config_toml: output.toml,
392            bound_addr: bound,
393            remote_addr: None,
394            peername: None,
395            sockname: None,
396            error: None,
397        })
398    }
399
400    #[getter]
401    fn state(&self) -> &str {
402        match self.state.load(Ordering::Acquire) {
403            STATE_CREATED => "created",
404            STATE_CONNECTING => "connecting",
405            STATE_CONNECTED => "connected",
406            STATE_CLOSING => "closing",
407            STATE_CLOSED => "closed",
408            STATE_FAILED => "failed",
409            _ => "unknown",
410        }
411    }
412
413    #[getter]
414    fn closed(&self) -> bool {
415        matches!(
416            self.state.load(Ordering::Acquire),
417            STATE_CLOSED | STATE_FAILED
418        )
419    }
420
421    #[getter]
422    fn config(&self) -> &str {
423        &self.config_toml
424    }
425
426    #[getter]
427    fn peername(&self) -> Option<&str> {
428        self.peername.as_deref()
429    }
430
431    #[getter]
432    fn sockname(&self) -> Option<&str> {
433        self.sockname.as_deref().or(self.bound_addr.as_deref())
434    }
435
436    #[getter]
437    fn extra_info(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
438        let dict = PyDict::new(py);
439        dict.set_item("state", self.state())?;
440        if let Some(ref addr) = self.bound_addr {
441            dict.set_item("bound_addr", addr)?;
442        }
443        if let Some(ref addr) = self.remote_addr {
444            dict.set_item("remote_addr", addr)?;
445        }
446        if let Some(ref err) = self.error {
447            dict.set_item("error", err)?;
448        }
449        Ok(dict.into())
450    }
451
452    fn close(&mut self, py: Python<'_>) -> PyResult<()> {
453        if !begin_close(&self.state) {
454            return Ok(());
455        }
456        if let Some(handle) = self.handle.take() {
457            py.detach(|| handle.shutdown_blocking())
458                .map_err(|e| ConnectionError::new_err(format!("shutdown error: {e}")))?;
459        }
460        self.state.store(STATE_CLOSED, Ordering::Release);
461        let _ = PY_CONNECTION_LIVE_COUNT
462            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| v.checked_sub(1));
463        Ok(())
464    }
465
466    fn wait_closed(&mut self, py: Python<'_>) -> PyResult<()> {
467        let current = self.state.load(Ordering::Acquire);
468        if current == STATE_CLOSED || current == STATE_FAILED {
469            return Ok(());
470        }
471        self.close(py)?;
472        Ok(())
473    }
474
475    fn __enter__(slf: Py<Self>) -> Py<Self> {
476        slf
477    }
478
479    fn __exit__(
480        &mut self,
481        py: Python<'_>,
482        _exc_type: &Bound<'_, PyAny>,
483        _exc_value: &Bound<'_, PyAny>,
484        _traceback: &Bound<'_, PyAny>,
485    ) -> PyResult<bool> {
486        self.close(py)?;
487        Ok(false)
488    }
489
490    fn __del__(&mut self) {
491        if !begin_close(&self.state) {
492            return;
493        }
494        eprintln!(
495            "Warning: Connection object was not properly closed. Calling close() in __del__."
496        );
497        if let Some(handle) = self.handle.take() {
498            match tokio::runtime::Handle::try_current() {
499                Ok(runtime) => {
500                    runtime.spawn(async move {
501                        if let Err(error) = handle.shutdown().await {
502                            eprintln!("shutdown error in __del__: {error}");
503                        }
504                    });
505                }
506                Err(error) => {
507                    // No runtime available: do not spawn a thread during
508                    // interpreter finalization (UB-prone). Drop the handle so
509                    // the runtime can reap it; log the deferred shutdown.
510                    eprintln!(
511                        "could not schedule shutdown in __del__: {error}; \
512                         dropping handle to be reaped by runtime"
513                    );
514                    drop(handle);
515                }
516            }
517        }
518        self.state.store(STATE_CLOSED, Ordering::Release);
519        // Guard against underflow on double-close paths.
520        let _ = PY_CONNECTION_LIVE_COUNT
521            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| v.checked_sub(1));
522    }
523
524    fn __repr__(&self) -> String {
525        format!(
526            "Connection(state='{}', bound='{}')",
527            self.state(),
528            self.bound_addr.as_deref().unwrap_or("None")
529        )
530    }
531
532    #[staticmethod]
533    fn connection_stats(py: Python<'_>) -> PyResult<Py<PyDict>> {
534        let dict = PyDict::new(py);
535        dict.set_item("live", PY_CONNECTION_LIVE_COUNT.load(Ordering::Relaxed))?;
536        dict.set_item(
537            "total_created",
538            PY_CONNECTION_TOTAL_CREATED.load(Ordering::Relaxed),
539        )?;
540        Ok(dict.into())
541    }
542
543    #[staticmethod]
544    fn reset_connection_stats() {
545        PY_CONNECTION_LIVE_COUNT.store(0, Ordering::Relaxed);
546        PY_CONNECTION_TOTAL_CREATED.store(0, Ordering::Relaxed);
547    }
548}
549
550fn begin_close(state: &AtomicU8) -> bool {
551    loop {
552        let current = state.load(Ordering::Acquire);
553        if current == STATE_CLOSED || current == STATE_FAILED || current == STATE_CLOSING {
554            return false;
555        }
556        match state.compare_exchange(current, STATE_CLOSING, Ordering::AcqRel, Ordering::Acquire) {
557            Ok(_) => return true,
558            Err(_) => continue,
559        }
560    }
561}
562
563// --- pproxy URI inspection helpers ---
564
565#[pyclass(skip_from_py_object)]
566#[derive(Clone)]
567struct PyUriInfo {
568    scheme: String,
569    host: String,
570    port: u16,
571    tls: bool,
572    ssl: bool,
573    inbound: bool,
574    backward_num: u32,
575    has_auth: bool,
576    has_rule: bool,
577    is_reverse_listener: bool,
578    redacted_display: String,
579    error: Option<String>,
580}
581
582#[pymethods]
583impl PyUriInfo {
584    #[getter]
585    fn scheme(&self) -> &str {
586        &self.scheme
587    }
588    #[getter]
589    fn host(&self) -> &str {
590        &self.host
591    }
592    #[getter]
593    fn port(&self) -> u16 {
594        self.port
595    }
596    #[getter]
597    fn tls(&self) -> bool {
598        self.tls
599    }
600    #[getter]
601    fn ssl(&self) -> bool {
602        self.ssl
603    }
604    #[getter]
605    fn inbound(&self) -> bool {
606        self.inbound
607    }
608    #[getter]
609    fn backward_num(&self) -> u32 {
610        self.backward_num
611    }
612    #[getter]
613    fn has_auth(&self) -> bool {
614        self.has_auth
615    }
616    #[getter]
617    fn has_rule(&self) -> bool {
618        self.has_rule
619    }
620    #[getter]
621    fn is_reverse_listener(&self) -> bool {
622        self.is_reverse_listener
623    }
624    #[getter]
625    fn redacted_display(&self) -> &str {
626        &self.redacted_display
627    }
628    #[getter]
629    fn error(&self) -> Option<&str> {
630        self.error.as_deref()
631    }
632
633    fn __repr__(&self) -> String {
634        match &self.error {
635            Some(e) => format!("UriInfo(error='{}')", e),
636            None => format!(
637                "UriInfo(scheme='{}', host='{}', port={}, tls={})",
638                self.scheme, self.host, self.port, self.tls
639            ),
640        }
641    }
642}
643
644#[pyfunction]
645fn check_pproxy_uri(uri: &str) -> PyUriInfo {
646    match eggress_pproxy_compat::uri::parse_pproxy_uri(uri) {
647        Ok(parsed) => PyUriInfo {
648            scheme: parsed.scheme.clone(),
649            host: parsed.host.clone(),
650            port: parsed.port,
651            tls: parsed.tls,
652            ssl: parsed.ssl,
653            inbound: parsed.inbound,
654            backward_num: parsed.backward_num,
655            has_auth: parsed.username.is_some(),
656            has_rule: parsed.rule.is_some(),
657            is_reverse_listener: parsed.is_reverse_listener(),
658            redacted_display: parsed.redacted_display(),
659            error: None,
660        },
661        Err(e) => PyUriInfo {
662            scheme: String::new(),
663            host: String::new(),
664            port: 0,
665            tls: false,
666            ssl: false,
667            inbound: false,
668            backward_num: 0,
669            has_auth: false,
670            has_rule: false,
671            is_reverse_listener: false,
672            redacted_display: String::new(),
673            error: Some(e.to_string()),
674        },
675    }
676}
677
678#[pyfunction]
679fn redact_pproxy_uri(uri: &str) -> PyResult<String> {
680    let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri(uri)
681        .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid pproxy URI: {e}")))?;
682    Ok(parsed.redacted_display())
683}
684
685// --- diagnostics ---
686
687#[pyclass(skip_from_py_object)]
688#[derive(Clone)]
689struct PyDiagnostic {
690    code: String,
691    feature_id: Option<String>,
692    tier: Option<String>,
693    message: String,
694    suggestion: Option<String>,
695}
696
697#[pymethods]
698impl PyDiagnostic {
699    #[getter]
700    fn code(&self) -> &str {
701        &self.code
702    }
703    #[getter]
704    fn feature_id(&self) -> Option<&str> {
705        self.feature_id.as_deref()
706    }
707    #[getter]
708    fn tier(&self) -> Option<&str> {
709        self.tier.as_deref()
710    }
711    #[getter]
712    fn message(&self) -> &str {
713        &self.message
714    }
715    #[getter]
716    fn suggestion(&self) -> Option<&str> {
717        self.suggestion.as_deref()
718    }
719
720    fn __repr__(&self) -> String {
721        format!("[{}] {}", self.code, self.message)
722    }
723}
724
725/// Return diagnostic-only compatibility information for a pproxy URI.
726///
727/// Entries describe translation warnings and unsupported features; this does
728/// not prove that the URI is executable. Use `check_pproxy_uri` for the
729/// actionable compatibility check.
730#[pyfunction]
731fn diagnostics_for_uri(py: Python<'_>, uri: &str) -> PyResult<Vec<PyDiagnostic>> {
732    let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri(uri)
733        .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid pproxy URI: {e}")))?;
734
735    let mut diagnostics: Vec<PyDiagnostic> = Vec::new();
736
737    let output = py
738        .detach(|| {
739            eggress_pproxy_compat::translate_from_uris(
740                &eggress_pproxy_compat::PproxyArgs::default_args(),
741                &[parsed],
742                &[],
743            )
744        })
745        .map_err(|e| UnsupportedFeatureError::new_err(format!("translation failed: {e}")))?;
746
747    for warn in &output.warnings {
748        let sd = eggress_pproxy_compat::StructuredDiagnostic::from(warn);
749        diagnostics.push(PyDiagnostic {
750            code: sd.code.to_string(),
751            feature_id: sd.feature_id,
752            tier: sd.tier,
753            message: sd.message,
754            suggestion: sd.suggestion,
755        });
756    }
757    for u in &output.unsupported {
758        diagnostics.push(PyDiagnostic {
759            code: "unsupported_protocol".to_string(),
760            feature_id: Some(u.feature.to_string()),
761            tier: Some("unsupported".to_string()),
762            message: u.detail.clone(),
763            suggestion: None,
764        });
765    }
766
767    diagnostics.sort_by_key(|diagnostic| {
768        if diagnostic.tier.as_deref() == Some("unsupported") {
769            0
770        } else {
771            1
772        }
773    });
774
775    Ok(diagnostics)
776}
777
778#[pyfunction]
779fn supported_features() -> Vec<&'static str> {
780    vec![
781        "http",
782        "socks4",
783        "socks4a",
784        "socks5",
785        "shadowsocks",
786        "trojan",
787        "redir",
788        "unix",
789        "bind",
790        "listen",
791        "backward",
792        "rebind",
793        "direct",
794    ]
795}
796
797// --- config explanation helpers ---
798
799fn parse_toml_config(py: Python<'_>, toml_str: &str) -> PyResult<Py<PyDict>> {
800    let parsed: toml::Value = py
801        .detach(|| toml::from_str(toml_str))
802        .map_err(|e| ConfigError::new_err(format!("failed to parse TOML: {e}")))?;
803
804    let dict = PyDict::new(py);
805
806    // Listeners
807    let listeners_list = PyList::empty(py);
808    if let Some(listeners) = parsed.get("listeners").and_then(|v| v.as_array()) {
809        for l in listeners {
810            let ldict = PyDict::new(py);
811            if let Some(name) = l.get("name").and_then(|v| v.as_str()) {
812                ldict.set_item("name", name)?;
813            }
814            if let Some(bind) = l.get("bind").and_then(|v| v.as_str()) {
815                ldict.set_item("bind", bind)?;
816            }
817            if let Some(protocols) = l.get("protocols").and_then(|v| v.as_array()) {
818                let py_protos = PyList::empty(py);
819                for p in protocols {
820                    if let Some(s) = p.as_str() {
821                        py_protos.append(s)?;
822                    }
823                }
824                ldict.set_item("protocols", py_protos)?;
825            }
826            if l.get("udp").is_some() {
827                ldict.set_item("udp_enabled", true)?;
828            }
829            if l.get("tls").is_some() {
830                ldict.set_item("tls", true)?;
831            }
832            if l.get("transparent").is_some() {
833                ldict.set_item("transparent", true)?;
834            }
835            if let Some(unix) = l.get("unix") {
836                ldict.set_item("unix_socket", true)?;
837                if let Some(path) = unix.get("path").and_then(|v| v.as_str()) {
838                    ldict.set_item("unix_path", path)?;
839                }
840            }
841            listeners_list.append(ldict)?;
842        }
843    }
844    dict.set_item("listeners", listeners_list)?;
845
846    // Upstreams
847    let upstreams_list = PyList::empty(py);
848    if let Some(upstreams) = parsed.get("upstreams").and_then(|v| v.as_array()) {
849        for u in upstreams {
850            let udict = PyDict::new(py);
851            if let Some(id) = u.get("id").and_then(|v| v.as_str()) {
852                udict.set_item("id", id)?;
853            }
854            if let Some(uri) = u.get("uri").and_then(|v| v.as_str()) {
855                // Redact credentials in the URI
856                let redacted = redact_config_uri(uri);
857                udict.set_item("uri", redacted)?;
858            }
859            upstreams_list.append(udict)?;
860        }
861    }
862    dict.set_item("upstreams", upstreams_list)?;
863
864    // Upstream groups
865    let groups_list = PyList::empty(py);
866    if let Some(groups) = parsed.get("upstream_groups").and_then(|v| v.as_array()) {
867        for g in groups {
868            let gdict = PyDict::new(py);
869            if let Some(id) = g.get("id").and_then(|v| v.as_str()) {
870                gdict.set_item("id", id)?;
871            }
872            if let Some(scheduler) = g.get("scheduler").and_then(|v| v.as_str()) {
873                gdict.set_item("scheduler", scheduler)?;
874            }
875            if let Some(members) = g.get("members").and_then(|v| v.as_array()) {
876                let py_members = PyList::empty(py);
877                for m in members {
878                    if let Some(s) = m.as_str() {
879                        py_members.append(s)?;
880                    }
881                }
882                gdict.set_item("members", py_members)?;
883            }
884            groups_list.append(gdict)?;
885        }
886    }
887    dict.set_item("upstream_groups", groups_list)?;
888
889    // Rules
890    let rules_list = PyList::empty(py);
891    if let Some(rules) = parsed.get("rules").and_then(|v| v.as_array()) {
892        for r in rules {
893            let rdict = PyDict::new(py);
894            if let Some(id) = r.get("id").and_then(|v| v.as_str()) {
895                rdict.set_item("id", id)?;
896            }
897            if let Some(ug) = r.get("upstream_group").and_then(|v| v.as_str()) {
898                rdict.set_item("upstream_group", ug)?;
899            }
900            if r.get("direct").and_then(|v| v.as_bool()) == Some(true) {
901                rdict.set_item("action", "direct")?;
902            } else if let Some(reject) = r.get("reject").and_then(|v| v.as_str()) {
903                rdict.set_item("action", format!("reject({})", reject))?;
904            } else if r.get("upstream_group").is_some() {
905                rdict.set_item("action", "upstream")?;
906            }
907            if r.get("match").is_some() {
908                rdict.set_item("has_match", true)?;
909            } else if r.get("any").and_then(|v| v.as_bool()) == Some(true) {
910                rdict.set_item("match_all", true)?;
911            }
912            rules_list.append(rdict)?;
913        }
914    }
915    dict.set_item("rules", rules_list)?;
916
917    // Reverse servers
918    let reverse_servers_list = PyList::empty(py);
919    if let Some(servers) = parsed.get("reverse_servers").and_then(|v| v.as_array()) {
920        for s in servers {
921            let sdict = PyDict::new(py);
922            if let Some(id) = s.get("id").and_then(|v| v.as_str()) {
923                sdict.set_item("id", id)?;
924            }
925            if let Some(bind) = s.get("control_bind").and_then(|v| v.as_str()) {
926                sdict.set_item("control_bind", bind)?;
927            }
928            reverse_servers_list.append(sdict)?;
929        }
930    }
931    dict.set_item("reverse_servers", reverse_servers_list)?;
932
933    // Reverse clients
934    let reverse_clients_list = PyList::empty(py);
935    if let Some(clients) = parsed.get("reverse_clients").and_then(|v| v.as_array()) {
936        for c in clients {
937            let cdict = PyDict::new(py);
938            if let Some(id) = c.get("id").and_then(|v| v.as_str()) {
939                cdict.set_item("id", id)?;
940            }
941            if let Some(addr) = c.get("server_addr").and_then(|v| v.as_str()) {
942                cdict.set_item("server_addr", addr)?;
943            }
944            reverse_clients_list.append(cdict)?;
945        }
946    }
947    dict.set_item("reverse_clients", reverse_clients_list)?;
948
949    // Security notes
950    let security_list = PyList::empty(py);
951    // Check for plaintext credentials
952    if let Some(listeners) = parsed.get("listeners").and_then(|v| v.as_array()) {
953        for l in listeners {
954            if l.get("auth").is_some() {
955                security_list.append("listener has plaintext auth credentials in TOML")?;
956            }
957            if l.get("shadowsocks").is_some() {
958                security_list.append("listener has Shadowsocks credentials in TOML")?;
959            }
960        }
961    }
962    if let Some(servers) = parsed.get("reverse_servers").and_then(|v| v.as_array()) {
963        for s in servers {
964            if s.get("auth_password").is_some() {
965                security_list.append("reverse server has plaintext credentials in TOML")?;
966            }
967        }
968    }
969    if let Some(clients) = parsed.get("reverse_clients").and_then(|v| v.as_array()) {
970        for c in clients {
971            if c.get("auth_password").is_some() {
972                security_list.append("reverse client has plaintext credentials in TOML")?;
973            }
974        }
975    }
976    if let Some(listeners) = parsed.get("listeners").and_then(|v| v.as_array()) {
977        for l in listeners {
978            if l.get("transparent").is_some() {
979                security_list.append("transparent proxy listener requires elevated privileges")?;
980            }
981        }
982    }
983    dict.set_item("security_notes", security_list)?;
984
985    Ok(dict.into())
986}
987
988/// Redact credentials from a config URI for safe display.
989fn redact_config_uri(uri: &str) -> String {
990    eggress_uri::redact_proxy_uri(uri)
991}
992
993#[pyfunction]
994fn explain_config_toml(py: Python<'_>, toml_str: &str) -> PyResult<Py<PyDict>> {
995    parse_toml_config(py, toml_str)
996}
997
998#[pyfunction]
999fn explain_pproxy_args(py: Python<'_>, args: &Bound<'_, PySequence>) -> PyResult<Py<PyDict>> {
1000    let result = translate_pproxy_args(py, args)?;
1001    let toml_str = result.output.toml.clone();
1002    let warnings: Vec<(String, String)> = result
1003        .output
1004        .warnings
1005        .iter()
1006        .map(|w| (w.category.to_string(), w.message.clone()))
1007        .collect();
1008    let unsupported: Vec<(String, String)> = result
1009        .output
1010        .unsupported
1011        .iter()
1012        .map(|u| (u.feature.to_string(), u.detail.clone()))
1013        .collect();
1014    let is_ok = !result.output.has_unsupported();
1015
1016    let dict = parse_toml_config(py, &toml_str)?;
1017
1018    let warnings_list = PyList::empty(py);
1019    for (cat, msg) in &warnings {
1020        let wdict = PyDict::new(py);
1021        wdict.set_item("category", cat.as_str())?;
1022        wdict.set_item("message", msg.as_str())?;
1023        warnings_list.append(wdict)?;
1024    }
1025    dict.bind(py).set_item("warnings", warnings_list)?;
1026
1027    let unsupported_list = PyList::empty(py);
1028    for (feat, detail) in &unsupported {
1029        let udict = PyDict::new(py);
1030        udict.set_item("feature", feat.as_str())?;
1031        udict.set_item("detail", detail.as_str())?;
1032        unsupported_list.append(udict)?;
1033    }
1034    dict.bind(py).set_item("unsupported", unsupported_list)?;
1035
1036    dict.bind(py).set_item("toml", &toml_str)?;
1037    dict.bind(py).set_item("ok", is_ok)?;
1038
1039    Ok(dict)
1040}
1041
1042#[pyfunction]
1043fn explain_pproxy_uri(py: Python<'_>, uri: &str) -> PyResult<Py<PyDict>> {
1044    let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri(uri)
1045        .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid pproxy URI: {e}")))?;
1046
1047    let output = py
1048        .detach(|| {
1049            eggress_pproxy_compat::translate_from_uris(
1050                &eggress_pproxy_compat::PproxyArgs::default_args(),
1051                &[parsed],
1052                &[],
1053            )
1054        })
1055        .map_err(|e| UnsupportedFeatureError::new_err(format!("translation failed: {e}")))?;
1056
1057    let dict = parse_toml_config(py, &output.toml)?;
1058
1059    let warnings_list = PyList::empty(py);
1060    for w in &output.warnings {
1061        let wdict = PyDict::new(py);
1062        wdict.set_item("category", w.category)?;
1063        wdict.set_item("message", &w.message)?;
1064        warnings_list.append(wdict)?;
1065    }
1066    dict.bind(py).set_item("warnings", warnings_list)?;
1067
1068    let unsupported_list = PyList::empty(py);
1069    for u in &output.unsupported {
1070        let udict = PyDict::new(py);
1071        udict.set_item("feature", u.feature)?;
1072        udict.set_item("detail", &u.detail)?;
1073        unsupported_list.append(udict)?;
1074    }
1075    dict.bind(py).set_item("unsupported", unsupported_list)?;
1076
1077    dict.bind(py).set_item("toml", &output.toml)?;
1078    dict.bind(py).set_item("ok", !output.has_unsupported())?;
1079
1080    Ok(dict)
1081}
1082
1083#[pyfunction]
1084fn route_explain(py: Python<'_>, toml_str: &str, target: &str) -> PyResult<Py<PyDict>> {
1085    use eggress_config::compile::compile_config;
1086    use eggress_config::model::ConfigFile;
1087    use eggress_core::{ClientIdentity, ProtocolId, TargetAddr};
1088    use eggress_routing::{RouteRequest, Router, TransportKind};
1089
1090    let target_addr: TargetAddr = target
1091        .parse()
1092        .map_err(|e: String| PyValueError::new_err(format!("invalid target: {e}")))?;
1093
1094    let explanation = py
1095        .detach(|| -> Result<_, String> {
1096            let config: ConfigFile =
1097                toml::from_str(toml_str).map_err(|e| format!("failed to parse TOML: {e}"))?;
1098
1099            let runtime_config =
1100                compile_config(&config).map_err(|e| format!("failed to compile config: {e}"))?;
1101
1102            let router =
1103                Router::with_groups(runtime_config.rules, runtime_config.default_action, vec![]);
1104
1105            let request = RouteRequest {
1106                target: &target_addr,
1107                source: None,
1108                listener: "",
1109                inbound_protocol: ProtocolId::Socks5,
1110                identity: &ClientIdentity::Anonymous,
1111                transport: TransportKind::Tcp,
1112            };
1113
1114            Ok(router.explain(&request, 0))
1115        })
1116        .map_err(ConfigError::new_err)?;
1117
1118    let dict = PyDict::new(py);
1119    dict.set_item("target", &explanation.target)?;
1120    dict.set_item("listener", &explanation.listener)?;
1121    dict.set_item("protocol", &explanation.protocol)?;
1122    dict.set_item("transport", &explanation.transport)?;
1123    dict.set_item("matched_rule", explanation.matched_rule)?;
1124    dict.set_item("action", &explanation.action)?;
1125    dict.set_item("upstream_group", explanation.upstream_group)?;
1126    dict.set_item("scheduler", explanation.scheduler)?;
1127    let eligible_list = PyList::empty(py);
1128    for u in &explanation.eligible_upstreams {
1129        let udict = PyDict::new(py);
1130        udict.set_item("id", &u.id)?;
1131        udict.set_item("health", &u.health)?;
1132        udict.set_item("eligible", u.eligible)?;
1133        udict.set_item("active", u.active)?;
1134        udict.set_item("in_flight", u.in_flight)?;
1135        eligible_list.append(udict)?;
1136    }
1137    dict.set_item("eligible_upstreams", eligible_list)?;
1138    dict.set_item("selected_upstream", explanation.selected_upstream)?;
1139    dict.set_item("chain", explanation.chain)?;
1140    dict.set_item("generation", explanation.generation)?;
1141
1142    Ok(dict.into())
1143}
1144
1145#[pyfunction]
1146fn test_upstream_connect(py: Python<'_>, uri: &str, timeout_secs: f64) -> PyResult<Py<PyDict>> {
1147    use std::net::ToSocketAddrs;
1148
1149    let dict = PyDict::new(py);
1150
1151    // Parse the URI to extract host:port
1152    let url =
1153        url::Url::parse(uri).map_err(|e| PyValueError::new_err(format!("invalid URI: {e}")))?;
1154
1155    let host = url
1156        .host_str()
1157        .ok_or_else(|| PyValueError::new_err("URI has no host"))?
1158        .to_string();
1159    let port = url.port().unwrap_or(match url.scheme() {
1160        "socks5" => 1080,
1161        "socks4" | "socks4a" => 1080,
1162        "http" | "https" => 80,
1163        "ss" => 8388,
1164        "trojan" => 443,
1165        _ => 0,
1166    });
1167
1168    dict.set_item("host", &host)?;
1169    dict.set_item("port", port)?;
1170    dict.set_item("scheme", url.scheme())?;
1171
1172    // Has auth?
1173    let has_auth = !url.username().is_empty() || url.password().is_some();
1174    dict.set_item("has_auth", has_auth)?;
1175
1176    // Redact for display
1177    let redacted = if has_auth {
1178        format!("{}://****@{}:{}", url.scheme(), host, port)
1179    } else {
1180        format!("{}://{}:{}", url.scheme(), host, port)
1181    };
1182    dict.set_item("redacted_uri", &redacted)?;
1183
1184    // Attempt TCP connect
1185    let addr_str = format!("{}:{}", host, port);
1186    let (connected, latency_us, last_error): (bool, Option<u64>, Option<String>) =
1187        py.detach(|| {
1188            let std_duration = std::time::Duration::from_secs_f64(timeout_secs);
1189            let socket_addrs = match addr_str.to_socket_addrs() {
1190                Ok(addrs) => addrs,
1191                Err(e) => {
1192                    return (false, None, Some(format!("DNS resolution failed: {e}")));
1193                }
1194            };
1195
1196            let mut last_error: Option<String> = None;
1197            for addr in socket_addrs {
1198                let start = std::time::Instant::now();
1199                match std::net::TcpStream::connect_timeout(&addr, std_duration) {
1200                    Ok(_stream) => {
1201                        return (true, Some(start.elapsed().as_micros() as u64), None);
1202                    }
1203                    Err(e) => {
1204                        last_error = Some(format!("connect to {addr} failed: {e}"));
1205                    }
1206                }
1207            }
1208            (false, None, last_error)
1209        });
1210
1211    dict.set_item("connected", connected)?;
1212    dict.set_item("latency_us", latency_us)?;
1213    dict.set_item("error", last_error)?;
1214
1215    Ok(dict.into())
1216}
1217
1218// --- pproxy compatibility translation helpers ---
1219
1220#[pyclass(skip_from_py_object)]
1221#[derive(Clone)]
1222struct PyTranslationWarning {
1223    inner: eggress_pproxy_compat::CompatWarning,
1224}
1225
1226#[pymethods]
1227impl PyTranslationWarning {
1228    #[getter]
1229    fn category(&self) -> &str {
1230        self.inner.category
1231    }
1232
1233    #[getter]
1234    fn message(&self) -> &str {
1235        &self.inner.message
1236    }
1237
1238    #[getter]
1239    fn tier(&self) -> &str {
1240        eggress_pproxy_compat::manifest_tier_for_category(self.inner.category).as_str()
1241    }
1242
1243    fn __repr__(&self) -> String {
1244        format!("[{}] {}", self.inner.category, self.inner.message)
1245    }
1246}
1247
1248#[pyclass(skip_from_py_object)]
1249#[derive(Clone)]
1250struct PyUnsupportedFeature {
1251    inner: eggress_pproxy_compat::UnsupportedFeature,
1252}
1253
1254#[pymethods]
1255impl PyUnsupportedFeature {
1256    #[getter]
1257    fn feature(&self) -> &str {
1258        self.inner.feature
1259    }
1260
1261    #[getter]
1262    fn message(&self) -> &str {
1263        &self.inner.detail
1264    }
1265
1266    #[getter]
1267    fn tier(&self) -> &str {
1268        eggress_pproxy_compat::classify_unsupported_feature_tier(self.inner.feature)
1269    }
1270
1271    fn __repr__(&self) -> String {
1272        format!("unsupported {}: {}", self.inner.feature, self.inner.detail)
1273    }
1274}
1275
1276#[pyclass]
1277struct PyTranslationResult {
1278    output: eggress_pproxy_compat::TranslationOutput,
1279}
1280
1281#[pymethods]
1282impl PyTranslationResult {
1283    #[getter]
1284    fn toml(&self) -> &str {
1285        &self.output.toml
1286    }
1287
1288    #[getter]
1289    fn warnings(&self, py: Python<'_>) -> PyResult<Py<PyList>> {
1290        let list = PyList::empty(py);
1291        for w in &self.output.warnings {
1292            list.append(PyTranslationWarning { inner: w.clone() })?;
1293        }
1294        Ok(list.into())
1295    }
1296
1297    #[getter]
1298    fn unsupported(&self, py: Python<'_>) -> PyResult<Py<PyList>> {
1299        let list = PyList::empty(py);
1300        for u in &self.output.unsupported {
1301            list.append(PyUnsupportedFeature { inner: u.clone() })?;
1302        }
1303        Ok(list.into())
1304    }
1305
1306    #[getter]
1307    fn ok(&self) -> bool {
1308        !self.output.has_unsupported()
1309    }
1310
1311    /// Manifest-aligned aggregate compatibility tier.
1312    ///
1313    /// This is the single source of truth for the five-tier compatibility
1314    /// classification; Python and the canonical manifest must agree with
1315    /// this value. The same value is reused by the CLI `pproxy check`
1316    /// reporter.
1317    #[getter]
1318    fn tier(&self) -> &'static str {
1319        eggress_pproxy_compat::classify_aggregate_tier(
1320            &self.output.warnings,
1321            &self.output.unsupported,
1322        )
1323        .as_str()
1324    }
1325
1326    fn config(&self, py: Python<'_>) -> PyResult<PyEggressConfig> {
1327        let config = py
1328            .detach(|| eggress_embed::EggressConfig::from_toml_str(&self.output.toml))
1329            .map_err(|e| map_error(py, e))?;
1330        Ok(PyEggressConfig { inner: config })
1331    }
1332
1333    fn __repr__(&self) -> String {
1334        format!(
1335            "TranslationResult(warnings={}, unsupported={})",
1336            self.output.warnings.len(),
1337            self.output.unsupported.len()
1338        )
1339    }
1340}
1341
1342#[pyfunction]
1343fn translate_pproxy_args(
1344    py: Python<'_>,
1345    args: &Bound<'_, PySequence>,
1346) -> PyResult<PyTranslationResult> {
1347    let len = args.len()?;
1348    let raw: Vec<String> = (0..len)
1349        .map(|i| args.get_item(i)?.extract::<String>())
1350        .collect::<PyResult<_>>()?;
1351
1352    let parsed = eggress_pproxy_compat::PproxyArgs::parse(&raw).map_err(|e| {
1353        UnsupportedFeatureError::new_err(format!("failed to parse pproxy args: {e}"))
1354    })?;
1355
1356    let output = py
1357        .detach(|| eggress_pproxy_compat::translate_pproxy_args(&parsed))
1358        .map_err(|e| UnsupportedFeatureError::new_err(format!("translation failed: {e}")))?;
1359
1360    Ok(PyTranslationResult { output })
1361}
1362
1363#[pyfunction]
1364fn translate_pproxy_uri(
1365    py: Python<'_>,
1366    local: &str,
1367    remotes: Option<&Bound<'_, PySequence>>,
1368) -> PyResult<PyTranslationResult> {
1369    let local_uri = eggress_pproxy_compat::uri::parse_pproxy_uri(local)
1370        .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid local URI: {e}")))?;
1371
1372    let remote_chains: Vec<eggress_pproxy_compat::PproxyChain> = match remotes {
1373        Some(seq) => {
1374            let len = seq.len()?;
1375            (0..len)
1376                .map(|i| {
1377                    let s: String = seq.get_item(i)?.extract()?;
1378                    eggress_pproxy_compat::uri::parse_pproxy_chain(&s).map_err(|e| {
1379                        UnsupportedFeatureError::new_err(format!("invalid remote URI: {e}"))
1380                    })
1381                })
1382                .collect::<PyResult<_>>()?
1383        }
1384        None => Vec::new(),
1385    };
1386
1387    let output = py
1388        .detach(|| {
1389            eggress_pproxy_compat::translate_from_uris(
1390                &eggress_pproxy_compat::PproxyArgs::default_args(),
1391                &[local_uri],
1392                &remote_chains,
1393            )
1394        })
1395        .map_err(|e| UnsupportedFeatureError::new_err(format!("translation failed: {e}")))?;
1396
1397    Ok(PyTranslationResult { output })
1398}
1399
1400#[pyfunction]
1401fn check_pproxy_args(
1402    py: Python<'_>,
1403    args: &Bound<'_, PySequence>,
1404) -> PyResult<PyTranslationResult> {
1405    translate_pproxy_args(py, args)
1406}
1407
1408/// Validate the frozen executable parser contract without starting a service.
1409/// Migration helpers intentionally retain their broader translation-only
1410/// extension surface.
1411#[pyfunction]
1412fn validate_pproxy_args(args: &Bound<'_, PySequence>) -> PyResult<()> {
1413    let len = args.len()?;
1414    let raw: Vec<String> = (0..len)
1415        .map(|i| args.get_item(i)?.extract::<String>())
1416        .collect::<PyResult<_>>()?;
1417    let parsed = if raw.is_empty() {
1418        eggress_pproxy_compat::PproxyArgs::default_args()
1419    } else {
1420        eggress_pproxy_compat::PproxyArgs::parse(&raw)
1421            .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))?
1422    };
1423    if let Some(flag) = parsed.strict_parser_violations().first() {
1424        return Err(PyValueError::new_err(format!(
1425            "pproxy: unknown option or positional argument '{flag}'"
1426        )));
1427    }
1428    parsed
1429        .validate_strict_values()
1430        .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))
1431}
1432
1433/// Return compatibility runtime options using the canonical Rust parser.
1434/// Migration callers may still use the broader translation surface; strict
1435/// executable entry points perform their separate parser-gate validation.
1436#[pyfunction]
1437fn pproxy_runtime_options(args: &Bound<'_, PySequence>) -> PyResult<Py<PyDict>> {
1438    let len = args.len()?;
1439    let raw: Vec<String> = (0..len)
1440        .map(|i| args.get_item(i)?.extract::<String>())
1441        .collect::<PyResult<_>>()?;
1442    let parsed = if raw.is_empty() {
1443        eggress_pproxy_compat::PproxyArgs::default_args()
1444    } else {
1445        eggress_pproxy_compat::PproxyArgs::parse(&raw)
1446            .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))?
1447    };
1448    let dict = PyDict::new(args.py());
1449    dict.set_item(
1450        "auth_timeout_seconds",
1451        parsed.effective_auth_timeout().as_secs(),
1452    )?;
1453    dict.set_item("system_proxy", parsed.system_proxy)?;
1454    dict.set_item("debug", parsed.debug)?;
1455    dict.set_item("verbose_level", parsed.verbose_level)?;
1456    dict.set_item("default_log_level", parsed.default_log_level())?;
1457    Ok(dict.into())
1458}
1459
1460/// Install compatibility-process logging for `python -m pproxy` while
1461/// respecting an existing embedded application's tracing subscriber.
1462#[pyfunction]
1463fn init_pproxy_logging(default_level: &str) -> PyResult<()> {
1464    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
1465        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level));
1466    let _ = tracing_subscriber::fmt()
1467        .with_env_filter(filter)
1468        .compact()
1469        .try_init();
1470    Ok(())
1471}
1472
1473/// Run the native compatibility upstream test without starting listeners.
1474#[pyfunction]
1475fn run_pproxy_test(py: Python<'_>, args: &Bound<'_, PySequence>, target: &str) -> PyResult<i32> {
1476    let len = args.len()?;
1477    let raw: Vec<String> = (0..len)
1478        .map(|i| args.get_item(i)?.extract::<String>())
1479        .collect::<PyResult<_>>()?;
1480    let parsed = if raw.is_empty() {
1481        eggress_pproxy_compat::PproxyArgs::default_args()
1482    } else {
1483        eggress_pproxy_compat::PproxyArgs::parse(&raw)
1484            .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))?
1485    };
1486    if let Some(flag) = parsed.strict_parser_violations().first() {
1487        return Err(PyValueError::new_err(format!(
1488            "pproxy: unknown option or positional argument '{flag}'"
1489        )));
1490    }
1491    parsed
1492        .validate_strict_values()
1493        .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))?;
1494    let output = eggress_pproxy_compat::translate_pproxy_args(&parsed)
1495        .map_err(|e| PyValueError::new_err(format!("pproxy translation error: {e}")))?;
1496    let gate = eggress_pproxy_compat::evaluate_execution_gate(&parsed, &output);
1497    if !gate.allows_start() {
1498        return Err(UnsupportedFeatureError::new_err(gate.blocker_summary()));
1499    }
1500    let (config, _) = eggress_config::validate_and_compile_toml_with_warnings(&output.toml)
1501        .map_err(|e| ConfigError::new_err(format!("pproxy config error: {e}")))?;
1502    let target = eggress_cli::parse_pproxy_test_target(target)
1503        .map_err(PyValueError::new_err)?
1504        .to_string();
1505    if config.upstreams.is_empty() {
1506        return Ok(0);
1507    }
1508    Ok(py.detach(|| {
1509        eggress_cli::run_upstream_test(&config, Some(&target), Duration::from_secs(10), false)
1510    }))
1511}
1512
1513#[pyclass]
1514struct PyReverseUriSummary {
1515    /// "server" or "client" or "unknown"
1516    role: String,
1517    scheme: String,
1518    /// "host:port" string in redacted form for display
1519    target: String,
1520    has_auth: bool,
1521    /// "reverse_servers" or "reverse_clients" or "unknown"
1522    toml_section: String,
1523    tls: bool,
1524    /// Modifiers parsed (e.g. "+tls", "+in")
1525    modifiers: Vec<String>,
1526}
1527
1528#[pymethods]
1529impl PyReverseUriSummary {
1530    #[getter]
1531    fn role(&self) -> &str {
1532        &self.role
1533    }
1534    #[getter]
1535    fn scheme(&self) -> &str {
1536        &self.scheme
1537    }
1538    #[getter]
1539    fn target(&self) -> &str {
1540        &self.target
1541    }
1542    #[getter]
1543    fn has_auth(&self) -> bool {
1544        self.has_auth
1545    }
1546    #[getter]
1547    fn toml_section(&self) -> &str {
1548        &self.toml_section
1549    }
1550    #[getter]
1551    fn tls(&self) -> bool {
1552        self.tls
1553    }
1554    #[getter]
1555    fn modifiers(&self) -> Vec<String> {
1556        self.modifiers.clone()
1557    }
1558    fn __repr__(&self) -> String {
1559        format!(
1560            "ReverseUriSummary(role={}, target={}, toml_section={}, has_auth={})",
1561            self.role, self.target, self.toml_section, self.has_auth
1562        )
1563    }
1564}
1565
1566#[pyfunction]
1567fn describe_reverse_pproxy_uri(uri: &str) -> PyResult<PyReverseUriSummary> {
1568    let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri(uri)
1569        .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid pproxy URI: {e}")))?;
1570
1571    let (role, toml_section) = if parsed.is_reverse_listener() {
1572        ("server", "reverse_servers")
1573    } else if parsed.is_backward() {
1574        ("client", "reverse_clients")
1575    } else {
1576        ("unknown", "unknown")
1577    };
1578
1579    let target = parsed.redacted_display();
1580
1581    // Modifiers encoded in the scheme: +tls, +ssl, +in, ...
1582    let mut modifiers: Vec<String> = Vec::new();
1583    if parsed.tls {
1584        modifiers.push("+tls".to_string());
1585    }
1586    if parsed.ssl {
1587        modifiers.push("+ssl".to_string());
1588    }
1589    for _ in 0..parsed.backward_num {
1590        modifiers.push("+in".to_string());
1591    }
1592
1593    Ok(PyReverseUriSummary {
1594        role: role.to_string(),
1595        scheme: parsed.scheme.clone(),
1596        target,
1597        has_auth: parsed.username.is_some(),
1598        toml_section: toml_section.to_string(),
1599        tls: parsed.tls,
1600        modifiers,
1601    })
1602}
1603
1604/// Python-accessible outbound connector for direct proxy chain connections.
1605///
1606/// This wraps the Rust `OutboundConnector` and provides a Python interface
1607/// for making outbound TCP connections through a configured proxy chain
1608/// without starting a listener service.
1609///
1610/// The returned stream owns the Tokio runtime needed by the Rust transport,
1611/// so it remains usable after the connector is dropped. Blocking methods
1612/// release the Python GIL while they wait for network I/O.
1613#[pyclass]
1614struct PyOutboundConnector {
1615    inner: eggress_embed::outbound::OutboundConnector,
1616}
1617
1618struct OutboundStreamState {
1619    stream: Option<eggress_core::BoxStream>,
1620}
1621
1622/// A connected native outbound stream.
1623///
1624/// This deliberately exposes a small socket/asyncio-stream compatible surface
1625/// instead of a file descriptor. Advanced transports (TLS, WebSocket, H2,
1626/// and multi-hop chains) do not necessarily have a meaningful OS socket after
1627/// the first hop. `recv`/`sendall` aliases are provided for pproxy programs;
1628/// `read`/`write` are the canonical APIs.
1629#[pyclass]
1630struct PyOutboundStream {
1631    runtime: Arc<tokio::runtime::Runtime>,
1632    state: std::sync::Mutex<OutboundStreamState>,
1633    peer_addr: Option<String>,
1634    local_addr: Option<String>,
1635    hop_count: usize,
1636}
1637
1638impl PyOutboundStream {
1639    fn with_stream<T>(
1640        &self,
1641        operation: impl FnOnce(&mut eggress_core::BoxStream) -> T,
1642    ) -> PyResult<T> {
1643        let mut state = self
1644            .state
1645            .lock()
1646            .map_err(|_| ConnectionError::new_err("outbound stream lock poisoned"))?;
1647        let stream = state
1648            .stream
1649            .as_mut()
1650            .ok_or_else(|| ConnectionClosedError::new_err("outbound stream is closed"))?;
1651        Ok(operation(stream))
1652    }
1653
1654    fn closed_inner(&self) -> bool {
1655        self.state
1656            .lock()
1657            .map(|state| state.stream.is_none())
1658            .unwrap_or(true)
1659    }
1660}
1661
1662impl Drop for PyOutboundStream {
1663    fn drop(&mut self) {
1664        // Dropping the BoxStream closes the transport. Do not block from a
1665        // destructor: Python may be shutting down and the runtime can be
1666        // unavailable at that point. Use try_lock to avoid blocking the
1667        // Python GC while another thread holds the state mutex.
1668        if let Ok(mut state) = self.state.try_lock() {
1669            state.stream.take();
1670        }
1671    }
1672}
1673
1674#[pymethods]
1675impl PyOutboundStream {
1676    #[getter]
1677    fn closed(&self) -> bool {
1678        self.closed_inner()
1679    }
1680
1681    fn is_closing(&self) -> bool {
1682        self.closed_inner()
1683    }
1684
1685    #[getter]
1686    fn peername(&self) -> Option<&str> {
1687        self.peer_addr.as_deref()
1688    }
1689
1690    #[getter]
1691    fn sockname(&self) -> Option<&str> {
1692        self.local_addr.as_deref()
1693    }
1694
1695    fn get_extra_info(
1696        &self,
1697        py: Python<'_>,
1698        name: &str,
1699        default: Option<Py<PyAny>>,
1700    ) -> PyResult<Py<PyAny>> {
1701        match name {
1702            "peername" => match self.peer_addr.as_ref() {
1703                Some(value) => Ok(PyString::new(py, value).into_any().unbind()),
1704                None => Ok(default.unwrap_or_else(|| py.None())),
1705            },
1706            "sockname" => match self.local_addr.as_ref() {
1707                Some(value) => Ok(PyString::new(py, value).into_any().unbind()),
1708                None => Ok(default.unwrap_or_else(|| py.None())),
1709            },
1710            "hop_count" => {
1711                let hop_count = self.hop_count.to_string();
1712                Ok(PyString::new(py, &hop_count).into_any().unbind())
1713            }
1714            _ => Ok(default.unwrap_or_else(|| py.None())),
1715        }
1716    }
1717
1718    const MAX_READ_LEN: usize = 16 * 1024 * 1024;
1719
1720    fn read(&self, py: Python<'_>, n: i64) -> PyResult<Vec<u8>> {
1721        if n < -1 {
1722            return Err(PyValueError::new_err(
1723                "read length must be -1 or non-negative",
1724            ));
1725        }
1726        let len = if n == -1 {
1727            None
1728        } else {
1729            let len = usize::try_from(n).map_err(|_| {
1730                PyValueError::new_err(format!(
1731                    "read length does not fit in usize on this platform (n={n})"
1732                ))
1733            })?;
1734            if len > Self::MAX_READ_LEN {
1735                return Err(PyValueError::new_err(format!(
1736                    "read length {len} exceeds maximum {}",
1737                    Self::MAX_READ_LEN
1738                )));
1739            }
1740            Some(len)
1741        };
1742        let runtime = self.runtime.clone();
1743        self.with_stream(|stream| {
1744            py.detach(|| {
1745                runtime.block_on(async {
1746                    if let Some(len) = len {
1747                        let mut data = vec![0_u8; len];
1748                        let count = stream.read(&mut data).await?;
1749                        data.truncate(count);
1750                        Ok(data)
1751                    } else {
1752                        let mut data = Vec::new();
1753                        let mut limited = stream.take(Self::MAX_READ_LEN as u64);
1754                        limited.read_to_end(&mut data).await.map(|_| data)
1755                    }
1756                })
1757            })
1758        })?
1759        .map_err(|e: std::io::Error| ConnectionError::new_err(format!("read failed: {e}")))
1760    }
1761
1762    fn readexactly(&self, py: Python<'_>, n: usize) -> PyResult<Vec<u8>> {
1763        if n > Self::MAX_READ_LEN {
1764            return Err(PyValueError::new_err(format!(
1765                "readexactly length {n} exceeds maximum {}",
1766                Self::MAX_READ_LEN
1767            )));
1768        }
1769        let runtime = self.runtime.clone();
1770        self.with_stream(|stream| {
1771            py.detach(|| {
1772                runtime.block_on(async {
1773                    let mut data = vec![0_u8; n];
1774                    stream.read_exact(&mut data).await.map(|_| data)
1775                })
1776            })
1777        })?
1778        .map_err(|e: std::io::Error| ConnectionError::new_err(format!("readexactly failed: {e}")))
1779    }
1780
1781    fn write(&self, py: Python<'_>, data: &[u8]) -> PyResult<usize> {
1782        let runtime = self.runtime.clone();
1783        self.with_stream(|stream| {
1784            py.detach(|| runtime.block_on(async { stream.write(data).await }))
1785        })?
1786        .map_err(|e: std::io::Error| ConnectionError::new_err(format!("write failed: {e}")))
1787    }
1788
1789    fn sendall(&self, py: Python<'_>, data: &[u8]) -> PyResult<()> {
1790        let runtime = self.runtime.clone();
1791        self.with_stream(|stream| {
1792            py.detach(|| runtime.block_on(async { stream.write_all(data).await }))
1793        })?
1794        .map_err(|e: std::io::Error| ConnectionError::new_err(format!("sendall failed: {e}")))
1795    }
1796
1797    fn drain(&self, py: Python<'_>) -> PyResult<()> {
1798        let runtime = self.runtime.clone();
1799        self.with_stream(|stream| py.detach(|| runtime.block_on(async { stream.flush().await })))?
1800            .map_err(|e: std::io::Error| ConnectionError::new_err(format!("drain failed: {e}")))
1801    }
1802
1803    fn write_eof(&self, py: Python<'_>) -> PyResult<()> {
1804        let runtime = self.runtime.clone();
1805        self.with_stream(|stream| {
1806            py.detach(|| runtime.block_on(async { stream.shutdown().await }))
1807        })?
1808        .map_err(|e: std::io::Error| ConnectionError::new_err(format!("write_eof failed: {e}")))
1809    }
1810
1811    fn close(&self) -> PyResult<()> {
1812        let mut state = self
1813            .state
1814            .lock()
1815            .map_err(|_| ConnectionError::new_err("outbound stream lock poisoned"))?;
1816        state.stream.take();
1817        Ok(())
1818    }
1819
1820    fn wait_closed(&self) -> PyResult<()> {
1821        self.close()
1822    }
1823
1824    fn __enter__(slf: Py<Self>) -> Py<Self> {
1825        slf
1826    }
1827
1828    fn __exit__(
1829        &self,
1830        _py: Python<'_>,
1831        _exc_type: &Bound<'_, PyAny>,
1832        _exc_value: &Bound<'_, PyAny>,
1833        _traceback: &Bound<'_, PyAny>,
1834    ) -> PyResult<bool> {
1835        self.close()?;
1836        Ok(false)
1837    }
1838
1839    fn __del__(&self) {
1840        if !self.closed_inner() {
1841            eprintln!("Warning: outbound stream was not explicitly closed; cleaning up");
1842            let _ = self.close();
1843        }
1844    }
1845
1846    fn __repr__(&self) -> String {
1847        format!(
1848            "OutboundStream(peername={:?}, hop_count={}, closed={})",
1849            self.peer_addr,
1850            self.hop_count,
1851            self.closed_inner()
1852        )
1853    }
1854}
1855
1856#[pymethods]
1857impl PyOutboundConnector {
1858    /// Create a connector from a pproxy-style URI string.
1859    #[staticmethod]
1860    fn from_pproxy_uri(uri: &str) -> PyResult<Self> {
1861        let inner = eggress_embed::outbound::OutboundConnector::from_pproxy_uri(uri)
1862            .map_err(|e| ConnectionError::new_err(format!("failed to create connector: {e}")))?;
1863        Ok(Self { inner })
1864    }
1865
1866    /// Create a connector from a TOML config string.
1867    #[staticmethod]
1868    fn from_toml(config_toml: &str) -> PyResult<Self> {
1869        let inner = eggress_embed::outbound::OutboundConnector::from_toml(config_toml)
1870            .map_err(|e| ConnectionError::new_err(format!("failed to create connector: {e}")))?;
1871        Ok(Self { inner })
1872    }
1873
1874    /// Validate that a TOML config is usable for outbound connections.
1875    ///
1876    /// Returns the number of hops in the first upstream's chain.
1877    #[staticmethod]
1878    fn validate_config(config_toml: &str) -> PyResult<usize> {
1879        eggress_embed::outbound::OutboundConnector::validate_outbound_config(config_toml)
1880            .map_err(|e| ConnectionError::new_err(format!("config validation failed: {e}")))
1881    }
1882
1883    /// Get the number of upstreams configured.
1884    fn upstream_count(&self) -> usize {
1885        self.inner.upstream_count()
1886    }
1887
1888    /// Open a native outbound TCP stream. No local listener is created.
1889    #[pyo3(signature = (host, port, timeout=None))]
1890    fn connect_tcp(
1891        &self,
1892        py: Python<'_>,
1893        host: &str,
1894        port: u16,
1895        timeout: Option<f64>,
1896    ) -> PyResult<PyOutboundStream> {
1897        if host.is_empty() {
1898            return Err(PyValueError::new_err("host must not be empty"));
1899        }
1900        let timeout = timeout
1901            .map(|seconds| {
1902                if !seconds.is_finite() || seconds <= 0.0 {
1903                    Err(PyValueError::new_err("timeout must be finite and positive"))
1904                } else {
1905                    Ok(Duration::from_secs_f64(seconds))
1906                }
1907            })
1908            .transpose()?;
1909        let runtime = outbound_runtime().map_err(ConnectionError::new_err)?;
1910        let inner = &self.inner;
1911        let result = py.detach(|| {
1912            runtime.block_on(async {
1913                match timeout {
1914                    Some(timeout) => inner.connect_tcp_timeout(host, port, timeout).await,
1915                    None => inner.connect_tcp(host, port).await,
1916                }
1917            })
1918        });
1919        let (stream, info) = result
1920            .map_err(|e| ConnectionError::new_err(format!("outbound connect failed: {e}")))?;
1921        Ok(PyOutboundStream {
1922            runtime,
1923            state: std::sync::Mutex::new(OutboundStreamState {
1924                stream: Some(stream),
1925            }),
1926            peer_addr: info.peer_addr.map(|addr| addr.to_string()),
1927            local_addr: info.local_addr.map(|addr| addr.to_string()),
1928            hop_count: info.hop_count,
1929        })
1930    }
1931
1932    /// Get connection metadata for a target host:port.
1933    ///
1934    /// Resolves the first hop endpoint and returns metadata about the
1935    /// configured chain without actually connecting.
1936    fn preview_connect(&self, py: Python<'_>, host: &str, port: u16) -> PyResult<Py<PyDict>> {
1937        let dict = PyDict::new(py);
1938        dict.set_item("target_host", host)?;
1939        dict.set_item("target_port", port)?;
1940        dict.set_item("hop_count", self.inner.upstream_count())?;
1941        Ok(dict.into())
1942    }
1943}
1944
1945#[pymodule]
1946fn _eggress(m: &Bound<'_, PyModule>) -> PyResult<()> {
1947    m.add_class::<PyEggressConfig>()?;
1948    m.add_class::<PyEggressService>()?;
1949    m.add_class::<PyEggressHandle>()?;
1950    m.add_class::<PyAppliedSystemProxy>()?;
1951    m.add_class::<PyTranslationWarning>()?;
1952    m.add_class::<PyUnsupportedFeature>()?;
1953    m.add_class::<PyTranslationResult>()?;
1954    m.add_class::<PyReverseUriSummary>()?;
1955    m.add_class::<PyUriInfo>()?;
1956    m.add_class::<PyDiagnostic>()?;
1957    m.add_class::<PyConnection>()?;
1958    m.add_class::<PyOutboundConnector>()?;
1959    m.add_class::<PyOutboundStream>()?;
1960    m.add_function(wrap_pyfunction!(translate_pproxy_args, m)?)?;
1961    m.add_function(wrap_pyfunction!(translate_pproxy_uri, m)?)?;
1962    m.add_function(wrap_pyfunction!(check_pproxy_args, m)?)?;
1963    m.add_function(wrap_pyfunction!(validate_pproxy_args, m)?)?;
1964    m.add_function(wrap_pyfunction!(pproxy_runtime_options, m)?)?;
1965    m.add_function(wrap_pyfunction!(init_pproxy_logging, m)?)?;
1966    m.add_function(wrap_pyfunction!(run_pproxy_test, m)?)?;
1967    m.add_function(wrap_pyfunction!(describe_reverse_pproxy_uri, m)?)?;
1968    m.add_function(wrap_pyfunction!(check_pproxy_uri, m)?)?;
1969    m.add_function(wrap_pyfunction!(redact_pproxy_uri, m)?)?;
1970    m.add_function(wrap_pyfunction!(diagnostics_for_uri, m)?)?;
1971    m.add_function(wrap_pyfunction!(supported_features, m)?)?;
1972    m.add_function(wrap_pyfunction!(explain_config_toml, m)?)?;
1973    m.add_function(wrap_pyfunction!(explain_pproxy_args, m)?)?;
1974    m.add_function(wrap_pyfunction!(explain_pproxy_uri, m)?)?;
1975    m.add_function(wrap_pyfunction!(route_explain, m)?)?;
1976    m.add_function(wrap_pyfunction!(test_upstream_connect, m)?)?;
1977    m.add_function(wrap_pyfunction!(apply_system_proxy, m)?)?;
1978    m.add("EggressError", m.py().get_type::<EggressError>())?;
1979    m.add("ConfigError", m.py().get_type::<ConfigError>())?;
1980    m.add("StartupError", m.py().get_type::<StartupError>())?;
1981    m.add("ReloadError", m.py().get_type::<ReloadError>())?;
1982    m.add("ShutdownError", m.py().get_type::<ShutdownError>())?;
1983    m.add(
1984        "UnsupportedFeatureError",
1985        m.py().get_type::<UnsupportedFeatureError>(),
1986    )?;
1987    m.add("InternalError", m.py().get_type::<InternalError>())?;
1988    m.add("ConnectionError", m.py().get_type::<ConnectionError>())?;
1989    m.add(
1990        "ConnectionClosedError",
1991        m.py().get_type::<ConnectionClosedError>(),
1992    )?;
1993    m.add("TimeoutError", m.py().get_type::<TimeoutError>())?;
1994    m.add("DnsError", m.py().get_type::<DnsError>())?;
1995    m.add("AuthError", m.py().get_type::<AuthError>())?;
1996    m.add("TlsError", m.py().get_type::<TlsError>())?;
1997    m.add("LoopMismatchError", m.py().get_type::<LoopMismatchError>())?;
1998    m.add(
1999        "ConnectionCancelledError",
2000        m.py().get_type::<ConnectionCancelledError>(),
2001    )?;
2002    m.add(
2003        "UseAfterCloseError",
2004        m.py().get_type::<UseAfterCloseError>(),
2005    )?;
2006    m.add(
2007        "UdpAssociationError",
2008        m.py().get_type::<UdpAssociationError>(),
2009    )?;
2010    m.add(
2011        "UnsupportedCompositionError",
2012        m.py().get_type::<UnsupportedCompositionError>(),
2013    )?;
2014    m.add("__version__", env!("CARGO_PKG_VERSION"))?;
2015    Ok(())
2016}
2017
2018#[cfg(test)]
2019mod tests {
2020    #[test]
2021    fn uri_translation_smoke() {
2022        let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri("http://127.0.0.1:8080")
2023            .expect("valid pproxy URI");
2024        assert_eq!(parsed.scheme, "http");
2025        assert_eq!(parsed.port, 8080);
2026
2027        let args = eggress_pproxy_compat::PproxyArgs::parse(&["http://127.0.0.1:8080".to_string()])
2028            .expect("valid pproxy args");
2029        let output =
2030            eggress_pproxy_compat::translate_pproxy_args(&args).expect("translatable pproxy args");
2031        assert!(output.toml.contains("127.0.0.1:8080"));
2032    }
2033
2034    #[test]
2035    fn config_conversion_smoke() {
2036        let args = eggress_pproxy_compat::PproxyArgs::parse(&["http://127.0.0.1:8080".to_string()])
2037            .expect("valid pproxy args");
2038        let output =
2039            eggress_pproxy_compat::translate_pproxy_args(&args).expect("translatable pproxy args");
2040        let config = eggress_embed::EggressConfig::from_toml_str(&output.toml)
2041            .expect("translated TOML converts to embed config");
2042        assert!(!config.source_toml().is_empty());
2043    }
2044
2045    #[test]
2046    fn error_mapping_categories_are_stable() {
2047        use eggress_embed::EggressError;
2048
2049        let cases = [
2050            (EggressError::Config("bad config".into()), "config"),
2051            (EggressError::Runtime("runtime".into()), "runtime"),
2052            (EggressError::Startup("startup".into()), "startup"),
2053            (EggressError::Reload("reload".into()), "reload"),
2054            (EggressError::Shutdown("shutdown".into()), "shutdown"),
2055            (
2056                EggressError::UnsupportedFeature {
2057                    feature: "feature".into(),
2058                    message: "unsupported".into(),
2059                },
2060                "unsupported_feature",
2061            ),
2062            (EggressError::Internal("internal".into()), "internal"),
2063        ];
2064        for (error, category) in cases {
2065            assert_eq!(error.category(), category);
2066        }
2067    }
2068}