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 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 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 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#[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#[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#[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
797fn 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 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 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 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 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 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 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 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 let security_list = PyList::empty(py);
951 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
988fn 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 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 let has_auth = !url.username().is_empty() || url.password().is_some();
1174 dict.set_item("has_auth", has_auth)?;
1175
1176 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 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#[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 #[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#[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#[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#[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#[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 role: String,
1517 scheme: String,
1518 target: String,
1520 has_auth: bool,
1521 toml_section: String,
1523 tls: bool,
1524 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 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#[pyclass]
1614struct PyOutboundConnector {
1615 inner: eggress_embed::outbound::OutboundConnector,
1616}
1617
1618struct OutboundStreamState {
1619 stream: Option<eggress_core::BoxStream>,
1620}
1621
1622#[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 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 #[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 #[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 #[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 fn upstream_count(&self) -> usize {
1885 self.inner.upstream_count()
1886 }
1887
1888 #[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 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}