Skip to main content

eggress_embed/
outbound.rs

1//! Native outbound connector for proxy chains.
2//!
3//! This module provides [`OutboundConnector`], which compiles a TOML config
4//! and executes the chain engine directly to open TCP connections through a
5//! configured proxy chain without starting a listener service.
6
7use std::sync::Arc;
8use std::time::Duration;
9
10use crate::EggressError;
11
12/// Metadata about an established outbound connection.
13#[derive(Debug, Clone)]
14pub struct OutboundInfo {
15    /// The local address of the underlying TCP connection (if available).
16    pub local_addr: Option<std::net::SocketAddr>,
17    /// The remote address of the first hop.
18    pub peer_addr: Option<std::net::SocketAddr>,
19    /// The chain hops that were traversed.
20    pub hop_count: usize,
21}
22
23/// A UDP association through a SOCKS5 proxy.
24///
25/// Contains the relay address to send/receive UDP datagrams and
26/// the control stream that must remain open for the association lifetime.
27pub struct UdpAssociation {
28    /// The UDP relay address of the SOCKS5 proxy.
29    pub relay_addr: std::net::SocketAddr,
30    /// The control TCP stream (must stay open for the association).
31    pub control_stream: Option<eggress_core::BoxStream>,
32    /// The target address for datagrams.
33    pub target: eggress_core::TargetAddr,
34}
35
36/// Resolve a proxy endpoint address (host:port) to a SocketAddr.
37///
38/// For IP addresses, returns directly. For domains, performs DNS lookup.
39async fn resolve_endpoint_addr(
40    endpoint: &eggress_uri::EndpointSpec,
41) -> Option<std::net::SocketAddr> {
42    if let Ok(ip) = endpoint.host.parse::<std::net::IpAddr>() {
43        return Some(std::net::SocketAddr::new(ip, endpoint.port));
44    }
45    let lookup = format!("{}:{}", endpoint.host, endpoint.port);
46    let result = tokio::net::lookup_host(&lookup).await.ok()?.next();
47    result
48}
49
50/// A native outbound connector that executes the chain engine directly.
51///
52/// This compiles routing/upstream state from a TOML config and provides
53/// methods to open TCP connections through the configured proxy chain
54/// without starting a listener service.
55pub struct OutboundConnector {
56    runtime_config: Option<Arc<eggress_config::compile::RuntimeConfig>>,
57    chain_executor: eggress_core::chain::ChainExecutor,
58    direct: bool,
59}
60
61impl OutboundConnector {
62    /// Create a connector from a TOML config string.
63    pub fn from_toml(config_toml: &str) -> Result<Self, EggressError> {
64        let config: eggress_config::model::ConfigFile =
65            toml::from_str(config_toml).map_err(|e| EggressError::Config(e.to_string()))?;
66
67        if let Some(version) = config.version {
68            if version != 1 {
69                return Err(EggressError::Config(format!(
70                    "unsupported config version: {version}"
71                )));
72            }
73        }
74
75        eggress_config::validate::validate_config(&config).map_err(|errors| {
76            let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
77            EggressError::Config(messages.join("; "))
78        })?;
79
80        let runtime_config = eggress_config::compile::compile_config(&config)
81            .map_err(|e| EggressError::Config(e.to_string()))?;
82
83        if runtime_config.upstreams.is_empty() {
84            return Err(EggressError::Config("no upstreams configured".to_string()));
85        }
86
87        let upstream = &runtime_config.upstreams[0];
88        if upstream.chain.hops.is_empty() {
89            return Err(EggressError::Config("upstream chain is empty".to_string()));
90        }
91
92        #[cfg(feature = "ssh")]
93        let chain_executor = eggress_server::build_chain_executor(None, None, None);
94        #[cfg(not(feature = "ssh"))]
95        let chain_executor = eggress_server::build_chain_executor(None, None);
96
97        Ok(Self {
98            runtime_config: Some(Arc::new(runtime_config)),
99            chain_executor,
100            direct: false,
101        })
102    }
103
104    /// Create a connector from a pproxy-style URI (e.g., "socks5://127.0.0.1:1080").
105    #[cfg(feature = "pproxy-compat")]
106    pub fn from_pproxy_uri(uri: &str) -> Result<Self, EggressError> {
107        let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri(uri)
108            .map_err(|e| EggressError::Config(e.to_string()))?;
109        if parsed.scheme == "direct" {
110            #[cfg(feature = "ssh")]
111            let executor = eggress_server::build_chain_executor(None, None, None);
112            #[cfg(not(feature = "ssh"))]
113            let executor = eggress_server::build_chain_executor(None, None);
114            return Ok(Self {
115                runtime_config: None,
116                chain_executor: executor,
117                direct: true,
118            });
119        }
120        let chain = eggress_pproxy_compat::uri::PproxyChain {
121            raw: uri.to_string(),
122            hops: vec![parsed],
123        };
124        let default_args = eggress_pproxy_compat::PproxyArgs::default_args();
125        let output = eggress_pproxy_compat::translate_from_uris(&default_args, &[], &[chain])
126            .map_err(|e| EggressError::Config(e.to_string()))?;
127        Self::from_toml(&output.toml)
128    }
129
130    /// Connect to a target host:port through the configured proxy chain.
131    ///
132    /// Returns the connected stream and connection metadata.
133    pub async fn connect_tcp(
134        &self,
135        host: &str,
136        port: u16,
137    ) -> Result<(eggress_core::BoxStream, OutboundInfo), EggressError> {
138        let target = eggress_core::TargetAddr {
139            host: if let Ok(ip) = host.parse::<std::net::IpAddr>() {
140                eggress_core::TargetHost::Ip(ip)
141            } else {
142                eggress_core::TargetHost::Domain(host.to_string())
143            },
144            port,
145        };
146
147        if self.direct {
148            let stream = eggress_core::connector::Connector::connect(
149                &eggress_core::connector::DirectConnector,
150                &target,
151            )
152            .await
153            .map_err(|e| EggressError::Runtime(e.to_string()))?;
154            return Ok((
155                stream,
156                OutboundInfo {
157                    local_addr: None,
158                    peer_addr: None,
159                    hop_count: 0,
160                },
161            ));
162        }
163
164        let runtime_config = self.runtime_config.as_ref().ok_or_else(|| {
165            EggressError::Runtime("outbound runtime configuration is unavailable".to_string())
166        })?;
167        let upstream = &runtime_config.upstreams[0];
168        let chain = &upstream.chain;
169
170        // Resolve the first hop endpoint address for metadata
171        let first_hop = &chain.hops[0];
172        let peer_addr = resolve_endpoint_addr(&first_hop.endpoint).await;
173
174        let stream = self
175            .chain_executor
176            .execute(&chain.hops, &target)
177            .await
178            .map_err(|e| EggressError::Runtime(e.to_string()))?;
179
180        let info = OutboundInfo {
181            local_addr: None,
182            peer_addr,
183            hop_count: chain.hops.len(),
184        };
185
186        Ok((stream, info))
187    }
188
189    /// Connect with a timeout.
190    pub async fn connect_tcp_timeout(
191        &self,
192        host: &str,
193        port: u16,
194        timeout: Duration,
195    ) -> Result<(eggress_core::BoxStream, OutboundInfo), EggressError> {
196        tokio::time::timeout(timeout, self.connect_tcp(host, port))
197            .await
198            .map_err(|_| EggressError::Runtime("connection timed out".to_string()))?
199    }
200
201    /// Create a UDP association through the configured proxy chain.
202    ///
203    /// Returns a `UdpAssociation` with the relay address to send/receive
204    /// UDP datagrams through the proxy chain.
205    ///
206    /// UDP association requires SOCKS5 with UDP ASSOCIATE support.
207    /// This method establishes the association and returns channel endpoints.
208    pub async fn associate_udp(
209        &self,
210        _target_host: &str,
211        _target_port: u16,
212    ) -> Result<UdpAssociation, EggressError> {
213        Err(EggressError::Runtime(
214            "UDP association through OutboundConnector is not yet implemented; \
215             use the listener-based approach for UDP"
216                .to_string(),
217        ))
218    }
219
220    /// Get the number of upstreams configured.
221    pub fn upstream_count(&self) -> usize {
222        self.runtime_config
223            .as_ref()
224            .map_or(0, |config| config.upstreams.len())
225    }
226
227    /// Validate that the config is usable for outbound connections.
228    ///
229    /// Returns the number of hops in the first upstream's chain.
230    pub fn validate_outbound_config(config_toml: &str) -> Result<usize, EggressError> {
231        let config: eggress_config::model::ConfigFile =
232            toml::from_str(config_toml).map_err(|e| EggressError::Config(e.to_string()))?;
233
234        if let Some(version) = config.version {
235            if version != 1 {
236                return Err(EggressError::Config(format!(
237                    "unsupported config version: {version}"
238                )));
239            }
240        }
241
242        eggress_config::validate::validate_config(&config).map_err(|errors| {
243            let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
244            EggressError::Config(messages.join("; "))
245        })?;
246
247        let runtime_config = eggress_config::compile::compile_config(&config)
248            .map_err(|e| EggressError::Config(e.to_string()))?;
249
250        if runtime_config.upstreams.is_empty() {
251            return Err(EggressError::Config(
252                "no upstreams configured; cannot make outbound connections".to_string(),
253            ));
254        }
255
256        let upstream = &runtime_config.upstreams[0];
257        let chain = &upstream.chain;
258
259        if chain.hops.is_empty() {
260            return Err(EggressError::Config(
261                "upstream chain is empty; cannot make outbound connections".to_string(),
262            ));
263        }
264
265        Ok(chain.hops.len())
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn test_outbound_connector_from_toml() {
275        let config = r#"
276            version = 1
277            [[listeners]]
278            name = "test"
279            bind = "127.0.0.1:0"
280            protocols = ["socks5"]
281            [[upstreams]]
282            id = "direct"
283            uri = "socks5://127.0.0.1:1080"
284        "#;
285        let connector = OutboundConnector::from_toml(config).unwrap();
286        assert_eq!(connector.upstream_count(), 1);
287    }
288
289    #[test]
290    fn test_validate_no_upstreams() {
291        let config = r#"
292            version = 1
293            [[listeners]]
294            name = "test"
295            bind = "127.0.0.1:0"
296            protocols = ["socks5"]
297        "#;
298        let result = OutboundConnector::validate_outbound_config(config);
299        assert!(result.is_err());
300        assert!(result.unwrap_err().to_string().contains("no upstreams"));
301    }
302
303    #[test]
304    fn test_validate_empty_chain() {
305        let config = r#"
306            version = 1
307            [[listeners]]
308            name = "test"
309            bind = "127.0.0.1:0"
310            protocols = ["socks5"]
311            [[upstreams]]
312            id = "up"
313            uri = "socks5://127.0.0.1:1080"
314        "#;
315        let result = OutboundConnector::validate_outbound_config(config);
316        assert!(result.is_ok());
317    }
318
319    #[test]
320    fn test_from_pproxy_uri() {
321        let connector = OutboundConnector::from_pproxy_uri("socks5://127.0.0.1:1080").unwrap();
322        assert_eq!(connector.upstream_count(), 1);
323    }
324}