Skip to content

Commit 997e410

Browse files
JSKittyclaude
andcommitted
add: Tor anti-censorship with failsafe, obfs4 bridges, and advanced UI
Always-on failsafe: when Tor is enabled but inactive (mid-bootstrap, mid-toggle, post-failure), all relay and HTTP traffic routes to a blackhole proxy instead of leaking to clearnet. Cache flip happens after stop, never before. Per-account lifecycle: TorService stops on logout, account switch, and account create, then restarts with the new account's preference. SOCKS task drains via JoinSet so state-dir locks release deterministically before runtime drop. Bridges with obfs4: vanilla and obfs4 supported via arti's pluggable transport layer. Bridge reconfigure uses arti's Reconfigure::AllOrNothing then re-bootstraps in place (no stop+start lock contention). System-installed obfs4proxy with inline detection banner and per-OS install hints. Auto-disables bridges when obfs4 lines exist but obfs4proxy is missing, falling back to direct Tor. Compile-time leak prevention: clippy disallowed_methods lint blocks raw reqwest client construction in first-party code, forcing all HTTP through the canonical build_http_client path that respects transport state. Per-stream isolation: each relay socket and HTTP request gets a fresh IsolationToken. Token rotates on circuit rebuild and bridge reconfigure so post-cycle traffic doesn't share circuits with pre-cycle. Advanced UI: live circuit display with rail and traveling pulse, hop position labels, fingerprint hex with hover-to-full, rebuild button, bridges section with horizontal-scroll textarea and apply-on-diff. Login lockscreen shows "Bootstrapping Tor NN%" while the first connection establishes. Android background sync: Tor lifecycle integrated. After client.disconnect, stop_and_join_if_running awaits SOCKS drain before the transient runtime drops. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 95e1b00 commit 997e410

31 files changed

Lines changed: 4256 additions & 2027 deletions

crates/Cargo.lock

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

crates/clippy.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Forbid raw reqwest::Client construction outside the canonical entry point.
2+
# `vector_core::net::build_http_client` honors the Tor failsafe (proxy when
3+
# Tor is up, blackhole when Tor is enabled-but-bootstrapping). Bypassing it
4+
# can leak clearnet traffic when the user has Tor enabled.
5+
disallowed-methods = [
6+
{ path = "reqwest::Client::new", reason = "Use vector_core::net::build_http_client so the Tor failsafe applies (or annotate this call site with #[allow(clippy::disallowed_methods)] if you genuinely need a raw client and have audited why it's safe)." },
7+
{ path = "reqwest::Client::builder", reason = "Use vector_core::net::build_http_client so the Tor failsafe applies." },
8+
{ path = "reqwest::ClientBuilder::new", reason = "Use vector_core::net::build_http_client so the Tor failsafe applies." },
9+
]

