1
//! Configuration for a channel manager (and, therefore, channels)
2
//!
3
//! # Semver note
4
//!
5
//! Most types in this module are re-exported by `arti-client`.
6

            
7
use derive_deftly::Deftly;
8
use percent_encoding::percent_decode_str;
9
use serde::Deserialize;
10
use std::net::{IpAddr, SocketAddr};
11
use tor_config::PaddingLevel;
12
use tor_config::derive::prelude::*;
13
use tor_socksproto::SocksAuth;
14
use tor_socksproto::SocksVersion;
15
use url::{Host, Url};
16

            
17
/// Error parsing a proxy URI string
18
#[derive(Debug, Clone, thiserror::Error)]
19
#[non_exhaustive]
20
pub enum ProxyProtocolParseError {
21
    /// Proxy URI has an unsupported or missing scheme.
22
    #[error("unsupported or missing proxy scheme: {0}")]
23
    UnsupportedScheme(String),
24
    /// Proxy URI includes a password for a scheme that does not support it.
25
    #[error("password not supported for proxy scheme: {0}")]
26
    UnsupportedPassword(String),
27
    /// Proxy URI had an invalid or unparsable address.
28
    #[error("invalid proxy address: {0}")]
29
    InvalidAddress(String),
30
    /// Proxy URI is missing a port or has an invalid port.
31
    #[error("missing or invalid port")]
32
    InvalidPort,
33
    /// Proxy URI does not match the expected format.
34
    #[error("invalid proxy URI format: {0}")]
35
    InvalidFormat(String),
36
}
37

            
38
/// Information about what proxy protocol to use, and how to use it.
39
///
40
/// This type can be parsed from a URI string using the same format as curl's
41
/// proxy URL syntax (see <https://curl.se/docs/url-syntax.html>).
42
///
43
/// Supported formats:
44
///
45
/// - `socks4://ip:port` - SOCKS4 proxy
46
/// - `socks4://user@ip:port` - SOCKS4 proxy with user ID
47
/// - `socks4a://ip:port` - SOCKS4a proxy (treated same as socks4)
48
/// - `socks5://ip:port` - SOCKS5 proxy without auth
49
/// - `socks5://user:pass@ip:port` - SOCKS5 proxy with username/password auth
50
/// - `socks5://user@ip:port` - SOCKS5 proxy with username only (empty password)
51
/// - `socks5h://ip:port` - SOCKS5 with remote hostname resolution (treated same as socks5)
52
///
53
/// - Hostnames for the proxy server itself are not supported.
54
/// - Credentials must be embedded in the URI; curl's `-U user:pass` style is not supported.
55
/// - For `socks4://`, passwords are not supported and will return an error.
56
/// - Special characters in credentials are percent-encoded using the `url` crate's
57
///   userinfo encoding.
58
#[derive(
59
    Debug, Clone, Eq, PartialEq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay,
