Skip to content

Commit 2f0b7d1

Browse files
authored
feat(net): SOCKS5 and PAC proxy support for all outbound connections (#145)
Adds proxy support to every outbound HTTP client, completing the corporate-network story started by the custom-CA work (#134) through the same `driven-tls` seam. ## Modes (KV-stored `global` settings, serde-default - NO migration) - **system** (default): unchanged - reqwest env-proxy pickup (DESIGN 5.8.7). - **none**: explicit `.no_proxy()` everywhere - bypasses env proxies too. - **manual**: one proxy URL (`http://`, `https://`, `socks5://`, `socks5h://` - socks5h resolves DNS proxy-side); reqwest `socks` feature enabled on both workspace reqwest 0.12 and the updater's reqwest 0.13. - **pac**: PAC file URL or local path, compiled with an embedded pure-Rust JS engine (boa 0.20), evaluated per-URL via `Proxy::custom` with a 256-entry per-HOST LRU. Standard helpers implemented (isPlainHostName, dnsDomainIs, localHostOrDomainIs, dnsDomainLevels, shExpMatch, dnsResolve, isResolvable, myIpAddress, isInNet); date/time predicates (weekdayRange/dateRange/timeRange) are defined-but-false stubs. ## Semantics + documented caveats - CONFIG failures fail closed at settings-save AND client build (bad URL, unfetchable/uncompilable PAC): clients are never built silently unproxied. A corrupt stored mode string degrades to `system` (the historical default). - RUNTIME PAC eval errors (e.g. a DNS failure mid-eval) log a warning and go direct for that request - browser-standard PAC failure behavior. - The PAC realm is pure ECMAScript (no fetch/fs/process); DNS via the helpers only. There is deliberately NO JS execution watchdog yet (admin-configured input; LRU bounds eval count) - future hardening if untrusted PAC ever becomes possible. DNS helpers use OS-resolver timeouts. - Cache keys on host only: a PAC branching on scheme/path gets the per-host decision (endorsed pragmatic subset). - Updater: PAC evaluates per-URL there too via a version-neutral engine handle; CA certs + proxy fold into ONE configure_client closure (the plugin keeps only the last). - CLI: env proxy + `DRIVEN_PROXY_URL` (manual) only; PAC unsupported there. - deny.toml: `paste` (RUSTSEC-2024-0436, unmaintained build-time proc-macro via boa) ignored with justification; `lru` at 0.16.3+ (clears RUSTSEC-2026-0002). - New IPC `validate_proxy` + Settings UI proxy section (mode select, conditional inputs, inline validation), localized, vitest mount coverage. Refs #34 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QZQVP2tUuTLh8oL31D8heC
1 parent 344262c commit 2f0b7d1

30 files changed

Lines changed: 2646 additions & 167 deletions

File tree

Cargo.lock

Lines changed: 577 additions & 43 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ reqwest = { version = "0.12", default-features = false, features = [
6666
"rustls-tls-native-roots",
6767
"http2",
6868
"stream",
69+
# Issue #34 (SOCKS5 + PAC proxy support): the `socks` feature enables
70+
# `socks5://` and `socks5h://` proxy URLs on `reqwest::Proxy` (the `h`
71+
# variant does proxy-side DNS). Off by default; required for manual SOCKS5
72+
# proxies and any PAC decision that returns a SOCKS proxy.
73+
"socks",
6974
] }
7075
# M3 encryption (DESIGN s7): XChaCha20-Poly1305 STREAM for content +
7176
# single-shot for filenames; BIP39 recovery phrase over the master key;

crates/driven-cli/src/main.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ use driven_drive::google::oauth::{run_pkce_loopback_flow, OAuthProgress};
3333
use driven_drive::google::token_store::{KeyringTokenStore, RefreshingTokenSource};
3434
use driven_drive::google::{md5_hex, parse_installed_client_config, GoogleDriveStore, UploadBytes};
3535
use driven_drive::remote_store::{RemoteStore, UploadBody};
36-
use driven_drive::CustomCaConfig;
36+
use driven_drive::{CustomCaConfig, ProxyConfig};
3737

3838
/// The public installed-app client id (SPEC s4; M4 brief). Used when neither
3939
/// `--client-id`, the env var, nor `client_secret.json` supplies one.
@@ -272,6 +272,7 @@ async fn run_auth(args: AuthArgs) -> anyhow::Result<()> {
272272
open_system_browser,
273273
tx,
274274
&cli_custom_ca(),
275+
&cli_proxy(),
275276
)
276277
.await?;
277278

@@ -414,14 +415,16 @@ fn build_store(account: &str, creds: &ClientCreds) -> anyhow::Result<GoogleDrive
414415
)
415416
})?;
416417
let ca = cli_custom_ca();
418+
let proxy = cli_proxy();
417419
let token_source = RefreshingTokenSource::from_stored_refresh_token(
418420
refresh_token,
419421
creds.client_id.clone(),
420422
creds.client_secret.clone(),
421423
&ca,
424+
&proxy,
422425
)?
423426
.with_store(store);
424-
GoogleDriveStore::with_default_clients(token_source, &ca)
427+
GoogleDriveStore::with_default_clients(token_source, &ca, &proxy)
425428
}
426429