crates/vector-core/Cargo.toml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,15 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "logg
6565
# workspace resolves through one consistent source — patching only tor-dirmgr
6666
# while siblings come from crates.io creates a tor_protover type-identity
6767
# split that breaks the build with 150+ trait-mismatch errors.
68-
arti-client = { git = "https://github.com/VectorPrivacy/arti.git", branch = "vector/rusqlite-0.32", default-features = false, features = ["tokio", "rustls"], optional = true }
68+
arti-client = { git = "https://github.com/VectorPrivacy/arti.git", branch = "vector/rusqlite-0.32", default-features = false, features = ["tokio", "rustls", "experimental-api", "bridge-client", "pt-client"], optional = true }
6969
tor-rtcompat = { git = "https://github.com/VectorPrivacy/arti.git", branch = "vector/rusqlite-0.32", default-features = false, features = ["tokio", "rustls"], optional = true }
70+
# For circuit introspection (the "Advanced" panel showing live hops). Direct git
71+
# deps from the same fork so type identities line up with arti-client's transitive
72+
# tree — see the note above on type-identity splits.
73+
tor-circmgr = { git = "https://github.com/VectorPrivacy/arti.git", branch = "vector/rusqlite-0.32", default-features = false, optional = true }
74+
tor-dirmgr = { git = "https://github.com/VectorPrivacy/arti.git", branch = "vector/rusqlite-0.32", default-features = false, optional = true }
75+
tor-linkspec = { git = "https://github.com/VectorPrivacy/arti.git", branch = "vector/rusqlite-0.32", default-features = false, optional = true }
76+
tor-guardmgr = { git = "https://github.com/VectorPrivacy/arti.git", branch = "vector/rusqlite-0.32", default-features = false, features = ["bridge-client", "pt-client"], optional = true }
7077
# tokio_util::compat bridges arti's futures::io::AsyncRead to tokio::io::AsyncRead so
7178
# we can splice Arti's DataStream into a tokio TcpStream for the SOCKS5 bridge.
7279
tokio-util = { version = "0.7", features = ["compat"], optional = true }
@@ -79,7 +86,7 @@ default = []
7986
# Enables embedded Tor (Arti) — bootstraps Arti, runs a localhost SOCKS5 listener
8087
# bridging into the Tor network. Consumers (HTTP / Nostr) opt into the proxy via
8188
# `tor::proxy_url()` returning `Some(socks5://127.0.0.1:<port>)` when active.
82-
tor = ["dep:arti-client", "dep:tor-rtcompat", "dep:tokio-util"]
89+
tor = ["dep:arti-client", "dep:tor-rtcompat", "dep:tokio-util", "dep:tor-circmgr", "dep:tor-dirmgr", "dep:tor-linkspec", "dep:tor-guardmgr"]
8390

8491
[dev-dependencies]
8592
tempfile = "3"

crates/vector-core/src/db/mod.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,30 @@ pub fn init_database(npub: &str) -> Result<(), String> {
247247
let write_conn = create_connection(&db_path)?;
248248
*DB_WRITE_CONN.lock().unwrap() = Some(write_conn);
249249

250+
// Hydrate Tor's hot-path settings cache from the new account's DB.
251+
// Use a direct rusqlite query against `db_path` rather than the global
252+
// `get_sql_setting()` helper — that helper resolves through the read
253+
// pool + `get_current_account()`, both of which may not yet reflect THIS
254+
// account at the moment we run (switch_account calls init_database
255+
// BEFORE set_current_account). Reading the path directly removes the
256+
// race entirely.
257+
#[cfg(feature = "tor")]
258+
{
259+
let enabled = create_connection(&db_path)
260+
.ok()
261+
.and_then(|c| {
262+
c.query_row(
263+
"SELECT value FROM settings WHERE key = 'tor_enabled'",
264+
[],
265+
|row| row.get::<_, String>(0),
266+
)
267+
.ok()
268+
})
269+
.map(|v| v == "1" || v == "true")
270+
.unwrap_or(false);
271+
crate::tor::set_tor_enabled_pref(enabled);
272+
}
273+
250274
Ok(())
251275
}
252276

