Skip to main content

eggress_core/
capability.rs

1use eggress_uri::ProtocolSpec;
2use eggress_uri::ProxyChainSpec;
3
4/// Transport capability for upstream chains.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum TransportCapability {
7    TcpConnect,
8    UdpAssociate,
9}
10
11/// Result of checking a specific transport capability.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum CapabilityResult {
14    Supported,
15    UnsupportedProtocol { protocol: String },
16    UnsupportedChain { reason: String },
17}
18
19/// Combined capabilities for an upstream chain.
20#[derive(Debug, Clone)]
21pub struct UpstreamCapabilities {
22    pub tcp_connect: CapabilityResult,
23    pub udp_associate: CapabilityResult,
24}
25
26impl UpstreamCapabilities {
27    pub fn is_tcp_supported(&self) -> bool {
28        self.tcp_connect == CapabilityResult::Supported
29    }
30
31    pub fn is_udp_supported(&self) -> bool {
32        self.udp_associate == CapabilityResult::Supported
33    }
34}
35
36/// Classify the capabilities of a proxy chain.
37///
38/// Rules:
39/// - Direct route (0 hops): TCP and UDP are not handled via upstream capability
40/// - HTTP upstream: TCP CONNECT supported; UDP unsupported
41/// - SOCKS4 upstream: TCP CONNECT supported; UDP unsupported
42/// - SOCKS5 upstream: TCP CONNECT supported; UDP supported
43/// - Shadowsocks upstream: TCP not advertised (non-standard AEAD framing);
44///   UDP supported (standard AEAD format)
45/// - Multi-hop: TCP may be supported; UDP is supported when every hop has a
46///   proven UDP codec (SOCKS5 or Shadowsocks)
47pub fn classify_upstream_chain(chain: &ProxyChainSpec) -> UpstreamCapabilities {
48    match chain.hops.len() {
49        0 => UpstreamCapabilities {
50            tcp_connect: CapabilityResult::UnsupportedChain {
51                reason: "direct".to_string(),
52            },
53            udp_associate: CapabilityResult::UnsupportedChain {
54                reason: "direct".to_string(),
55            },
56        },
57        1 => {
58            let hop = &chain.hops[0];
59            if hop.protocols.len() == 1 {
60                classify_single_protocol(hop.protocols[0])
61            } else if hop.protocols.contains(&ProtocolSpec::Quic)
62                && hop.protocols.iter().any(|protocol| {
63                    matches!(
64                        protocol,
65                        ProtocolSpec::Http
66                            | ProtocolSpec::HttpOnly
67                            | ProtocolSpec::Socks4
68                            | ProtocolSpec::Socks5
69                            | ProtocolSpec::Raw
70                    )
71                })
72            {
73                UpstreamCapabilities {
74                    tcp_connect: CapabilityResult::Supported,
75                    udp_associate: CapabilityResult::UnsupportedProtocol {
76                        protocol: "QUIC UDP stream mapping".to_string(),
77                    },
78                }
79            } else {
80                UpstreamCapabilities {
81                    tcp_connect: CapabilityResult::UnsupportedChain {
82                        reason: "multi-protocol".to_string(),
83                    },
84                    udp_associate: CapabilityResult::UnsupportedChain {
85                        reason: "multi-protocol".to_string(),
86                    },
87                }
88            }
89        }
90        _ if chain.hops.len() > 1 && chain.hops[0].protocols.contains(&ProtocolSpec::Quic) => {
91            UpstreamCapabilities {
92                tcp_connect: CapabilityResult::Supported,
93                udp_associate: CapabilityResult::UnsupportedChain {
94                    reason: "QUIC UDP stream mapping is only supported at the first hop"
95                        .to_string(),
96                },
97            }
98        }
99        _ => {
100            let udp_supported = chain.hops.iter().all(|hop| {
101                hop.protocols.len() == 1
102                    && matches!(
103                        hop.protocols[0],
104                        ProtocolSpec::Socks5 | ProtocolSpec::Shadowsocks
105                    )
106            });
107            UpstreamCapabilities {
108                tcp_connect: CapabilityResult::Supported,
109                udp_associate: if udp_supported {
110                    CapabilityResult::Supported
111                } else {
112                    CapabilityResult::UnsupportedChain {
113                        reason: "multi-hop contains a non-UDP protocol".to_string(),
114                    }
115                },
116            }
117        }
118    }
119}
120
121fn classify_single_protocol(protocol: ProtocolSpec) -> UpstreamCapabilities {
122    match protocol {
123        ProtocolSpec::Http | ProtocolSpec::HttpOnly => UpstreamCapabilities {
124            tcp_connect: CapabilityResult::Supported,
125            udp_associate: CapabilityResult::UnsupportedProtocol {
126                protocol: "Http".to_string(),
127            },
128        },
129        ProtocolSpec::Socks4 => UpstreamCapabilities {
130            tcp_connect: CapabilityResult::Supported,
131            udp_associate: CapabilityResult::UnsupportedProtocol {
132                protocol: "Socks4".to_string(),
133            },
134        },
135        ProtocolSpec::Socks5 => UpstreamCapabilities {
136            tcp_connect: CapabilityResult::Supported,
137            udp_associate: CapabilityResult::Supported,
138        },
139        ProtocolSpec::Shadowsocks => UpstreamCapabilities {
140            tcp_connect: CapabilityResult::Supported,
141            udp_associate: CapabilityResult::Supported,
142        },
143        ProtocolSpec::ShadowsocksR => UpstreamCapabilities {
144            tcp_connect: CapabilityResult::Supported,
145            udp_associate: CapabilityResult::UnsupportedProtocol {
146                protocol: "ShadowsocksR".to_string(),
147            },
148        },
149        ProtocolSpec::Trojan => UpstreamCapabilities {
150            tcp_connect: CapabilityResult::Supported,
151            udp_associate: CapabilityResult::UnsupportedProtocol {
152                protocol: "Trojan".to_string(),
153            },
154        },
155        ProtocolSpec::Http2 => UpstreamCapabilities {
156            tcp_connect: CapabilityResult::Supported,
157            udp_associate: CapabilityResult::UnsupportedProtocol {
158                protocol: "Http2".to_string(),
159            },
160        },
161        ProtocolSpec::Http3 => UpstreamCapabilities {
162            tcp_connect: CapabilityResult::Supported,
163            udp_associate: CapabilityResult::UnsupportedProtocol {
164                protocol: "Http3".to_string(),
165            },
166        },
167        ProtocolSpec::Quic => UpstreamCapabilities {
168            tcp_connect: CapabilityResult::UnsupportedChain {
169                reason: "QUIC requires an application protocol".to_string(),
170            },
171            udp_associate: CapabilityResult::UnsupportedProtocol {
172                protocol: "Quic".to_string(),
173            },
174        },
175        ProtocolSpec::WebSocket => UpstreamCapabilities {
176            tcp_connect: CapabilityResult::Supported,
177            udp_associate: CapabilityResult::UnsupportedProtocol {
178                protocol: "WebSocket".to_string(),
179            },
180        },
181        ProtocolSpec::Raw | ProtocolSpec::Ssh | ProtocolSpec::Unix => UpstreamCapabilities {
182            tcp_connect: CapabilityResult::Supported,
183            udp_associate: CapabilityResult::UnsupportedProtocol {
184                protocol: "Raw".to_string(),
185            },
186        },
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use eggress_uri::{CredentialSpec, EndpointSpec, ProxyHopSpec};
194
195    fn chain(hops: Vec<ProxyHopSpec>) -> ProxyChainSpec {
196        ProxyChainSpec { hops }
197    }
198
199    fn hop(protocols: Vec<ProtocolSpec>) -> ProxyHopSpec {
200        ProxyHopSpec {
201            protocols,
202            endpoint: EndpointSpec {
203                host: "proxy.example".to_string(),
204                port: 1080,
205            },
206            credentials: None,
207            rule: None,
208            local_bind: None,
209            tls: false,
210            server_name: None,
211            insecure: false,
212            plugins: Vec::new(),
213            auth_prefix: None,
214        }
215    }
216
217    fn hop_with_creds(protocols: Vec<ProtocolSpec>) -> ProxyHopSpec {
218        ProxyHopSpec {
219            protocols,
220            endpoint: EndpointSpec {
221                host: "proxy.example".to_string(),
222                port: 1080,
223            },
224            credentials: Some(CredentialSpec {
225                username: "user".to_string(),
226                password: "pass".to_string(),
227            }),
228            rule: None,
229            local_bind: None,
230            tls: false,
231            server_name: None,
232            insecure: false,
233            plugins: Vec::new(),
234            auth_prefix: None,
235        }
236    }
237
238    #[test]
239    fn single_socks5_hop_supported() {
240        let c = chain(vec![hop(vec![ProtocolSpec::Socks5])]);
241        let caps = classify_upstream_chain(&c);
242        assert!(caps.is_tcp_supported());
243        assert!(caps.is_udp_supported());
244        assert_eq!(caps.tcp_connect, CapabilityResult::Supported);
245        assert_eq!(caps.udp_associate, CapabilityResult::Supported);
246    }
247
248    #[test]
249    fn single_socks5_with_credentials_supported() {
250        let c = chain(vec![hop_with_creds(vec![ProtocolSpec::Socks5])]);
251        let caps = classify_upstream_chain(&c);
252        assert!(caps.is_tcp_supported());
253        assert!(caps.is_udp_supported());
254    }
255
256    #[test]
257    fn single_http_hop() {
258        let c = chain(vec![hop(vec![ProtocolSpec::Http])]);
259        let caps = classify_upstream_chain(&c);
260        assert!(caps.is_tcp_supported());
261        assert!(!caps.is_udp_supported());
262        assert_eq!(caps.tcp_connect, CapabilityResult::Supported);
263        assert_eq!(
264            caps.udp_associate,
265            CapabilityResult::UnsupportedProtocol {
266                protocol: "Http".to_string()
267            }
268        );
269    }
270
271    #[test]
272    fn single_socks4_hop() {
273        let c = chain(vec![hop(vec![ProtocolSpec::Socks4])]);
274        let caps = classify_upstream_chain(&c);
275        assert!(caps.is_tcp_supported());
276        assert!(!caps.is_udp_supported());
277        assert_eq!(
278            caps.udp_associate,
279            CapabilityResult::UnsupportedProtocol {
280                protocol: "Socks4".to_string()
281            }
282        );
283    }
284
285    #[test]
286    fn single_shadowsocks_hop() {
287        let c = chain(vec![hop(vec![ProtocolSpec::Shadowsocks])]);
288        let caps = classify_upstream_chain(&c);
289        assert!(caps.is_tcp_supported());
290        assert!(caps.is_udp_supported());
291        assert_eq!(caps.tcp_connect, CapabilityResult::Supported);
292        assert_eq!(caps.udp_associate, CapabilityResult::Supported);
293    }
294
295    #[test]
296    fn multi_protocol_hop_unsupported() {
297        let c = chain(vec![hop(vec![ProtocolSpec::Http, ProtocolSpec::Socks5])]);
298        let caps = classify_upstream_chain(&c);
299        assert!(!caps.is_tcp_supported());
300        assert!(!caps.is_udp_supported());
301        assert_eq!(
302            caps.tcp_connect,
303            CapabilityResult::UnsupportedChain {
304                reason: "multi-protocol".to_string()
305            }
306        );
307    }
308
309    #[test]
310    fn multi_hop_chain_tcp_supported_udp_unsupported() {
311        let c = chain(vec![
312            hop(vec![ProtocolSpec::Socks5]),
313            hop(vec![ProtocolSpec::Http]),
314        ]);
315        let caps = classify_upstream_chain(&c);
316        assert!(caps.is_tcp_supported());
317        assert!(!caps.is_udp_supported());
318        assert_eq!(caps.tcp_connect, CapabilityResult::Supported);
319        assert_eq!(
320            caps.udp_associate,
321            CapabilityResult::UnsupportedChain {
322                reason: "multi-hop contains a non-UDP protocol".to_string()
323            }
324        );
325    }
326
327    #[test]
328    fn empty_hops_direct() {
329        let c = chain(vec![]);
330        let caps = classify_upstream_chain(&c);
331        assert!(!caps.is_tcp_supported());
332        assert!(!caps.is_udp_supported());
333        assert_eq!(
334            caps.tcp_connect,
335            CapabilityResult::UnsupportedChain {
336                reason: "direct".to_string()
337            }
338        );
339        assert_eq!(
340            caps.udp_associate,
341            CapabilityResult::UnsupportedChain {
342                reason: "direct".to_string()
343            }
344        );
345    }
346
347    #[test]
348    fn unsupported_reason_labels_stable() {
349        let c = chain(vec![]);
350        let caps = classify_upstream_chain(&c);
351        match &caps.tcp_connect {
352            CapabilityResult::UnsupportedChain { reason } => {
353                assert_eq!(reason, "direct");
354            }
355            _ => panic!("expected UnsupportedChain"),
356        }
357
358        let c = chain(vec![
359            hop(vec![ProtocolSpec::Socks5]),
360            hop(vec![ProtocolSpec::Http]),
361        ]);
362        let caps = classify_upstream_chain(&c);
363        match &caps.udp_associate {
364            CapabilityResult::UnsupportedChain { reason } => {
365                assert_eq!(reason, "multi-hop contains a non-UDP protocol");
366            }
367            _ => panic!("expected UnsupportedChain"),
368        }
369
370        let c = chain(vec![hop(vec![ProtocolSpec::Http])]);
371        let caps = classify_upstream_chain(&c);
372        match &caps.udp_associate {
373            CapabilityResult::UnsupportedProtocol { protocol } => {
374                assert_eq!(protocol, "Http");
375            }
376            _ => panic!("expected UnsupportedProtocol"),
377        }
378    }
379}