427430
/// Issue #34: the dev/e2e CLI reads its custom root CA (if any) from the
@@ -435,6 +438,18 @@ fn cli_custom_ca() -> CustomCaConfig {
435438
}
436439
}
437440

441+
/// Issue #34: the dev/e2e CLI resolves its proxy from `DRIVEN_PROXY_URL` (an
442+
/// `http`/`https`/`socks5`/`socks5h` URL). Unset = `System` mode, which honours
443+
/// the standard `HTTP_PROXY`/`HTTPS_PROXY` env vars. PAC auto-config is a
444+
/// desktop-app-only feature (it needs an async fetch); the dev CLI supports only
445+
/// the env + manual forms. An invalid URL fails closed at client-build time.
446+
fn cli_proxy() -> ProxyConfig {
447+
match std::env::var("DRIVEN_PROXY_URL") {
448+
Ok(url) if !url.trim().is_empty() => ProxyConfig::Manual(url.trim().to_string()),
449+
_ => ProxyConfig::system(),
450+
}
451+
}
452+
438453
#[cfg(test)]
439454
mod tests {
440455
use super::*;

crates/driven-drive/src/google/mod.rs

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ use std::time::Duration;
4949

5050
use async_trait::async_trait;
5151
use bytes::Bytes;
52-
use driven_tls::CustomCaConfig;
52+
use driven_tls::{CustomCaConfig, ProxyConfig};
5353
use futures::Stream;
5454
use serde::Deserialize;
5555
use tokio::io::{AsyncRead, ReadBuf};
@@ -557,14 +557,19 @@ impl GoogleDriveStore {
557557
/// flows over (the caller builds it with the DESIGN s5.8.4 metadata
558558
/// timeouts). A second, no-overall-timeout streaming client is derived
559559
/// for the resumable chunk PUT + download paths.
560-
pub fn new(http: reqwest::Client, tokens: RefreshingTokenSource, ca: &CustomCaConfig) -> Self {
560+
pub fn new(
561+
http: reqwest::Client,
562+
tokens: RefreshingTokenSource,
563+
ca: &CustomCaConfig,
564+
proxy: &ProxyConfig,
565+
) -> Self {
561566
let _ = TARGET;
562567
// The streaming client mirrors `http`'s TLS/proxy config (including the
563-
// issue #34 custom root CA via `ca`) but drops the overall request cap;
564-
// if the dedicated build fails we fall back to the provided client
565-
// (correctness over a missing idle timeout - `http` already carries the
566-
// same additive CA trust).
567-
let http_stream = build_stream_client(ca).unwrap_or_else(|e| {
568+
// issue #34 custom root CA via `ca` and proxy via `proxy`) but drops the
569+
// overall request cap; if the dedicated build fails we fall back to the
570+
// provided client (correctness over a missing idle timeout - `http`
571+
// already carries the same additive CA trust + proxy).
572+
let http_stream = build_stream_client(ca, proxy).unwrap_or_else(|e| {
568573
warn!(
569574
target: TARGET,
570575
error = %e,
@@ -587,9 +592,10 @@ impl GoogleDriveStore {
587592
pub fn with_default_clients(
588593
tokens: RefreshingTokenSource,
589594
ca: &CustomCaConfig,
595+
proxy: &ProxyConfig,
590596
) -> anyhow::Result<Self> {
591-
let http = build_meta_client(ca)?;
592-
let http_stream = build_stream_client(ca)?;
597+
let http = build_meta_client(ca, proxy)?;
598+
let http_stream = build_stream_client(ca, proxy)?;
593599
Ok(Self {
594600
http,
595601
http_stream,
@@ -1708,28 +1714,36 @@ impl GoogleDriveStore {
17081714
/// Builds the metadata Drive client with the DESIGN s5.8.4 timeouts. `ca` adds
17091715
/// the user's custom root CA (issue #34) additively; fail-closed if it cannot be
17101716
/// loaded.
1711-
pub(crate) fn build_meta_client(ca: &CustomCaConfig) -> anyhow::Result<reqwest::Client> {
1717+
pub(crate) fn build_meta_client(
1718+
ca: &CustomCaConfig,
1719+
proxy: &ProxyConfig,
1720+
) -> anyhow::Result<reqwest::Client> {
17121721
let builder = reqwest::Client::builder()
17131722
.connect_timeout(CONNECT_TIMEOUT)
17141723
.timeout(META_TOTAL_TIMEOUT)
17151724
.read_timeout(STREAM_IDLE_TIMEOUT)
17161725
.pool_max_idle_per_host(4)
17171726
.pool_idle_timeout(Duration::from_secs(90));
1718-
driven_tls::apply_custom_ca(builder, ca)?
1727+
let builder = driven_tls::apply_custom_ca(builder, ca)?;
1728+
driven_tls::apply_proxy(builder, proxy)?
17191729
.build()
17201730
.map_err(|e| anyhow::anyhow!("drive: failed to build metadata client: {e}"))
17211731
}
17221732

17231733
/// Builds the streaming Drive client (resumable chunk PUT + download): no
17241734
/// overall request cap, only the per-byte idle timeout (DESIGN s5.8.4 `*`).
17251735
/// `ca` adds the user's custom root CA (issue #34) additively; fail-closed.
1726-
pub(crate) fn build_stream_client(ca: &CustomCaConfig) -> anyhow::Result<reqwest::Client> {
1736+
pub(crate) fn build_stream_client(
1737+
ca: &CustomCaConfig,
1738+
proxy: &ProxyConfig,
1739+
) -> anyhow::Result<reqwest::Client> {
17271740
let builder = reqwest::Client::builder()
17281741
.connect_timeout(CONNECT_TIMEOUT)
17291742
.read_timeout(STREAM_IDLE_TIMEOUT)
17301743
.pool_max_idle_per_host(4)
17311744
.pool_idle_timeout(Duration::from_secs(90));
1732-
driven_tls::apply_custom_ca(builder, ca)?
1745+
let builder = driven_tls::apply_custom_ca(builder, ca)?;
1746+
driven_tls::apply_proxy(builder, proxy)?
17331747
.build()
17341748
.map_err(|e| anyhow::anyhow!("drive: failed to build streaming client: {e}"))
17351749
}
@@ -1960,12 +1974,15 @@ mod tests {
19601974
refresh_token: "rt".to_string(),
19611975
expires_at: 0,
19621976
};
1963-
let http = build_meta_client(&CustomCaConfig::none()).expect("meta client");
1977+
let http = build_meta_client(&CustomCaConfig::none(), &ProxyConfig::system())
1978+
.expect("meta client");
19641979
let source = RefreshingTokenSource::new(tokens, http, "cid", "secret");
19651980
let store = GoogleDriveStore::new(
1966-
build_meta_client(&CustomCaConfig::none()).expect("meta client"),
1981+
build_meta_client(&CustomCaConfig::none(), &ProxyConfig::system())
1982+
.expect("meta client"),
19671983
source,
19681984
&CustomCaConfig::none(),
1985+
&ProxyConfig::system(),
19691986
);
19701987
// The streaming client is a distinct, usable handle (no panic on build).
19711988
let _ = store.http_stream();
@@ -1976,21 +1993,43 @@ mod tests {
19761993
// Issue #34: the Drive metadata + stream clients add the custom CA
19771994
// additively and fail closed on a bad one; `None` builds normally.
19781995
let none = CustomCaConfig::none();
1979-
assert!(build_meta_client(&none).is_ok(), "no-CA meta client builds");
1996+
let sys = ProxyConfig::system();
1997+
assert!(
1998+
build_meta_client(&none, &sys).is_ok(),
1999+
"no-CA meta client builds"
2000+
);
19802001
assert!(
1981-
build_stream_client(&none).is_ok(),
2002+
build_stream_client(&none, &sys).is_ok(),
19822003
"no-CA stream client builds"
19832004
);
19842005
let bad = CustomCaConfig::from_path(Some(std::path::PathBuf::from(
19852006
"/driven/no/such/drive-ca.pem",
19862007
)));
1987-
assert!(build_meta_client(&bad).is_err(), "bad CA fails meta build");
19882008
assert!(
1989-
build_stream_client(&bad).is_err(),
2009+
build_meta_client(&bad, &sys).is_err(),
2010+
"bad CA fails meta build"
2011+
);
2012+
assert!(
2013+
build_stream_client(&bad, &sys).is_err(),
19902014
"bad CA fails stream build"
19912015
);
19922016
}
19932017

2018+
#[test]
2019+
fn drive_clients_apply_proxy_fail_closed() {
2020+
// Issue #34: a bad proxy URL fails the Drive client builds closed.
2021+
let none = CustomCaConfig::none();
2022+
let bad = ProxyConfig::Manual("ftp://nope:21".to_string());
2023+
assert!(
2024+
build_meta_client(&none, &bad).is_err(),
2025+
"bad proxy fails meta"
2026+
);
2027+
assert!(
2028+
build_stream_client(&none, &bad).is_err(),
2029+
"bad proxy fails stream"
2030+
);
2031+
}
2032+
19942033
#[test]
19952034
fn rfc3339_parses_to_unix_ms() {
19962035
// 2024-01-01T00:00:00Z == 1704067200000 ms.

crates/driven-drive/src/google/oauth.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
2525
use std::time::Duration;
2626

27-
use driven_tls::CustomCaConfig;
27+
use driven_tls::{CustomCaConfig, ProxyConfig};
2828
use oauth2::basic::BasicClient;
2929
use oauth2::{
3030
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl,
@@ -118,6 +118,7 @@ pub async fn run_pkce_loopback_flow(
118118
open_browser: impl FnOnce(&str) -> anyhow::Result<()>,
119119
progress_tx: Sender<OAuthProgress>,
120120
ca: &CustomCaConfig,
121+
proxy: &ProxyConfig,
121122
) -> anyhow::Result<Tokens> {
122123
let (listener_v4, listener_v6, port) = bind_dual_loopback().await?;
123124
// The redirect URI we register with Google MUST be one literal string and
@@ -136,9 +137,11 @@ pub async fn run_pkce_loopback_flow(
136137
.redirect(reqwest::redirect::Policy::none())
137138
.connect_timeout(EXCHANGE_CONNECT_TIMEOUT)
138139
.timeout(EXCHANGE_TOTAL_TIMEOUT);
139-
// Issue #34: add the user's custom root CA additively (fail-closed) so the
140-
// token exchange works behind a corporate TLS-inspecting proxy.
141-
let http = driven_tls::apply_custom_ca(http_builder, ca)?.build()?;
140+
// Issue #34: add the user's custom root CA additively then the configured
141+
// proxy (fail-closed) so the token exchange works behind a corporate
142+
// TLS-inspecting proxy or an explicit SOCKS/PAC proxy.
143+
let http_builder = driven_tls::apply_custom_ca(http_builder, ca)?;
144+
let http = driven_tls::apply_proxy(http_builder, proxy)?.build()?;
142145

143146
let client = BasicClient::new(ClientId::new(client_id.to_string()))
144147
.set_client_secret(ClientSecret::new(client_secret.to_string()))

crates/driven-drive/src/google/token_store.rs

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
use std::sync::Arc;
3535
use std::time::Duration;
3636

37-
use driven_tls::CustomCaConfig;
37+
use driven_tls::{CustomCaConfig, ProxyConfig};
3838
use keyring::Entry;
3939
use serde::Deserialize;
4040
use tokio::sync::Mutex;
@@ -326,8 +326,9 @@ impl RefreshingTokenSource {
326326
client_id: impl Into<String>,
327327
client_secret: impl Into<String>,
328328
ca: &CustomCaConfig,
329+
proxy: &ProxyConfig,
329330
) -> anyhow::Result<Self> {
330-
let http = build_refresh_client(ca)?;
331+
let http = build_refresh_client(ca, proxy)?;
331332
let tokens = Tokens {
332333
access_token: String::new(),
333334
refresh_token: refresh_token.into(),
@@ -475,15 +476,20 @@ fn now_unix() -> i64 {
475476
/// time keeps a hung token endpoint from wedging every Drive request (the
476477
/// refresh holds the token mutex across the await); disabling redirects keeps
477478
/// the credential-bearing client from being steered to an attacker endpoint.
478-
fn build_refresh_client(ca: &CustomCaConfig) -> anyhow::Result<reqwest::Client> {
479+
fn build_refresh_client(
480+
ca: &CustomCaConfig,
481+
proxy: &ProxyConfig,
482+
) -> anyhow::Result<reqwest::Client> {
479483
let builder = reqwest::Client::builder()
480484
.connect_timeout(REFRESH_CONNECT_TIMEOUT)
481485
.timeout(REFRESH_TOTAL_TIMEOUT)
482486
.redirect(reqwest::redirect::Policy::none());
483-
// Issue #34: add the user's custom root CA additively; fail-closed if a
484-
// configured CA cannot be loaded (a corporate proxy would break the refresh
485-
// otherwise, and silently ignoring the CA is never correct).
486-
driven_tls::apply_custom_ca(builder, ca)?
487+
// Issue #34: add the user's custom root CA additively then the configured
488+
// proxy; fail-closed if either cannot be applied (a corporate proxy would
489+
// break the refresh otherwise, and silently ignoring the CA/proxy is never
490+
// correct).
491+
let builder = driven_tls::apply_custom_ca(builder, ca)?;
492+
driven_tls::apply_proxy(builder, proxy)?
487493
.build()
488494
.map_err(|e| anyhow::anyhow!("drive: failed to build OAuth refresh client: {e}"))
489495
}
@@ -597,7 +603,7 @@ mod tests {
597603
// V-A1: the refresh client must build with timeouts + redirect::none.
598604
// Building it is offline (no network); a failure would be a TLS-init
599605
// bug, so this is a real assertion, not a skip.
600-
let client = build_refresh_client(&CustomCaConfig::none());
606+
let client = build_refresh_client(&CustomCaConfig::none(), &ProxyConfig::system());
601607
assert!(
602608
client.is_ok(),
603609
"refresh client must build offline: {:?}",
@@ -613,15 +619,26 @@ mod tests {
613619
let missing = std::path::PathBuf::from("/driven/no/such/ca-bundle.pem");
614620
let ca = CustomCaConfig::from_path(Some(missing));
615621
assert!(
616-
build_refresh_client(&ca).is_err(),
622+
build_refresh_client(&ca, &ProxyConfig::system()).is_err(),
617623
"a missing custom CA file must fail the refresh-client build"
618624
);
619625
}
620626

627+
#[test]
628+
fn refresh_client_fails_closed_with_a_bad_proxy() {
629+
// Issue #34: a configured-but-invalid proxy URL must FAIL the refresh
630+
// client build closed, never building an unproxied credential client.
631+
let bad = ProxyConfig::Manual("ftp://nope:21".to_string());
632+
assert!(
633+
build_refresh_client(&CustomCaConfig::none(), &bad).is_err(),
634+
"an invalid proxy URL must fail the refresh-client build"
635+
);
636+
}
637+
621638
#[test]
622639
fn with_store_wires_the_keychain_store() {
623640
// C-P2-4 / V-A3: with_store attaches a store; without it, none.
624-
let http = build_refresh_client(&CustomCaConfig::none()).unwrap();
641+
let http = build_refresh_client(&CustomCaConfig::none(), &ProxyConfig::system()).unwrap();
625642
let tokens = Tokens {
626643
access_token: String::new(),
627644
refresh_token: "rt".to_string(),

crates/driven-drive/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ pub mod remote_store;
2020
// it without a separate `driven-tls` dependency. `apply_custom_ca` /
2121
// `validate_ca_file` live in `driven_tls` for the crates that build clients.
2222
pub use driven_tls::CustomCaConfig;
23+
// Issue #34: likewise re-export the proxy config type (SOCKS5 + PAC support) so
24+
// the same callers can name it without a direct `driven-tls` dependency.
25+
pub use driven_tls::ProxyConfig;

crates/driven-drive/tests/google_e2e.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,17 @@ async fn setup_store(
9797
drive_context: &DriveContext,
9898
) -> (GoogleDriveStore, String) {
9999
let ca = driven_drive::CustomCaConfig::none();
100+
let proxy = driven_drive::ProxyConfig::system();
100101
let token_source = RefreshingTokenSource::from_stored_refresh_token(
101102
creds.refresh_token.clone(),
102103
creds.client_id.clone(),
103104
creds.client_secret.clone(),
104105
&ca,
106+
&proxy,
105107
)
106108
.expect("build refreshing token source");
107-
let store =
108-
GoogleDriveStore::with_default_clients(token_source, &ca).expect("build GoogleDriveStore");
109+
let store = GoogleDriveStore::with_default_clients(token_source, &ca, &proxy)
110+
.expect("build GoogleDriveStore");
109111

110112
// Each test operates inside a fresh UUID-named child folder so concurrent
111113
// runs never collide and cleanup is a single trash of that subtree

0 commit comments

Comments
 (0)