60
)]
61
#[non_exhaustive]
62
pub enum ProxyProtocol {
63
    /// Connect via SOCKS 4, SOCKS 4a, or SOCKS 5.
64
    Socks {
65
        /// The SOCKS version to use
66
        version: SocksVersion,
67
        /// The authentication method to use
68
        auth: SocksAuth,
69
        /// The proxy server address
70
        addr: SocketAddr,
71
    },
72
}
73

            
74
impl std::str::FromStr for ProxyProtocol {
75
    type Err = ProxyProtocolParseError;
76

            
77
54
    fn from_str(s: &str) -> Result<Self, Self::Err> {
78
55
        let url = Url::parse(s).map_err(|e| match e {
79
            url::ParseError::InvalidPort => ProxyProtocolParseError::InvalidPort,
80
            url::ParseError::InvalidIpv4Address
81
            | url::ParseError::InvalidIpv6Address
82
            | url::ParseError::EmptyHost
83
            | url::ParseError::InvalidDomainCharacter
84
            | url::ParseError::IdnaError => ProxyProtocolParseError::InvalidAddress(s.to_string()),
85
2
            _ => ProxyProtocolParseError::InvalidFormat(s.to_string()),
86
3
        })?;
87

            
88
52
        let scheme_lower = url.scheme().to_ascii_lowercase();
89
52
        let version = match scheme_lower.as_str() {
90
52
            "socks4" | "socks4a" => SocksVersion::V4,
91
36
            "socks5" | "socks5h" => SocksVersion::V5,
92
            _ => {
93
2
                return Err(ProxyProtocolParseError::UnsupportedScheme(
94
2
                    url.scheme().to_string(),
95
2
                ));
96
            }
97
        };
98

            
99
50
        if url.query().is_some() || url.fragment().is_some() {
100
            return Err(ProxyProtocolParseError::InvalidFormat(s.to_string()));
101
50
        }
102

            
103
50
        let path = url.path();
104
50
        if !path.is_empty() && path != "/" {
105
            return Err(ProxyProtocolParseError::InvalidFormat(s.to_string()));
106
50
        }
107

            
108
50
        let port = url.port().ok_or(ProxyProtocolParseError::InvalidPort)?;
109
48
        let host = url
110
48
            .host()
111
48
            .ok_or_else(|| ProxyProtocolParseError::InvalidAddress(s.to_string()))?;
112
48
        let ip = match host {
113
            Host::Ipv4(ip) => IpAddr::V4(ip),
114
6
            Host::Ipv6(ip) => IpAddr::V6(ip),
115
42
            Host::Domain(domain) => domain
116
42
                .parse::<IpAddr>()
117
43
                .map_err(|_| ProxyProtocolParseError::InvalidAddress(domain.to_string()))?,
118
        };
119
46
        let addr = SocketAddr::new(ip, port);
120

            
121
        // Check for authentication credentials (user:pass@host:port or user@host:port).
122
        // The URL parser returns percent-encoded userinfo, so decode it here.
123
46
        let user = url.username();
124
46
        let pass = url.password();
125
46
        if version == SocksVersion::V4 && pass.is_some() {
126
2
            return Err(ProxyProtocolParseError::UnsupportedPassword(
127
2
                url.scheme().to_string(),
128
2
            ));
129
44
        }
130
44
        let user_decoded = percent_decode_str(user).decode_utf8_lossy();
131
48
        let pass_decoded = pass.map(|p| percent_decode_str(p).decode_utf8_lossy());
132
44
        let auth = if user.is_empty() && pass.is_none() {
133
28
            SocksAuth::NoAuth
134
        } else {
135
16
            match version {
136
6
                SocksVersion::V4 => SocksAuth::Socks4(user_decoded.as_bytes().to_vec()),
137
                SocksVersion::V5 => {
138
10
                    let pass = pass_decoded.as_deref().unwrap_or("");
139
10
                    SocksAuth::Username(user_decoded.as_bytes().to_vec(), pass.as_bytes().to_vec())
140
                }
141
                _ => SocksAuth::NoAuth,
142
            }
143
        };
144

            
145
44
        Ok(ProxyProtocol::Socks {
146
44
            version,
147
44
            auth,
148
44
            addr,
149
44
        })
150
54
    }
151
}
152

            
153
impl std::fmt::Display for ProxyProtocol {
154
12
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155
12
        match self {
156
            ProxyProtocol::Socks {
157
12
                version,
158
12
                auth,
159
12
                addr,
160
            } => {
161
                // Use SocksVersion's Display impl for the scheme (e.g., "socks5")
162
12
                match auth {
163
6
                    SocksAuth::NoAuth => write!(f, "{}://{}", version, addr),
164
2
                    SocksAuth::Socks4(user_id) => {
165
                        // SOCKS4: user@host format (no password in SOCKS4)
166
2
                        let user = String::from_utf8_lossy(user_id);
167
2
                        match encode_userinfo(*version, *addr, &user, None) {
168
2
                            Some((user_encoded, _)) => {
169
2
                                write!(f, "{}://{}@{}", version, user_encoded, addr)
170
                            }
171
                            None => write!(f, "{}://{}@{}", version, user, addr),
172
                        }
173
                    }
174
4
                    SocksAuth::Username(user, pass) => {
175
                        // SOCKS5: user:pass@host format
176
4
                        let user = String::from_utf8_lossy(user);
177
4
                        let pass = String::from_utf8_lossy(pass);
178
4
                        match encode_userinfo(*version, *addr, &user, Some(&pass)) {
179
4
                            Some((user_encoded, pass_encoded)) => {
180
4
                                let pass_encoded = pass_encoded.unwrap_or_default();
181
4
                                write!(
182
4
                                    f,
183
4
                                    "{}://{}:{}@{}",
184
                                    version, user_encoded, pass_encoded, addr
185
                                )
186
                            }
187
                            None => write!(f, "{}://{}:{}@{}", version, user, pass, addr),
188
                        }
189
                    }
190
                    // Handle potential future auth types
191
                    _ => write!(f, "{}://{}", version, addr),
192
                }
193
            }
194
        }
195
12
    }
