use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::net::SocketAddr;
use futures_util::StreamExt;
use arti_client::{TorClient, TorClientConfig};
use arti_client::config::CfgPath;
use tor_rtcompat::PreferredRuntime;
use tokio::sync::oneshot;
mod socks;
static TOR_SERVICE: OnceLock<Mutex<Option<Arc<TorService>>>> = OnceLock::new();
fn tor_slot() -> &'static Mutex<Option<Arc<TorService>>> {
TOR_SERVICE.get_or_init(|| Mutex::new(None))
}
static TOR_BOOTSTRAPPING: AtomicBool = AtomicBool::new(false);
static TOR_BOOTSTRAP_PROGRESS: AtomicU8 = AtomicU8::new(0);
pub fn is_bootstrapping() -> bool {
TOR_BOOTSTRAPPING.load(Ordering::Acquire)
}
pub fn bootstrap_progress() -> u8 {
TOR_BOOTSTRAP_PROGRESS.load(Ordering::Acquire)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TorStatus {
Disabled,
Bootstrapping(u8),
Connected,
Failed(String),
}
pub struct TorService {
client: TorClient<PreferredRuntime>,
socks_addr: SocketAddr,
shutdown_tx: Mutex<Option<oneshot::Sender<()>>>,
socks_join: Mutex<Option<tokio::task::JoinHandle<()>>>,
status: Mutex<TorStatus>,
}
impl TorService {
pub async fn start(
state_dir: PathBuf,
cache_dir: PathBuf,
bridges: &[String],
) -> Result<Arc<Self>, String> {
log_info!("[Tor] starting; state={} cache={} bridges={}", state_dir.display(), cache_dir.display(), bridges.len());
TOR_BOOTSTRAPPING.store(true, Ordering::Release);
TOR_BOOTSTRAP_PROGRESS.store(0, Ordering::Release);
struct BootstrapGuard;
impl Drop for BootstrapGuard {
fn drop(&mut self) { TOR_BOOTSTRAPPING.store(false, Ordering::Release); }
}
let _guard = BootstrapGuard;
let mut config_builder = TorClientConfig::builder();
config_builder.storage().state_dir(CfgPath::new_literal(state_dir));
config_builder.storage().cache_dir(CfgPath::new_literal(cache_dir));
let mut bridge_addrs: Vec<SocketAddr> = Vec::new();
if !bridges.is_empty() {
use tor_guardmgr::bridge::BridgeConfigBuilder;
use tor_linkspec::HasAddrs;
let mut valid_count = 0usize;
for line in bridges {
let trimmed = line.trim();
if trimmed.is_empty() { continue; }
match trimmed.parse::<BridgeConfigBuilder>() {
Ok(builder) => {
if let Ok(built) = builder.build() {
for addr in built.addrs() {
bridge_addrs.push(addr);
}
}
config_builder.bridges().bridges().push(builder);
valid_count += 1;
}
Err(e) => log_warn!("[Tor] invalid bridge line, skipping: {} ({})", trimmed, e),
}
}
if valid_count > 0 {
log_info!("[Tor] using {} valid bridge(s)", valid_count);
} else {
log_warn!("[Tor] no valid bridges parsed, falling back to direct connection");
}
if valid_count > 0 && any_obfs4(bridges) {
use arti_client::config::pt::TransportConfigBuilder;
match resolve_obfs4proxy() {
Some(path) => {
log_info!("[Tor] obfs4 transport via {}", path.display());
let mut transport = TransportConfigBuilder::default();
transport
.protocols(vec![
"obfs4".parse().expect("obfs4 is a valid PT name"),
])
.path(CfgPath::new_literal(path))
.run_on_startup(true);
config_builder.bridges().transports().push(transport);
}
None => {
return Err(obfs4proxy_missing_error());
}
}
}
}
*bridge_addrs_slot().lock().unwrap_or_else(|e| e.into_inner()) = bridge_addrs;
let config = config_builder
.build()
.map_err(|e| format!("Tor config build: {e}"))?;
let runtime = PreferredRuntime::current()
.map_err(|e| format!("Tor runtime acquire (need an active tokio runtime): {e}"))?;
let client = TorClient::with_runtime(runtime)
.config(config)
.local_resource_timeout(std::time::Duration::from_secs(3))
.create_unbootstrapped_async()
.await
.map_err(|e| format!("Tor client create: {e}"))?;
let status = Mutex::new(TorStatus::Bootstrapping(0));
let bootstrap_events = client.bootstrap_events();
let log_progress = !bridges.is_empty();
tokio::spawn(async move {
let mut events = bootstrap_events;
while let Some(status) = events.next().await {
let pct = (status.as_frac() * 100.0).clamp(0.0, 100.0) as u8;
TOR_BOOTSTRAP_PROGRESS.store(pct, Ordering::Release);
if log_progress {
log_info!("[Tor] bootstrap progress: {}% (blocked: {})",
pct,
status.blocked().map(|b| b.to_string()).unwrap_or_else(|| "no".into())
);
}
if status.ready_for_traffic() {
break;
}
}
});
log_info!("[Tor] bootstrapping...");
client
.bootstrap()
.await
.map_err(|e| format!("Tor bootstrap: {e}"))?;
TOR_BOOTSTRAP_PROGRESS.store(100, Ordering::Release);
log_info!("[Tor] bootstrap complete");
if !bridges.is_empty() {
log_info!("[Tor] bridges configured: if connections still fail, the bridge may be unreachable or its fingerprint may be missing the ed25519 ID. Get fresh bridges at bridges.torproject.org/bridges/en?transport=vanilla");
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("SOCKS bind: {e}"))?;
let socks_addr = listener
.local_addr()
.map_err(|e| format!("SOCKS local_addr: {e}"))?;
log_info!("[Tor] SOCKS5 listener on {}", socks_addr);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let client_for_socks = client.clone();
let socks_join = tokio::spawn(async move {
socks::run(listener, client_for_socks, shutdown_rx).await;
log_info!("[Tor] SOCKS5 listener stopped");
});
*status.lock().unwrap_or_else(|e| e.into_inner()) = TorStatus::Connected;
let service = Arc::new(TorService {
client,
socks_addr,
shutdown_tx: Mutex::new(Some(shutdown_tx)),
socks_join: Mutex::new(Some(socks_join)),
status,
});
*tor_slot().lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::clone(&service));
Ok(service)
}
pub async fn reconfigure_bridges(
&self,
state_dir: PathBuf,
cache_dir: PathBuf,
bridges: &[String],
) -> Result<(), String> {
use arti_client::config::Reconfigure;
use tor_guardmgr::bridge::BridgeConfigBuilder;
use tor_linkspec::HasAddrs;
let mut config_builder = TorClientConfig::builder();
config_builder.storage().state_dir(CfgPath::new_literal(state_dir));
config_builder.storage().cache_dir(CfgPath::new_literal(cache_dir));
let mut bridge_addrs: Vec<SocketAddr> = Vec::new();
let mut valid_count = 0usize;
for line in bridges {
let trimmed = line.trim();
if trimmed.is_empty() { continue; }
match trimmed.parse::<BridgeConfigBuilder>() {
Ok(builder) => {
if let Ok(built) = builder.build() {
for addr in built.addrs() {
bridge_addrs.push(addr);
}
}
config_builder.bridges().bridges().push(builder);
valid_count += 1;
}
Err(e) => log_warn!("[Tor] invalid bridge line, skipping: {} ({})", trimmed, e),
}
}
if valid_count > 0 {
log_info!("[Tor] reconfiguring with {} bridge(s)", valid_count);
} else {
log_info!("[Tor] reconfiguring without bridges (direct)");
}
if valid_count > 0 && any_obfs4(bridges) {
use arti_client::config::pt::TransportConfigBuilder;
match resolve_obfs4proxy() {
Some(path) => {
log_info!("[Tor] obfs4 transport via {}", path.display());
let mut transport = TransportConfigBuilder::default();
transport
.protocols(vec!["obfs4".parse().expect("obfs4 is a valid PT name")])
.path(CfgPath::new_literal(path))
.run_on_startup(true);
config_builder.bridges().transports().push(transport);
}
None => {
return Err(obfs4proxy_missing_error());
}
}
}
let new_config = config_builder
.build()
.map_err(|e| format!("Tor config build: {e}"))?;
self.client
.reconfigure(&new_config, Reconfigure::AllOrNothing)
.map_err(|e| format!("Tor reconfigure: {e}"))?;
*bridge_addrs_slot().lock().unwrap_or_else(|e| e.into_inner()) = bridge_addrs;
TOR_BOOTSTRAPPING.store(true, Ordering::Release);
TOR_BOOTSTRAP_PROGRESS.store(0, Ordering::Release);
struct BootstrapGuard;
impl Drop for BootstrapGuard {
fn drop(&mut self) { TOR_BOOTSTRAPPING.store(false, Ordering::Release); }
}
let _guard = BootstrapGuard;
let bootstrap_events = self.client.bootstrap_events();
tokio::spawn(async move {
let mut events = bootstrap_events;
while let Some(status) = events.next().await {
let pct = (status.as_frac() * 100.0).clamp(0.0, 100.0) as u8;
TOR_BOOTSTRAP_PROGRESS.store(pct, Ordering::Release);
if status.ready_for_traffic() { break; }
}
});
log_info!("[Tor] re-bootstrapping after bridge reconfigure...");
self.client
.bootstrap()
.await
.map_err(|e| format!("Tor re-bootstrap: {e}"))?;
TOR_BOOTSTRAP_PROGRESS.store(100, Ordering::Release);
log_info!("[Tor] re-bootstrap complete");
let new_token = tor_circmgr::isolation::IsolationToken::new();
*isolation_slot().lock().unwrap_or_else(|e| e.into_inner()) = new_token;
Ok(())
}
pub fn stop(&self) {
if let Some(tx) = self.shutdown_tx.lock().unwrap_or_else(|e| e.into_inner()).take() {
let _ = tx.send(());
}
*tor_slot().lock().unwrap_or_else(|e| e.into_inner()) = None;
log_info!("[Tor] stopped");
}
pub async fn stop_and_join(&self) {
if let Some(tx) = self.shutdown_tx.lock().unwrap_or_else(|e| e.into_inner()).take() {
let _ = tx.send(());
}
let join = self.socks_join.lock().unwrap_or_else(|e| e.into_inner()).take();
if let Some(j) = join {
let _ = j.await;
}
*tor_slot().lock().unwrap_or_else(|e| e.into_inner()) = None;
log_info!("[Tor] stopped (joined)");
}
pub fn proxy_url(&self) -> String {
format!("socks5h://{}", self.socks_addr)
}
pub fn socks_addr(&self) -> SocketAddr {
self.socks_addr
}
pub fn status(&self) -> TorStatus {
self.status.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
}
pub fn current() -> Option<Arc<TorService>> {
tor_slot().lock().unwrap_or_else(|e| e.into_inner()).clone()
}
pub fn proxy_url() -> Option<String> {
current().map(|s| s.proxy_url())
}
pub fn socks_addr() -> Option<SocketAddr> {
current().map(|s| s.socks_addr())
}
pub fn is_active() -> bool {
current().is_some()
}
#[derive(Debug, Clone, Copy)]
pub enum TorTransportState {
Active(SocketAddr),
RequiredButInactive,
Disabled,
}
static TOR_ENABLED_PREF: AtomicBool = AtomicBool::new(false);
static ACTIVE_BRIDGE_ADDRS: OnceLock<Mutex<Vec<SocketAddr>>> = OnceLock::new();
fn bridge_addrs_slot() -> &'static Mutex<Vec<SocketAddr>> {
ACTIVE_BRIDGE_ADDRS.get_or_init(|| Mutex::new(Vec::new()))
}
pub fn active_bridge_addrs() -> Vec<SocketAddr> {
bridge_addrs_slot()
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub fn clear_active_bridge_addrs() {
bridge_addrs_slot()
.lock()
.unwrap_or_else(|e| e.into_inner())
.clear();
}
#[cfg(feature = "tor")]
pub fn resolve_obfs4proxy() -> Option<std::path::PathBuf> {
use std::path::PathBuf;
if let Ok(paths) = std::env::var("PATH") {
for p in std::env::split_paths(&paths) {
for name in &["obfs4proxy", "lyrebird"] {
let candidate = p.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
}
}
let common: &[&str] = &[
"/opt/homebrew/bin/obfs4proxy",
"/usr/local/bin/obfs4proxy",
"/usr/bin/obfs4proxy",
"/opt/local/bin/obfs4proxy",
"/opt/homebrew/bin/lyrebird",
"/usr/local/bin/lyrebird",
"/usr/bin/lyrebird",
];
for c in common {
let p = PathBuf::from(c);
if p.is_file() {
return Some(p);
}
}
None
}
#[cfg(feature = "tor")]
fn any_obfs4(bridges: &[String]) -> bool {
bridges.iter().any(|line| {
line.trim().to_ascii_lowercase().starts_with("obfs4 ")
})
}
#[cfg(feature = "tor")]
pub fn obfs4proxy_missing_error() -> String {
let hint = if cfg!(target_os = "macos") {
"Install via `brew install obfs4proxy`."
} else if cfg!(target_os = "linux") {
"Install via `apt install obfs4proxy` (or your distro's package manager)."
} else if cfg!(target_os = "windows") {
"Download `obfs4proxy.exe` from torproject.org and add it to PATH."
} else if cfg!(target_os = "android") {
"obfs4 isn't supported on Android yet."
} else {
"Install `obfs4proxy` for your platform and add it to PATH."
};
format!(
"obfs4 bridges configured but `obfs4proxy` (or `lyrebird`) was not found. {}",
hint
)
}
fn tor_enabled_pref() -> bool {
TOR_ENABLED_PREF.load(Ordering::Acquire)
}
pub fn set_tor_enabled_pref(enabled: bool) {
TOR_ENABLED_PREF.store(enabled, Ordering::Release);
}
pub fn init_tor_enabled_pref_from_db() {
let enabled = matches!(
crate::db::settings::get_sql_setting("tor_enabled".to_string()),
Ok(Some(ref v)) if v == "1" || v == "true"
);
set_tor_enabled_pref(enabled);
}
pub fn transport_state() -> TorTransportState {
match (tor_enabled_pref(), socks_addr()) {
(true, Some(addr)) => TorTransportState::Active(addr),
(true, None) => TorTransportState::RequiredButInactive,
(false, _) => TorTransportState::Disabled,
}
}
pub fn blackhole_proxy_addr() -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], 1))
}
#[cfg(feature = "tor")]
static CIRCUIT_ISOLATION: OnceLock<Mutex<tor_circmgr::isolation::IsolationToken>> = OnceLock::new();
#[cfg(feature = "tor")]
fn isolation_slot() -> &'static Mutex<tor_circmgr::isolation::IsolationToken> {
CIRCUIT_ISOLATION.get_or_init(|| Mutex::new(tor_circmgr::isolation::IsolationToken::new()))
}
#[cfg(feature = "tor")]
pub fn rotate_circuits() {
let mut guard = isolation_slot().lock().unwrap_or_else(|e| e.into_inner());
*guard = tor_circmgr::isolation::IsolationToken::new();
}
#[cfg(feature = "tor")]
pub fn current_isolation_token() -> tor_circmgr::isolation::IsolationToken {
*isolation_slot().lock().unwrap_or_else(|e| e.into_inner())
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct CircuitHop {
pub position: String,
pub address: String,
pub fingerprint: String,
pub is_bridge: bool,
}
#[cfg(feature = "tor")]
pub async fn current_circuit_hops(force_new: bool) -> Result<Vec<CircuitHop>, String> {
use tor_circmgr::isolation::{IsolationToken, StreamIsolation};
use tor_dirmgr::Timeliness;
use tor_linkspec::{HasAddrs, HasRelayIds, RelayIdType};
let svc = current().ok_or_else(|| "Tor not running".to_string())?;
let netdir = svc
.client
.dirmgr()
.netdir(Timeliness::Timely)
.map_err(|e| format!("netdir unavailable: {e}"))?;
let token = if force_new {
IsolationToken::new()
} else {
current_isolation_token()
};
let isolation = StreamIsolation::builder()
.owner_token(token)
.build()
.map_err(|e| format!("isolation build: {e}"))?;
let tunnel = svc
.client
.circmgr()
.get_or_launch_exit(netdir.as_ref().into(), &[], isolation)
.await
.map_err(|e| format!("launch exit: {e}"))?;
if force_new {
*isolation_slot().lock().unwrap_or_else(|e| e.into_inner()) = token;
}
let path = tunnel
.as_ref()
.all_paths()
.into_iter()
.next()
.ok_or_else(|| "tunnel has no path".to_string())?;
let total = path.n_hops();
let bridge_addrs = active_bridge_addrs();
let hops = path
.hops()
.iter()
.enumerate()
.map(|(i, entry)| {
let position = match i {
0 => "Guard",
n if n + 1 == total => "Exit",
_ => "Middle",
}
.to_string();
match entry.as_chan_target() {
Some(ct) => {
let raw_addr = ct.addrs().next();
let address = raw_addr.map(|a| a.to_string()).unwrap_or_default();
let is_bridge = i == 0
&& raw_addr
.map(|a| bridge_addrs.contains(&a))
.unwrap_or(false);
let fingerprint = ct
.identity(RelayIdType::Ed25519)
.map(|id| {
let s = id.to_string();
s.strip_prefix("ed25519:")
.map(|p| p.to_string())
.unwrap_or(s)
})
.unwrap_or_default();
CircuitHop { position, address, fingerprint, is_bridge }
}
None => CircuitHop {
position,
address: "<virtual>".to_string(),
fingerprint: String::new(),
is_bridge: false,
},
}
})
.collect();
Ok(hops)
}
#[cfg(not(feature = "tor"))]
pub async fn current_circuit_hops(_force_new: bool) -> Result<Vec<CircuitHop>, String> {
Err("Vector was built without the `tor` feature.".to_string())
}