Skip to main content

io_proxy/socks/v5/
connect.rs

1//! I/O-free coroutine running the SOCKS5 client `CONNECT` handshake
2//! ([RFC 1928] method negotiation + request/reply, [RFC 1929] auth).
3//!
4//! On success the tunnel to the target is open and the socket is
5//! positioned exactly at its first byte — the coroutine reads each
6//! length-framed message with an exact byte count, so nothing past the
7//! reply is ever consumed.
8//!
9//! [RFC 1928]: https://www.rfc-editor.org/rfc/rfc1928
10//! [RFC 1929]: https://www.rfc-editor.org/rfc/rfc1929
11
12use alloc::vec::Vec;
13
14use log::{debug, trace};
15use thiserror::Error;
16
17use crate::{
18    coroutine::{ProxyCoroutine, ProxyCoroutineState, ProxyYield},
19    socks::v5::{
20        ATYP_DOMAIN, ATYP_IPV4, ATYP_IPV6, AUTH_VERSION, CMD_CONNECT, METHOD_NO_ACCEPTABLE,
21        METHOD_NO_AUTH, METHOD_USER_PASS, RSV, VERSION, address::Socks5Address,
22        auth::Socks5Credentials, message::Socks5Reply,
23    },
24};
25
26/// Failure causes during the SOCKS5 `CONNECT` handshake.
27#[derive(Clone, Debug, Error, PartialEq, Eq)]
28pub enum Socks5ConnectError {
29    /// The proxy replied with a SOCKS version other than `0x05`.
30    #[error("SOCKS5 connect failed: proxy returned version {0:#04x}, expected 0x05")]
31    UnexpectedVersion(u8),
32    /// The proxy accepted none of the offered authentication methods.
33    #[error("SOCKS5 connect failed: proxy rejected all offered authentication methods")]
34    NoAcceptableAuthMethod,
35    /// The proxy selected an authentication method the client did not
36    /// offer or does not support.
37    #[error("SOCKS5 connect failed: proxy selected unsupported authentication method {0:#04x}")]
38    UnsupportedAuthMethod(u8),
39    /// The proxy asked for username/password auth but no credentials were
40    /// configured.
41    #[error(
42        "SOCKS5 connect failed: proxy requires authentication but no credentials were provided"
43    )]
44    AuthRequired,
45    /// The auth sub-negotiation carried a version other than `0x01`.
46    #[error("SOCKS5 connect failed: invalid auth sub-negotiation version {0:#04x}, expected 0x01")]
47    UnexpectedAuthVersion(u8),
48    /// The proxy rejected the username/password credentials.
49    #[error("SOCKS5 connect failed: proxy rejected the username/password credentials")]
50    AuthRejected,
51    /// The proxy refused the `CONNECT` with a known reply code.
52    #[error("SOCKS5 connect failed: {0}")]
53    Reply(Socks5Reply),
54    /// The proxy replied with a reply code outside the RFC 1928 range.
55    #[error("SOCKS5 connect failed: proxy returned unknown reply code {0:#04x}")]
56    UnknownReply(u8),
57    /// The proxy reply used an address type the client cannot parse.
58    #[error("SOCKS5 connect failed: proxy returned unknown address type {0:#04x}")]
59    UnknownAddressType(u8),
60    /// A message from the proxy was shorter than its fixed framing
61    /// requires (a misbehaving pump or proxy).
62    #[error("SOCKS5 connect failed: proxy sent a malformed or truncated message")]
63    Malformed,
64}
65
66/// Handshake step; each read step yields [`ProxyYield::WantsRead`] with an
67/// exact length, then consumes exactly those bytes on the next resume.
68#[derive(Debug)]
69enum State {
70    /// Emit the method-negotiation greeting.
71    Greet,
72    /// Read the 2-byte method selection.
73    Method,
74    /// Emit the username/password sub-negotiation.
75    Auth,
76    /// Read the 2-byte auth status.
77    AuthStatus,
78    /// Emit the `CONNECT` request.
79    Request,
80    /// Read the 4-byte reply head (`VER REP RSV ATYP`).
81    ReplyHead,
82    /// Read the 1-byte domain length of a domain-typed bound address.
83    ReplyDomainLen,
84    /// Read the remaining `n` bytes of `BND.ADDR` + `BND.PORT`.
85    ReplyTail(usize),
86    /// Handshake complete.
87    Done,
88}
89
90/// I/O-free SOCKS5 `CONNECT` handshake coroutine.
91#[derive(Debug)]
92pub struct Socks5Connect {
93    target: Socks5Address,
94    credentials: Option<Socks5Credentials>,
95    state: State,
96}
97
98impl Socks5Connect {
99    /// Creates a coroutine that tunnels to `target`, authenticating with
100    /// `credentials` if the proxy requests username/password.
101    pub fn new(target: Socks5Address, credentials: Option<Socks5Credentials>) -> Self {
102        debug!("prepare socks5 connect handshake");
103        Self {
104            target,
105            credentials,
106            state: State::Greet,
107        }
108    }
109
110    /// `VER | NMETHODS | METHODS`: offer no-auth, plus username/password
111    /// when credentials are available.
112    fn greeting(&self) -> Vec<u8> {
113        if self.credentials.is_some() {
114            vec![VERSION, 2, METHOD_NO_AUTH, METHOD_USER_PASS]
115        } else {
116            vec![VERSION, 1, METHOD_NO_AUTH]
117        }
118    }
119
120    /// `VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT`.
121    fn request(&self) -> Vec<u8> {
122        let mut out = vec![VERSION, CMD_CONNECT, RSV];
123        self.target.encode_into(&mut out);
124        out
125    }
126}
127
128impl ProxyCoroutine for Socks5Connect {
129    type Yield = ProxyYield;
130    type Return = Result<(), Socks5ConnectError>;
131
132    fn resume(&mut self, mut arg: Option<&[u8]>) -> ProxyCoroutineState<Self::Yield, Self::Return> {
133        use ProxyCoroutineState::{Complete, Yielded};
134
135        loop {
136            match self.state {
137                State::Greet => {
138                    trace!("offering method negotiation");
139                    self.state = State::Method;
140                    return Yielded(ProxyYield::WantsWrite(self.greeting()));
141                }
142
143                State::Method => {
144                    let Some(data) = arg.take() else {
145                        return Yielded(ProxyYield::WantsRead(2));
146                    };
147                    let &[version, method] = data else {
148                        return Complete(Err(Socks5ConnectError::Malformed));
149                    };
150                    if version != VERSION {
151                        return Complete(Err(Socks5ConnectError::UnexpectedVersion(version)));
152                    }
153                    match method {
154                        METHOD_NO_AUTH => {
155                            trace!("proxy selected no-auth");
156                            self.state = State::Request;
157                        }
158                        METHOD_USER_PASS => {
159                            if self.credentials.is_none() {
160                                return Complete(Err(Socks5ConnectError::AuthRequired));
161                            }
162                            trace!("proxy selected username/password auth");
163                            self.state = State::Auth;
164                        }
165                        METHOD_NO_ACCEPTABLE => {
166                            return Complete(Err(Socks5ConnectError::NoAcceptableAuthMethod));
167                        }
168                        other => {
169                            return Complete(Err(Socks5ConnectError::UnsupportedAuthMethod(other)));
170                        }
171                    }
172                }
173
174                State::Auth => {
175                    // NOTE: only reached with credentials present.
176                    let bytes = self
177                        .credentials
178                        .as_ref()
179                        .expect("credentials present in Auth state")
180                        .encode();
181                    self.state = State::AuthStatus;
182                    return Yielded(ProxyYield::WantsWrite(bytes));
183                }
184
185                State::AuthStatus => {
186                    let Some(data) = arg.take() else {
187                        return Yielded(ProxyYield::WantsRead(2));
188                    };
189                    let &[version, status] = data else {
190                        return Complete(Err(Socks5ConnectError::Malformed));
191                    };
192                    if version != AUTH_VERSION {
193                        return Complete(Err(Socks5ConnectError::UnexpectedAuthVersion(version)));
194                    }
195                    if status != 0 {
196                        return Complete(Err(Socks5ConnectError::AuthRejected));
197                    }
198                    trace!("username/password auth accepted");
199                    self.state = State::Request;
200                }
201
202                State::Request => {
203                    trace!("requesting connect to target");
204                    self.state = State::ReplyHead;
205                    return Yielded(ProxyYield::WantsWrite(self.request()));
206                }
207
208                State::ReplyHead => {
209                    let Some(data) = arg.take() else {
210                        return Yielded(ProxyYield::WantsRead(4));
211                    };
212                    let &[version, rep, _rsv, atyp] = data else {
213                        return Complete(Err(Socks5ConnectError::Malformed));
214                    };
215                    if version != VERSION {
216                        return Complete(Err(Socks5ConnectError::UnexpectedVersion(version)));
217                    }
218                    if rep != 0 {
219                        let err = match Socks5Reply::from_u8(rep) {
220                            Some(reply) => Socks5ConnectError::Reply(reply),
221                            None => Socks5ConnectError::UnknownReply(rep),
222                        };
223                        return Complete(Err(err));
224                    }
225                    // consume the bound address so the socket is left at
226                    // the tunnel start; its value is not needed
227                    match atyp {
228                        ATYP_IPV4 => self.state = State::ReplyTail(4 + 2),
229                        ATYP_IPV6 => self.state = State::ReplyTail(16 + 2),
230                        ATYP_DOMAIN => self.state = State::ReplyDomainLen,
231                        other => {
232                            return Complete(Err(Socks5ConnectError::UnknownAddressType(other)));
233                        }
234                    }
235                }
236
237                State::ReplyDomainLen => {
238                    let Some(data) = arg.take() else {
239                        return Yielded(ProxyYield::WantsRead(1));
240                    };
241                    let &[len] = data else {
242                        return Complete(Err(Socks5ConnectError::Malformed));
243                    };
244                    self.state = State::ReplyTail(len as usize + 2);
245                }
246
247                State::ReplyTail(n) => {
248                    if arg.take().is_none() {
249                        return Yielded(ProxyYield::WantsRead(n));
250                    }
251                    debug!("socks5 tunnel established");
252                    self.state = State::Done;
253                    return Complete(Ok(()));
254                }
255
256                State::Done => panic!("Socks5Connect resumed after completion"),
257            }
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    fn no_auth() -> Socks5Connect {
267        Socks5Connect::new(Socks5Address::Domain("example.com".into(), 993), None)
268    }
269
270    fn with_auth() -> Socks5Connect {
271        let creds = Socks5Credentials::new("user", "pass").unwrap();
272        Socks5Connect::new(Socks5Address::Ipv4([1, 2, 3, 4].into(), 25), Some(creds))
273    }
274
275    fn wants_write(cor: &mut Socks5Connect, arg: Option<&[u8]>) -> Vec<u8> {
276        match cor.resume(arg) {
277            ProxyCoroutineState::Yielded(ProxyYield::WantsWrite(bytes)) => bytes,
278            state => panic!("expected WantsWrite, got {state:?}"),
279        }
280    }
281
282    fn wants_read(cor: &mut Socks5Connect, arg: Option<&[u8]>) -> usize {
283        match cor.resume(arg) {
284            ProxyCoroutineState::Yielded(ProxyYield::WantsRead(n)) => n,
285            state => panic!("expected WantsRead, got {state:?}"),
286        }
287    }
288
289    fn complete_ok(cor: &mut Socks5Connect, arg: Option<&[u8]>) {
290        match cor.resume(arg) {
291            ProxyCoroutineState::Complete(Ok(())) => {}
292            state => panic!("expected Complete(Ok), got {state:?}"),
293        }
294    }
295
296    fn complete_err(cor: &mut Socks5Connect, arg: Option<&[u8]>) -> Socks5ConnectError {
297        match cor.resume(arg) {
298            ProxyCoroutineState::Complete(Err(err)) => err,
299            state => panic!("expected Complete(Err), got {state:?}"),
300        }
301    }
302
303    #[test]
304    fn no_auth_domain_reply_happy_path() {
305        let mut cor = no_auth();
306
307        // greeting: offer no-auth only
308        assert_eq!(wants_write(&mut cor, None), [0x05, 0x01, 0x00]);
309        // method selection read
310        assert_eq!(wants_read(&mut cor, None), 2);
311        // server picks no-auth -> connect request with domain address
312        let req = wants_write(&mut cor, Some(&[0x05, 0x00]));
313        let mut expected = vec![0x05, 0x01, 0x00, 0x03, 11];
314        expected.extend_from_slice(b"example.com");
315        expected.extend_from_slice(&993u16.to_be_bytes());
316        assert_eq!(req, expected);
317        // reply head read
318        assert_eq!(wants_read(&mut cor, None), 4);
319        // reply: success, domain-typed bound address
320        assert_eq!(wants_read(&mut cor, Some(&[0x05, 0x00, 0x00, 0x03])), 1);
321        // domain length = 3 -> tail of 3 + 2 port
322        assert_eq!(wants_read(&mut cor, Some(&[0x03])), 5);
323        complete_ok(&mut cor, Some(&[b'a', b'b', b'c', 0x00, 0x50]));
324    }
325
326    #[test]
327    fn user_pass_ipv4_reply_happy_path() {
328        let mut cor = with_auth();
329
330        // greeting: offer no-auth + user/pass
331        assert_eq!(wants_write(&mut cor, None), [0x05, 0x02, 0x00, 0x02]);
332        assert_eq!(wants_read(&mut cor, None), 2);
333        // server selects user/pass -> auth sub-negotiation
334        let auth = wants_write(&mut cor, Some(&[0x05, 0x02]));
335        assert_eq!(
336            auth,
337            [
338                0x01, 0x04, b'u', b's', b'e', b'r', 0x04, b'p', b'a', b's', b's'
339            ]
340        );
341        // auth status read
342        assert_eq!(wants_read(&mut cor, None), 2);
343        // auth ok -> connect request
344        let _req = wants_write(&mut cor, Some(&[0x01, 0x00]));
345        assert_eq!(wants_read(&mut cor, None), 4);
346        // reply: success, IPv4 bound address -> tail of 4 + 2
347        assert_eq!(wants_read(&mut cor, Some(&[0x05, 0x00, 0x00, 0x01])), 6);
348        complete_ok(&mut cor, Some(&[0, 0, 0, 0, 0, 0]));
349    }
350
351    #[test]
352    fn server_requires_auth_without_credentials() {
353        let mut cor = no_auth();
354        wants_write(&mut cor, None);
355        wants_read(&mut cor, None);
356        let err = complete_err(&mut cor, Some(&[0x05, 0x02]));
357        assert_eq!(err, Socks5ConnectError::AuthRequired);
358    }
359
360    #[test]
361    fn no_acceptable_method() {
362        let mut cor = no_auth();
363        wants_write(&mut cor, None);
364        wants_read(&mut cor, None);
365        let err = complete_err(&mut cor, Some(&[0x05, 0xFF]));
366        assert_eq!(err, Socks5ConnectError::NoAcceptableAuthMethod);
367    }
368
369    #[test]
370    fn rejected_auth() {
371        let mut cor = with_auth();
372        wants_write(&mut cor, None);
373        wants_read(&mut cor, None);
374        wants_write(&mut cor, Some(&[0x05, 0x02]));
375        wants_read(&mut cor, None);
376        let err = complete_err(&mut cor, Some(&[0x01, 0x01]));
377        assert_eq!(err, Socks5ConnectError::AuthRejected);
378    }
379
380    #[test]
381    fn reply_failure_maps_to_reply_error() {
382        let mut cor = no_auth();
383        wants_write(&mut cor, None);
384        wants_read(&mut cor, None);
385        wants_write(&mut cor, Some(&[0x05, 0x00]));
386        wants_read(&mut cor, None);
387        // REP = 0x04 host unreachable
388        let err = complete_err(&mut cor, Some(&[0x05, 0x04, 0x00, 0x01]));
389        assert_eq!(err, Socks5ConnectError::Reply(Socks5Reply::HostUnreachable));
390    }
391
392    #[test]
393    fn unexpected_version() {
394        let mut cor = no_auth();
395        wants_write(&mut cor, None);
396        wants_read(&mut cor, None);
397        let err = complete_err(&mut cor, Some(&[0x04, 0x00]));
398        assert_eq!(err, Socks5ConnectError::UnexpectedVersion(0x04));
399    }
400}