196
}
197

            
198
/// URL-encodes username and optional password for SOCKS proxy userinfo display.
199
///
200
/// Uses `Url` parsing to produce percent-encoded forms suitable for
201
/// `socks://user:pass@host:port` style output.
202
6
fn encode_userinfo(
203
6
    version: SocksVersion,
204
6
    addr: SocketAddr,
205
6
    username: &str,
206
6
    password: Option<&str>,
207
6
) -> Option<(String, Option<String>)> {
208
6
    let url_str = format!("{}://{}", version, addr);
209
6
    let mut url = match Url::parse(&url_str) {
210
6
        Ok(url) => url,
211
        Err(_) => return None,
212
    };
213
6
    if url.set_username(username).is_err() {
214
        return None;
215
6
    }
216
6
    if url.set_password(password).is_err() {
217
        return None;
218
6
    }
219
6
    let user_encoded = url.username().to_string();
220
6
    let pass_encoded = url.password().map(str::to_string);
221
6
    Some((user_encoded, pass_encoded))
222
6
}
223

            
224
impl ProxyProtocol {
225
    /// Create a new SOCKS proxy configuration with no authentication
226
    pub fn socks_no_auth(version: SocksVersion, addr: SocketAddr) -> Self {
227
        ProxyProtocol::Socks {
228
            version,
229
            auth: SocksAuth::NoAuth,
230
            addr,
231
        }
232
    }
233
}
234

            
235
/// Deserialize an outbound proxy, treating empty strings as unset.
236
#[allow(clippy::option_option)]
237
4
fn deserialize_outbound_proxy<'de, D>(
238
4
    deserializer: D,
239
4
) -> Result<Option<Option<ProxyProtocol>>, D::Error>
240
4
where
241
4
    D: serde::Deserializer<'de>,
242
{
243
4
    let value = Option::<String>::deserialize(deserializer)?;
244
4
    match value {
245
        None => Ok(None),
246
4
        Some(s) => {
247
4
            if s.trim().is_empty() {
248
4
                return Ok(Some(None));
249
            }
250
            let parsed = s.parse().map_err(serde::de::Error::custom)?;
251
            Ok(Some(Some(parsed)))
252
        }
253
    }
254
4
}
255

            
256
/// Channel configuration
257
///
258
/// This type is immutable once constructed.  To build one, use
259
/// [`ChannelConfigBuilder`], or deserialize it from a string.
260
#[derive(Debug, Clone, Deftly, Eq, PartialEq)]
261
#[derive_deftly(TorConfig)]
262
pub struct ChannelConfig {
263
    /// Control of channel padding
264
    #[deftly(tor_config(default))]
265
    pub(crate) padding: PaddingLevel,
266

            
267
    /// Outbound proxy to use for all direct connections
268
    #[deftly(tor_config(
269
        default,
270
        serde = r#" deserialize_with = "deserialize_outbound_proxy" "#
271
    ))]
272
    pub(crate) outbound_proxy: Option<ProxyProtocol>,