crates/vector-core/src/lib.rs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,18 @@ pub fn nostr_client_options() -> nostr_sdk::ClientOptions {
7676
let opts = nostr_sdk::ClientOptions::new();
7777
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
7878
{
79-
if let Some(addr) = tor::socks_addr() {
80-
let conn = nostr_sdk::client::Connection::new().proxy(addr);
81-
return opts.connection(conn);
79+
match tor::transport_state() {
80+
tor::TorTransportState::Active(addr) => {
81+
return opts.connection(nostr_sdk::client::Connection::new().proxy(addr));
82+
}
83+
tor::TorTransportState::RequiredButInactive => {
84+
// Tor failsafe: route to a blackhole so the relay socket can't
85+
// accidentally come up direct while Tor is mid-bootstrap.
86+
return opts.connection(
87+
nostr_sdk::client::Connection::new().proxy(tor::blackhole_proxy_addr()),
88+
);
89+
}
90+
tor::TorTransportState::Disabled => {}
8291
}
8392
}
8493
opts
@@ -96,8 +105,18 @@ pub fn nostr_client_options() -> nostr_sdk::ClientOptions {
96105
pub fn tor_aware_relay_options(opts: nostr_sdk::RelayOptions) -> nostr_sdk::RelayOptions {
97106
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
98107
{
99-
if let Some(addr) = tor::socks_addr() {
100-
return opts.connection_mode(nostr_sdk::pool::ConnectionMode::proxy(addr));
108+
match tor::transport_state() {
109+
tor::TorTransportState::Active(addr) => {
110+
return opts.connection_mode(nostr_sdk::pool::ConnectionMode::proxy(addr));
111+
}
112+
tor::TorTransportState::RequiredButInactive => {
113+
// Tor failsafe: pin to blackhole so this relay can never come
114+
// up direct while Tor isn't running.
115+
return opts.connection_mode(
116+
nostr_sdk::pool::ConnectionMode::proxy(tor::blackhole_proxy_addr()),
117+
);
118+
}
119+
tor::TorTransportState::Disabled => {}
101120
}
102121
}
103122
opts

crates/vector-core/src/net.rs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,21 +49,42 @@ fn is_ipv6_private(ip: &std::net::Ipv6Addr) -> bool {
4949

5050
/// Build an HTTP client with the given timeout.
5151
///
52-
/// When the `tor` feature is enabled and `tor::TorService` is currently active,
53-
/// the returned client routes all requests through Tor's local SOCKS5 proxy.
54-
/// When Tor is disabled or the feature is off, this is the identity client.
52+
/// Honors the Tor failsafe: when the user has Tor enabled, every connection
53+
/// goes through Tor — period. If Tor is enabled but not currently running
54+
/// (bootstrap in flight, mid-restart, service crashed), the returned client
55+
/// is wired to a blackhole SOCKS proxy so requests fail at the TCP layer
56+
/// without any chance of leaking clearnet traffic. Direct connections are
57+
/// only ever issued when the user has explicitly disabled Tor.
5558
///
5659
/// Callers should use this rather than `reqwest::Client::builder()` directly
57-
/// so the Tor toggle automatically covers their traffic.
60+
/// so the failsafe automatically covers their traffic. The `disallowed_methods`
61+
/// clippy lint enforces this everywhere except this one canonical call site.
62+
#[allow(clippy::disallowed_methods)]
5863
pub fn build_http_client(timeout: std::time::Duration) -> Result<reqwest::Client, String> {
5964
let mut builder = reqwest::Client::builder().timeout(timeout);
6065

6166
#[cfg(feature = "tor")]
6267
{
63-
if let Some(url) = crate::tor::proxy_url() {
64-
let proxy = reqwest::Proxy::all(&url)
65-
.map_err(|e| format!("Tor proxy URL ({url}) invalid: {e}"))?;
66-
builder = builder.proxy(proxy);
68+
match crate::tor::transport_state() {
69+
crate::tor::TorTransportState::Active(addr) => {
70+
// Use the addr from the variant directly — re-querying via
71+
// proxy_url() races against TorService::stop() and can panic.
72+
let url = format!("socks5h://{addr}");
73+
let proxy = reqwest::Proxy::all(&url)
74+
.map_err(|e| format!("Tor proxy URL ({url}) invalid: {e}"))?;
75+
builder = builder.proxy(proxy);
76+
}
77+
crate::tor::TorTransportState::RequiredButInactive => {
78+
// Tor failsafe: route to a blackhole so connections fail safe
79+
// instead of leaking direct.
80+
let url = format!("socks5h://{}", crate::tor::blackhole_proxy_addr());
81+
let proxy = reqwest::Proxy::all(&url)
82+
.map_err(|e| format!("blackhole proxy invalid: {e}"))?;
83+
builder = builder.proxy(proxy);
84+
}
85+
crate::tor::TorTransportState::Disabled => {
86+
// No proxy — user has Tor off.
87+
}
6788
}
6889
}
6990

0 commit comments

Comments
 (0)