deltachat/net/
proxy.rs

1//! # Proxy support.
2//!
3//! Delta Chat supports HTTP(S) CONNECT, SOCKS5 and Shadowsocks protocols.
4
5use std::fmt;
6use std::pin::Pin;
7
8use anyhow::{Context as _, Result, bail, format_err};
9use base64::Engine;
10use bytes::{BufMut, BytesMut};
11use fast_socks5::AuthenticationMethod;
12use fast_socks5::Socks5Command;
13use fast_socks5::client::Socks5Stream;
14use fast_socks5::util::target_addr::ToTargetAddr;
15use percent_encoding::{NON_ALPHANUMERIC, percent_encode, utf8_percent_encode};
16use tokio::io::{AsyncReadExt, AsyncWriteExt};
17use tokio::net::TcpStream;
18use tokio_io_timeout::TimeoutStream;
19use url::Url;
20
21use crate::config::Config;
22use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
23use crate::context::Context;
24use crate::net::connect_tcp;
25use crate::net::session::SessionStream;
26use crate::net::tls::wrap_rustls;
27use crate::sql::Sql;
28
29/// Default SOCKS5 port according to [RFC 1928](https://tools.ietf.org/html/rfc1928).
30pub const DEFAULT_SOCKS_PORT: u16 = 1080;
31
32#[derive(Debug, Clone)]
33pub struct ShadowsocksConfig {
34    pub server_config: shadowsocks::config::ServerConfig,
35}
36
37impl PartialEq for ShadowsocksConfig {
38    fn eq(&self, other: &Self) -> bool {
39        self.server_config.to_url() == other.server_config.to_url()
40    }
41}
42
43impl Eq for ShadowsocksConfig {}
44
45impl ShadowsocksConfig {
46    fn to_url(&self) -> String {
47        self.server_config.to_url()
48    }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct HttpConfig {
53    /// HTTP proxy host.
54    pub host: String,
55
56    /// HTTP proxy port.
57    pub port: u16,
58
59    /// Username and password for basic authentication.
60    ///
61    /// If set, `Proxy-Authorization` header is sent.
62    pub user_password: Option<(String, String)>,
63}
64
65impl HttpConfig {
66    fn from_url(url: Url) -> Result<Self> {
67        let host = url
68            .host_str()
69            .context("HTTP proxy URL has no host")?
70            .to_string();
71        let port = url
72            .port_or_known_default()
73            .context("HTTP(S) URLs are guaranteed to return Some port")?;
74        let user_password = if let Some(password) = url.password() {
75            let username = percent_encoding::percent_decode_str(url.username())
76                .decode_utf8()
77                .context("HTTP(S) proxy username is not a valid UTF-8")?
78                .to_string();
79            let password = percent_encoding::percent_decode_str(password)
80                .decode_utf8()
81                .context("HTTP(S) proxy password is not a valid UTF-8")?
82                .to_string();
83            Some((username, password))
84        } else {
85            None
86        };
87        let http_config = HttpConfig {
88            host,
89            port,
90            user_password,
91        };
92        Ok(http_config)
93    }
94
95    fn to_url(&self, scheme: &str) -> String {
96        let host = utf8_percent_encode(&self.host, NON_ALPHANUMERIC_WITHOUT_DOT);
97        if let Some((user, password)) = &self.user_password {
98            let user = utf8_percent_encode(user, NON_ALPHANUMERIC);
99            let password = utf8_percent_encode(password, NON_ALPHANUMERIC);
100            format!("{scheme}://{user}:{password}@{host}:{}", self.port)
101        } else {
102            format!("{scheme}://{host}:{}", self.port)
103        }
104    }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Socks5Config {
109    pub host: String,
110    pub port: u16,
111    pub user_password: Option<(String, String)>,
112}
113
114impl Socks5Config {
115    async fn connect(
116        &self,
117        context: &Context,
118        target_host: &str,
119        target_port: u16,
120        load_dns_cache: bool,
121    ) -> Result<Socks5Stream<Pin<Box<TimeoutStream<TcpStream>>>>> {
122        let tcp_stream = connect_tcp(context, &self.host, self.port, load_dns_cache)
123            .await
124            .context("Failed to connect to SOCKS5 proxy")?;
125
126        let authentication_method = if let Some((username, password)) = self.user_password.as_ref()
127        {
128            Some(AuthenticationMethod::Password {
129                username: username.into(),
130                password: password.into(),
131            })
132        } else {
133            None
134        };
135        let mut socks_stream =
136            Socks5Stream::use_stream(tcp_stream, authentication_method, Default::default()).await?;
137        let target_addr = (target_host, target_port).to_target_addr()?;
138        socks_stream
139            .request(Socks5Command::TCPConnect, target_addr)
140            .await?;
141
142        Ok(socks_stream)
143    }
144
145    fn to_url(&self) -> String {
146        let host = utf8_percent_encode(&self.host, NON_ALPHANUMERIC_WITHOUT_DOT);
147        if let Some((user, password)) = &self.user_password {
148            let user = utf8_percent_encode(user, NON_ALPHANUMERIC);
149            let password = utf8_percent_encode(password, NON_ALPHANUMERIC);
150            format!("socks5://{user}:{password}@{host}:{}", self.port)
151        } else {
152            format!("socks5://{host}:{}", self.port)
153        }
154    }
155}
156
157/// Configuration for the proxy through which all traffic
158/// (except for iroh p2p connections)
159/// will be sent.
160#[derive(Debug, Clone, PartialEq, Eq)]
161#[expect(clippy::large_enum_variant)]
162pub enum ProxyConfig {
163    /// HTTP proxy.
164    Http(HttpConfig),
165
166    /// HTTPS proxy.
167    Https(HttpConfig),
168
169    /// SOCKS5 proxy.
170    Socks5(Socks5Config),
171
172    /// Shadowsocks proxy.
173    Shadowsocks(ShadowsocksConfig),
174}
175
176/// Constructs HTTP/1.1 `CONNECT` request for HTTP(S) proxy.
177#[expect(clippy::arithmetic_side_effects)]
178fn http_connect_request(host: &str, port: u16, auth: Option<(&str, &str)>) -> String {
179    // According to <https://datatracker.ietf.org/doc/html/rfc7230#section-5.4>
180    // clients MUST send `Host:` header in HTTP/1.1 requests,
181    // so repeat the host there.
182    let mut res = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n");
183    if let Some((username, password)) = auth {
184        res += "Proxy-Authorization: Basic ";
185        res += &base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"));
186        res += "\r\n";
187    }
188    res += "\r\n";
189    res
190}
191
192/// Sends HTTP/1.1 `CONNECT` request over given connection
193/// to establish an HTTP tunnel.
194///
195/// Returns the same connection back so actual data can be tunneled over it.
196async fn http_tunnel<T>(mut conn: T, host: &str, port: u16, auth: Option<(&str, &str)>) -> Result<T>
197where
198    T: AsyncReadExt + AsyncWriteExt + Unpin,
199{
200    // Send HTTP/1.1 CONNECT request.
201    let request = http_connect_request(host, port, auth);
202    conn.write_all(request.as_bytes()).await?;
203
204    let mut buffer = BytesMut::with_capacity(4096);
205
206    let res = loop {
207        if !buffer.has_remaining_mut() {
208            bail!("CONNECT response exceeded buffer size");
209        }
210        let n = conn.read_buf(&mut buffer).await?;
211        if n == 0 {
212            bail!("Unexpected end of CONNECT response");
213        }
214
215        let res = &buffer[..];
216        if res.ends_with(b"\r\n\r\n") {
217            // End of response is not reached, read more.
218            break res;
219        }
220    };
221
222    // Normally response looks like
223    // `HTTP/1.1 200 Connection established\r\n\r\n`.
224    if !res.starts_with(b"HTTP/") {
225        bail!("Unexpected HTTP CONNECT response: {res:?}");
226    }
227
228    // HTTP-version followed by space has fixed length
229    // according to RFC 7230:
230    // <https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2>
231    //
232    // Normally status line starts with `HTTP/1.1 `.
233    // We only care about 3-digit status code.
234    let status_code = res
235        .get(9..12)
236        .context("HTTP status line does not contain a status code")?;
237
238    // Interpret status code according to
239    // <https://datatracker.ietf.org/doc/html/rfc7231#section-6>.
240    if status_code == b"407" {
241        Err(format_err!("Proxy Authentication Required"))
242    } else if status_code.starts_with(b"2") {
243        // Success.
244        Ok(conn)
245    } else {
246        Err(format_err!(
247            "Failed to establish HTTP CONNECT tunnel: {res:?}"
248        ))
249    }
250}
251
252impl ProxyConfig {
253    /// Creates a new proxy configuration by parsing given proxy URL.
254    pub fn from_url(url: &str) -> Result<Self> {
255        let url = Url::parse(url).context("Cannot parse proxy URL")?;
256        match url.scheme() {
257            "http" => {
258                let http_config = HttpConfig::from_url(url)?;
259                Ok(Self::Http(http_config))
260            }
261            "https" => {
262                let https_config = HttpConfig::from_url(url)?;
263                Ok(Self::Https(https_config))
264            }
265            "ss" => {
266                let server_config = shadowsocks::config::ServerConfig::from_url(url.as_str())?;
267                let shadowsocks_config = ShadowsocksConfig { server_config };
268                Ok(Self::Shadowsocks(shadowsocks_config))
269            }
270
271            // Because of `curl` convention,
272            // `socks5` URL scheme may be expected to resolve domain names locally
273            // with `socks5h` URL scheme meaning that hostnames are passed to the proxy.
274            // Resolving hostnames locally is not supported
275            // in Delta Chat when using a proxy
276            // to prevent DNS leaks.
277            // Because of this we do not distinguish
278            // between `socks5` and `socks5h`.
279            "socks5" => {
280                let host = url
281                    .host_str()
282                    .context("socks5 URL has no host")?
283                    .to_string();
284                let port = url.port().unwrap_or(DEFAULT_SOCKS_PORT);
285                let user_password = if let Some(password) = url.password() {
286                    let username = percent_encoding::percent_decode_str(url.username())
287                        .decode_utf8()
288                        .context("SOCKS5 username is not a valid UTF-8")?
289                        .to_string();
290                    let password = percent_encoding::percent_decode_str(password)
291                        .decode_utf8()
292                        .context("SOCKS5 password is not a valid UTF-8")?
293                        .to_string();
294                    Some((username, password))
295                } else {
296                    None
297                };
298                let socks5_config = Socks5Config {
299                    host,
300                    port,
301                    user_password,
302                };
303                Ok(Self::Socks5(socks5_config))
304            }
305            scheme => Err(format_err!("Unknown URL scheme {scheme:?}")),
306        }
307    }
308
309    /// Serializes proxy config into an URL.
310    ///
311    /// This function can be used to normalize proxy URL
312    /// by parsing it and serializing back.
313    pub fn to_url(&self) -> String {
314        match self {
315            Self::Http(http_config) => http_config.to_url("http"),
316            Self::Https(http_config) => http_config.to_url("https"),
317            Self::Socks5(socks5_config) => socks5_config.to_url(),
318            Self::Shadowsocks(shadowsocks_config) => shadowsocks_config.to_url(),
319        }
320    }
321
322    /// Migrates legacy `socks5_host`, `socks5_port`, `socks5_user` and `socks5_password`
323    /// config into `proxy_url` if `proxy_url` is unset or empty.
324    ///
325    /// Unsets `socks5_host`, `socks5_port`, `socks5_user` and `socks5_password` in any case.
326    #[expect(clippy::arithmetic_side_effects)]
327    async fn migrate_socks_config(sql: &Sql) -> Result<()> {
328        if sql.get_raw_config("proxy_url").await?.is_none() {
329            // Load legacy SOCKS5 settings.
330            if let Some(host) = sql
331                .get_raw_config("socks5_host")
332                .await?
333                .filter(|s| !s.is_empty())
334            {
335                let port: u16 = sql
336                    .get_raw_config_int("socks5_port")
337                    .await?
338                    .unwrap_or(DEFAULT_SOCKS_PORT.into()) as u16;
339                let user = sql.get_raw_config("socks5_user").await?.unwrap_or_default();
340                let pass = sql
341                    .get_raw_config("socks5_password")
342                    .await?
343                    .unwrap_or_default();
344
345                let mut proxy_url = "socks5://".to_string();
346                if !pass.is_empty() {
347                    proxy_url += &percent_encode(user.as_bytes(), NON_ALPHANUMERIC).to_string();
348                    proxy_url += ":";
349                    proxy_url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
350                    proxy_url += "@";
351                };
352                proxy_url += &host;
353                proxy_url += ":";
354                proxy_url += &port.to_string();
355
356                sql.set_raw_config("proxy_url", Some(&proxy_url)).await?;
357            } else {
358                sql.set_raw_config("proxy_url", Some("")).await?;
359            }
360
361            let socks5_enabled = sql.get_raw_config("socks5_enabled").await?;
362            sql.set_raw_config("proxy_enabled", socks5_enabled.as_deref())
363                .await?;
364        }
365
366        sql.set_raw_config("socks5_enabled", None).await?;
367        sql.set_raw_config("socks5_host", None).await?;
368        sql.set_raw_config("socks5_port", None).await?;
369        sql.set_raw_config("socks5_user", None).await?;
370        sql.set_raw_config("socks5_password", None).await?;
371        Ok(())
372    }
373
374    /// Reads proxy configuration from the database.
375    pub async fn load(context: &Context) -> Result<Option<Self>> {
376        Self::migrate_socks_config(&context.sql)
377            .await
378            .context("Failed to migrate legacy SOCKS config")?;
379
380        let enabled = context.get_config_bool(Config::ProxyEnabled).await?;
381        if !enabled {
382            return Ok(None);
383        }
384
385        let proxy_url = context
386            .get_config(Config::ProxyUrl)
387            .await?
388            .unwrap_or_default();
389        let proxy_url = proxy_url
390            .split_once('\n')
391            .map_or(proxy_url.clone(), |(first_url, _rest)| {
392                first_url.to_string()
393            });
394        let proxy_config = Self::from_url(&proxy_url).context("Failed to parse proxy URL")?;
395        Ok(Some(proxy_config))
396    }
397
398    /// If `load_dns_cache` is true, loads cached DNS resolution results.
399    /// Use this only if the connection is going to be protected with TLS checks.
400    pub(crate) async fn connect(
401        &self,
402        context: &Context,
403        target_host: &str,
404        target_port: u16,
405        load_dns_cache: bool,
406    ) -> Result<Box<dyn SessionStream>> {
407        match self {
408            ProxyConfig::Http(http_config) => {
409                let load_cache = false;
410                let tcp_stream = crate::net::connect_tcp(
411                    context,
412                    &http_config.host,
413                    http_config.port,
414                    load_cache,
415                )
416                .await?;
417                let auth = if let Some((username, password)) = &http_config.user_password {
418                    Some((username.as_str(), password.as_str()))
419                } else {
420                    None
421                };
422                let tunnel_stream = http_tunnel(tcp_stream, target_host, target_port, auth).await?;
423                Ok(Box::new(tunnel_stream))
424            }
425            ProxyConfig::Https(https_config) => {
426                let load_cache = true;
427                let tcp_stream = crate::net::connect_tcp(
428                    context,
429                    &https_config.host,
430                    https_config.port,
431                    load_cache,
432                )
433                .await?;
434                let use_sni = true;
435                let tls_stream = wrap_rustls(
436                    &https_config.host,
437                    https_config.port,
438                    use_sni,
439                    "",
440                    tcp_stream,
441                    &context.tls_session_store,
442                )
443                .await?;
444                let auth = if let Some((username, password)) = &https_config.user_password {
445                    Some((username.as_str(), password.as_str()))
446                } else {
447                    None
448                };
449                let tunnel_stream = http_tunnel(tls_stream, target_host, target_port, auth).await?;
450                Ok(Box::new(tunnel_stream))
451            }
452            ProxyConfig::Socks5(socks5_config) => {
453                let socks5_stream = socks5_config
454                    .connect(context, target_host, target_port, load_dns_cache)
455                    .await?;
456                Ok(Box::new(socks5_stream))
457            }
458            ProxyConfig::Shadowsocks(ShadowsocksConfig { server_config }) => {
459                let shadowsocks_context = shadowsocks::context::Context::new_shared(
460                    shadowsocks::config::ServerType::Local,
461                );
462
463                let tcp_stream = {
464                    let server_addr = server_config.addr();
465                    let host = server_addr.host();
466                    let port = server_addr.port();
467                    connect_tcp(context, &host, port, load_dns_cache)
468                        .await
469                        .context("Failed to connect to Shadowsocks proxy")?
470                };
471
472                let shadowsocks_stream = shadowsocks::ProxyClientStream::from_stream(
473                    shadowsocks_context,
474                    tcp_stream,
475                    server_config,
476                    (target_host.to_string(), target_port),
477                );
478
479                Ok(Box::new(shadowsocks_stream))
480            }
481        }
482    }
483}
484
485impl fmt::Display for Socks5Config {
486    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487        write!(
488            f,
489            "host:{},port:{},user_password:{}",
490            self.host,
491            self.port,
492            if let Some(user_password) = self.user_password.clone() {
493                format!("user: {}, password: ***", user_password.0)
494            } else {
495                "user: None".to_string()
496            }
497        )
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::config::Config;
505    use crate::test_utils::TestContext;
506
507    #[test]
508    fn test_socks5_url() {
509        let proxy_config = ProxyConfig::from_url("socks5://127.0.0.1:9050").unwrap();
510        assert_eq!(
511            proxy_config,
512            ProxyConfig::Socks5(Socks5Config {
513                host: "127.0.0.1".to_string(),
514                port: 9050,
515                user_password: None
516            })
517        );
518
519        let proxy_config = ProxyConfig::from_url("socks5://foo:bar@127.0.0.1:9150").unwrap();
520        assert_eq!(
521            proxy_config,
522            ProxyConfig::Socks5(Socks5Config {
523                host: "127.0.0.1".to_string(),
524                port: 9150,
525                user_password: Some(("foo".to_string(), "bar".to_string()))
526            })
527        );
528
529        let proxy_config = ProxyConfig::from_url("socks5://%66oo:b%61r@127.0.0.1:9150").unwrap();
530        assert_eq!(
531            proxy_config,
532            ProxyConfig::Socks5(Socks5Config {
533                host: "127.0.0.1".to_string(),
534                port: 9150,
535                user_password: Some(("foo".to_string(), "bar".to_string()))
536            })
537        );
538
539        let proxy_config = ProxyConfig::from_url("socks5://127.0.0.1:80").unwrap();
540        assert_eq!(
541            proxy_config,
542            ProxyConfig::Socks5(Socks5Config {
543                host: "127.0.0.1".to_string(),
544                port: 80,
545                user_password: None
546            })
547        );
548
549        let proxy_config = ProxyConfig::from_url("socks5://127.0.0.1").unwrap();
550        assert_eq!(
551            proxy_config,
552            ProxyConfig::Socks5(Socks5Config {
553                host: "127.0.0.1".to_string(),
554                port: 1080,
555                user_password: None
556            })
557        );
558
559        let proxy_config = ProxyConfig::from_url("socks5://127.0.0.1:1080").unwrap();
560        assert_eq!(
561            proxy_config,
562            ProxyConfig::Socks5(Socks5Config {
563                host: "127.0.0.1".to_string(),
564                port: 1080,
565                user_password: None
566            })
567        );
568    }
569
570    #[test]
571    fn test_http_url() {
572        let proxy_config = ProxyConfig::from_url("http://127.0.0.1").unwrap();
573        assert_eq!(
574            proxy_config,
575            ProxyConfig::Http(HttpConfig {
576                host: "127.0.0.1".to_string(),
577                port: 80,
578                user_password: None
579            })
580        );
581
582        let proxy_config = ProxyConfig::from_url("http://127.0.0.1:80").unwrap();
583        assert_eq!(
584            proxy_config,
585            ProxyConfig::Http(HttpConfig {
586                host: "127.0.0.1".to_string(),
587                port: 80,
588                user_password: None
589            })
590        );
591
592        let proxy_config = ProxyConfig::from_url("http://127.0.0.1:443").unwrap();
593        assert_eq!(
594            proxy_config,
595            ProxyConfig::Http(HttpConfig {
596                host: "127.0.0.1".to_string(),
597                port: 443,
598                user_password: None
599            })
600        );
601    }
602
603    #[test]
604    fn test_https_url() {
605        let proxy_config = ProxyConfig::from_url("https://127.0.0.1").unwrap();
606        assert_eq!(
607            proxy_config,
608            ProxyConfig::Https(HttpConfig {
609                host: "127.0.0.1".to_string(),
610                port: 443,
611                user_password: None
612            })
613        );
614
615        let proxy_config = ProxyConfig::from_url("https://127.0.0.1:80").unwrap();
616        assert_eq!(
617            proxy_config,
618            ProxyConfig::Https(HttpConfig {
619                host: "127.0.0.1".to_string(),
620                port: 80,
621                user_password: None
622            })
623        );
624
625        let proxy_config = ProxyConfig::from_url("https://127.0.0.1:443").unwrap();
626        assert_eq!(
627            proxy_config,
628            ProxyConfig::Https(HttpConfig {
629                host: "127.0.0.1".to_string(),
630                port: 443,
631                user_password: None
632            })
633        );
634    }
635
636    #[test]
637    fn test_http_connect_request() {
638        assert_eq!(
639            http_connect_request("example.org", 143, Some(("aladdin", "opensesame"))),
640            "CONNECT example.org:143 HTTP/1.1\r\nHost: example.org:143\r\nProxy-Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l\r\n\r\n"
641        );
642        assert_eq!(
643            http_connect_request("example.net", 587, None),
644            "CONNECT example.net:587 HTTP/1.1\r\nHost: example.net:587\r\n\r\n"
645        );
646    }
647
648    #[test]
649    fn test_shadowsocks_url() {
650        // Example URL from <https://shadowsocks.org/doc/sip002.html>.
651        let proxy_config =
652            ProxyConfig::from_url("ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1")
653                .unwrap();
654        assert!(matches!(proxy_config, ProxyConfig::Shadowsocks(_)));
655    }
656
657    #[test]
658    fn test_invalid_proxy_url() {
659        assert!(ProxyConfig::from_url("foobar://127.0.0.1:9050").is_err());
660        assert!(ProxyConfig::from_url("abc").is_err());
661
662        // This caused panic before shadowsocks 1.22.0.
663        assert!(ProxyConfig::from_url("ss://foo:bar@127.0.0.1:9999").is_err());
664    }
665
666    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
667    async fn test_socks5_migration() -> Result<()> {
668        let t = TestContext::new().await;
669
670        // Test that config is migrated on attempt to load even if disabled.
671        t.set_config(Config::Socks5Host, Some("127.0.0.1")).await?;
672        t.set_config(Config::Socks5Port, Some("9050")).await?;
673
674        let proxy_config = ProxyConfig::load(&t).await?;
675        // Even though proxy is not enabled, config should be migrated.
676        assert_eq!(proxy_config, None);
677
678        assert_eq!(
679            t.get_config(Config::ProxyUrl).await?.unwrap(),
680            "socks5://127.0.0.1:9050"
681        );
682        Ok(())
683    }
684
685    // Test SOCKS5 setting migration if proxy was never configured.
686    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
687    async fn test_socks5_migration_unconfigured() -> Result<()> {
688        let t = TestContext::new().await;
689
690        // Try to load config to trigger migration.
691        assert_eq!(ProxyConfig::load(&t).await?, None);
692
693        assert_eq!(t.get_config(Config::ProxyEnabled).await?, None);
694        assert_eq!(
695            t.get_config(Config::ProxyUrl).await?.unwrap(),
696            String::new()
697        );
698        Ok(())
699    }
700
701    // Test SOCKS5 setting migration if SOCKS5 host is empty.
702    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
703    async fn test_socks5_migration_empty() -> Result<()> {
704        let t = TestContext::new().await;
705
706        t.set_config(Config::Socks5Host, Some("")).await?;
707
708        // Try to load config to trigger migration.
709        assert_eq!(ProxyConfig::load(&t).await?, None);
710
711        assert_eq!(t.get_config(Config::ProxyEnabled).await?, None);
712        assert_eq!(
713            t.get_config(Config::ProxyUrl).await?.unwrap(),
714            String::new()
715        );
716        Ok(())
717    }
718}