273
}
274

            
275
#[cfg(feature = "testing")]
276
impl ChannelConfig {
277
    /// The padding level (accessor for testing)
278
    pub fn padding(&self) -> PaddingLevel {
279
        self.padding
280
    }
281
}
282

            
283
#[cfg(test)]
284
mod test {
285
    // @@ begin test lint list maintained by maint/add_warning @@
286
    #![allow(clippy::bool_assert_comparison)]
287
    #![allow(clippy::clone_on_copy)]
288
    #![allow(clippy::dbg_macro)]
289
    #![allow(clippy::mixed_attributes_style)]
290
    #![allow(clippy::print_stderr)]
291
    #![allow(clippy::print_stdout)]
292
    #![allow(clippy::single_char_pattern)]
293
    #![allow(clippy::unwrap_used)]
294
    #![allow(clippy::unchecked_time_subtraction)]
295
    #![allow(clippy::useless_vec)]
296
    #![allow(clippy::needless_pass_by_value)]
297
    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
298
    use super::*;
299

            
300
    #[test]
301
    fn channel_config() {
302
        let config = ChannelConfig::default();
303

            
304
        assert_eq!(PaddingLevel::Normal, config.padding);
305
    }
306

            
307
    #[test]
308
    fn proxy_protocol_parse_socks5_basic() {
309
        let p: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
310
        match p {
311
            ProxyProtocol::Socks {
312
                version,
313
                auth,
314
                addr,
315
            } => {
316
                assert_eq!(version, SocksVersion::V5);
317
                assert_eq!(auth, SocksAuth::NoAuth);
318
                assert_eq!(addr, "127.0.0.1:1080".parse().unwrap());
319
            }
320
        }
321
    }
322

            
323
    #[test]
324
    fn proxy_protocol_parse_socks5_with_auth() {
325
        let p: ProxyProtocol = "socks5://myuser:mypass@192.168.1.1:9050".parse().unwrap();
326
        match p {
327
            ProxyProtocol::Socks {
328
                version,
329
                auth,
330
                addr,
331
            } => {
332
                assert_eq!(version, SocksVersion::V5);
333
                assert_eq!(
334
                    auth,
335
                    SocksAuth::Username(b"myuser".to_vec(), b"mypass".to_vec())
336
                );
337
                assert_eq!(addr, "192.168.1.1:9050".parse().unwrap());
338
            }
339
        }
340
    }
341

            
342
    #[test]
343
    fn proxy_protocol_parse_socks4() {
344
        let p: ProxyProtocol = "socks4://10.0.0.1:1080".parse().unwrap();
345
        match p {
346
            ProxyProtocol::Socks {
347
                version,
348
                auth,
349
                addr,
350
            } => {
351
                assert_eq!(version, SocksVersion::V4);
352
                assert_eq!(auth, SocksAuth::NoAuth);
353
                assert_eq!(addr, "10.0.0.1:1080".parse().unwrap());
354
            }
355
        }
356
    }
357

            
358
    #[test]
359
    fn proxy_protocol_parse_socks4a() {
360
        let p: ProxyProtocol = "socks4a://10.0.0.1:1080".parse().unwrap();
361
        match p {
362
            ProxyProtocol::Socks { version, auth, .. } => {
363
                assert_eq!(version, SocksVersion::V4);
364
                assert_eq!(auth, SocksAuth::NoAuth);
365
            }
366
        }
367
    }
368

            
369
    #[test]
370
    fn proxy_protocol_parse_ipv6() {
371
        let p: ProxyProtocol = "socks5://[::1]:1080".parse().unwrap();
372
        match p {
373
            ProxyProtocol::Socks { addr, .. } => {
374
                assert_eq!(addr, "[::1]:1080".parse().unwrap());
375
            }
376
        }
377
    }
378

            
379
    #[test]
380
    fn proxy_protocol_display_roundtrip() {
381
        for uri in [
382
            "socks5://127.0.0.1:1080",
383
            "socks4://10.0.0.1:9050",
384
            "socks5://user:pass@192.168.1.1:1080",
385
            "socks5://[::1]:1080",
386
        ] {
387
            let p: ProxyProtocol = uri.parse().unwrap();
388
            let s = p.to_string();
389
            let p2: ProxyProtocol = s.parse().unwrap();
390
            assert_eq!(p, p2, "Round-trip failed for: {}", uri);
391
        }
392
    }
393

            
394
    #[test]
395
    fn proxy_protocol_parse_errors() {
396
        // Missing scheme
397
        assert!("127.0.0.1:1080".parse::<ProxyProtocol>().is_err());
398

            
399
        // Invalid scheme
400
        assert!("invalid://127.0.0.1:1080".parse::<ProxyProtocol>().is_err());
401

            
402
        // Missing port
403
        assert!("socks5://127.0.0.1".parse::<ProxyProtocol>().is_err());
404

            
405
        // Invalid address
406
        assert!("socks5://not-an-ip:1080".parse::<ProxyProtocol>().is_err());
407

            
408
        // SOCKS4 does not support passwords
409
        assert!(
410
            "socks4://user:pass@10.0.0.1:1080"
411
                .parse::<ProxyProtocol>()
412
                .is_err()
413
        );
414
    }
415

            
416
    #[test]
417
    fn proxy_protocol_case_insensitive() {
418
        // Scheme parsing should be case-insensitive
419
        let p1: ProxyProtocol = "SOCKS5://127.0.0.1:1080".parse().unwrap();
420
        let p2: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
421
        let p3: ProxyProtocol = "SoCkS5://127.0.0.1:1080".parse().unwrap();
422

            
423
        assert_eq!(p1, p2);
424
        assert_eq!(p2, p3);
425
    }
426

            
427
    #[test]
428
    fn proxy_protocol_parse_socks5h() {
429
        // socks5h:// should be treated as socks5
430
        let p: ProxyProtocol = "socks5h://127.0.0.1:1080".parse().unwrap();
431
        match p {
432
            ProxyProtocol::Socks { version, auth, .. } => {
433
                assert_eq!(version, SocksVersion::V5);
434
                assert_eq!(auth, SocksAuth::NoAuth);
435
            }
436
        }
437
    }
438

            
439
    #[test]
440
    fn proxy_protocol_parse_socks4_user_only() {
441
        // SOCKS4 with user only (no password)
442
        let p: ProxyProtocol = "socks4://myuser@10.0.0.1:1080".parse().unwrap();
443
        match p {
444
            ProxyProtocol::Socks {
445
                version,
446
                auth,
447
                addr,
448
            } => {
449
                assert_eq!(version, SocksVersion::V4);
450
                assert_eq!(auth, SocksAuth::Socks4(b"myuser".to_vec()));
451
                assert_eq!(addr, "10.0.0.1:1080".parse().unwrap());
452
            }
453
        }
454
    }
455

            
456
    #[test]
457
    fn proxy_protocol_parse_socks5_user_only() {
458
        // SOCKS5 with user only (empty password)
459
        let p: ProxyProtocol = "socks5://myuser@192.168.1.1:9050".parse().unwrap();
460
        match p {
461
            ProxyProtocol::Socks {
462
                version,
463
                auth,
464
                addr,
465
            } => {
466
                assert_eq!(version, SocksVersion::V5);
467
                assert_eq!(auth, SocksAuth::Username(b"myuser".to_vec(), b"".to_vec()));
468
                assert_eq!(addr, "192.168.1.1:9050".parse().unwrap());
469
            }
470
        }
471
    }
472

            
473
    #[test]
474
    fn proxy_protocol_percent_encoding_roundtrip() {
475
        // Test percent-encoding round-trip for special characters
476
        // User with @ and : characters that need encoding
477
        let p = ProxyProtocol::Socks {
478
            version: SocksVersion::V5,
479
            auth: SocksAuth::Username(b"user@domain".to_vec(), b"pass:word".to_vec()),
480
            addr: "127.0.0.1:1080".parse().unwrap(),
481
        };
482
        let s = p.to_string();
483
        // Should contain percent-encoded characters
484
        assert!(s.contains("%40"), "@ should be encoded as %40");
485
        assert!(
486
            s.contains("%3A") || s.contains("%3a"),
487
            ": in password should be encoded"
488
        );
489

            
490
        // Parse it back
491
        let p2: ProxyProtocol = s.parse().unwrap();
492
        assert_eq!(p, p2, "Round-trip failed for percent-encoded URI");
493
    }
494

            
495
    #[test]
496
    fn proxy_protocol_socks4_user_roundtrip() {
497
        // SOCKS4 user-only format should round-trip
498
        let uri = "socks4://testuser@10.0.0.1:1080";
499
        let p: ProxyProtocol = uri.parse().unwrap();
500
        let s = p.to_string();
501
        let p2: ProxyProtocol = s.parse().unwrap();
502
        assert_eq!(p, p2, "SOCKS4 user-only round-trip failed");
503
    }
504
}