#[derive(Debug, Clone)]
pub enum DialMode {
Direct,
Tls { pinned_cert_der: Option<Vec<u8>> },
Socks5 { proxy: String },
#[cfg(feature = "arti")]
Arti { bridge: Option<String> },
}
#[cfg(feature = "arti")]
pub async fn arti_client(
_bridge: Option<&str>,
) -> crate::error::Result<arti_client::TorClient<tor_rtcompat::PreferredRuntime>> {
use arti_client::{TorClient, TorClientConfig};
use tokio::sync::OnceCell;
use tor_rtcompat::PreferredRuntime;
static ARTI: OnceCell<TorClient<PreferredRuntime>> = OnceCell::const_new();
let client = ARTI
.get_or_try_init(|| async {
let config = TorClientConfig::default();
TorClient::create_bootstrapped(config).await.map_err(|e| {
crate::error::HuddleError::Network(format!("arti bootstrap: {e}"))
})
})
.await?;
Ok(client.clone())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TransportId {
OnionSystemTor,
OnionBridge,
OnionArti,
ClearnetWss,
ClearnetWs,
}
impl TransportId {
pub fn as_str(&self) -> &'static str {
match self {
TransportId::OnionSystemTor => "onion-tor",
TransportId::OnionBridge => "onion-bridge",
TransportId::OnionArti => "onion-arti",
TransportId::ClearnetWss => "clearnet-wss",
TransportId::ClearnetWs => "clearnet-ws",
}
}
pub fn from_str(s: &str) -> Option<Self> {
Some(match s.trim().to_ascii_lowercase().as_str() {
"onion-tor" | "onion" | "tor" => TransportId::OnionSystemTor,
"onion-bridge" | "bridge" => TransportId::OnionBridge,
"onion-arti" | "arti" => TransportId::OnionArti,
"clearnet-wss" | "wss" => TransportId::ClearnetWss,
"clearnet-ws" | "ws" | "clearnet" => TransportId::ClearnetWs,
_ => return None,
})
}
pub fn label(&self) -> &'static str {
match self {
TransportId::OnionSystemTor => "Tor onion (system Tor)",
TransportId::OnionBridge => "Tor onion via bridge (obfs4/WebTunnel)",
TransportId::OnionArti => "Tor onion (built-in Arti)",
TransportId::ClearnetWss => "Clearnet TLS (wss)",
TransportId::ClearnetWs => "Clearnet plain (ws)",
}
}
pub fn description(&self) -> &'static str {
match self {
TransportId::OnionSystemTor => {
"Routes through Tor via your system tor daemon. Hides your IP from the relay. Needs Tor running. Most private."
}
TransportId::OnionBridge => {
"Tor with a private bridge (obfs4/WebTunnel) to get through networks that block Tor. Configure the bridge in your tor (or use --tor-bridge with the arti build)."
}
TransportId::OnionArti => {
"Tor built into huddle (Arti) — same IP protection, no separate Tor install. Requires the `arti` build."
}
TransportId::ClearnetWss => {
"Direct TLS to the relay. Fast and works behind most VPNs, but the relay sees your IP (messages stay end-to-end encrypted)."
}
TransportId::ClearnetWs => {
"Direct WebSocket, no transport encryption (messages still E2E). Fastest; relay + on-path observers see your IP. LAN / testing / last resort."
}
}
}
}
#[derive(Debug, Clone)]
pub struct TransportProfile {
pub id: TransportId,
pub url: Option<String>,
pub dial: Option<DialMode>,
pub reason: Option<&'static str>,
}
impl TransportProfile {
pub fn available(&self) -> bool {
self.dial.is_some()
}
}
fn unavailable(id: TransportId, reason: &'static str) -> TransportProfile {
TransportProfile {
id,
url: None,
dial: None,
reason: Some(reason),
}
}
fn onion_dial(url: &str, tor_socks: &str) -> DialMode {
if url.contains(".onion") {
DialMode::Socks5 {
proxy: tor_socks.to_string(),
}
} else if url.starts_with("wss://") {
DialMode::Tls {
pinned_cert_der: None,
}
} else {
DialMode::Direct
}
}
pub fn builtin_profiles(
onion_url: Option<&str>,
clearnet_url: Option<&str>,
tor_socks: &str,
_tor_bridge: Option<&str>,
) -> Vec<TransportProfile> {
let mut out = Vec::new();
out.push(match onion_url {
Some(u) => TransportProfile {
id: TransportId::OnionSystemTor,
url: Some(u.to_string()),
dial: Some(onion_dial(u, tor_socks)),
reason: None,
},
None => unavailable(TransportId::OnionSystemTor, "no onion relay URL configured"),
});
out.push(match onion_url {
Some(u) => TransportProfile {
id: TransportId::OnionBridge,
url: Some(u.to_string()),
dial: Some(onion_dial(u, tor_socks)),
reason: None,
},
None => unavailable(TransportId::OnionBridge, "no onion relay URL configured"),
});
#[cfg(feature = "arti")]
out.push(match onion_url {
Some(u) => TransportProfile {
id: TransportId::OnionArti,
url: Some(u.to_string()),
dial: Some(DialMode::Arti {
bridge: _tor_bridge.map(|s| s.to_string()),
}),
reason: None,
},
None => unavailable(TransportId::OnionArti, "no onion relay URL configured"),
});
#[cfg(not(feature = "arti"))]
out.push(unavailable(
TransportId::OnionArti,
"rebuild huddle with --features arti to enable",
));
let (wss, ws) = match clearnet_url {
Some(u) if u.starts_with("wss://") => (Some(u.to_string()), None),
Some(u) if u.starts_with("ws://") => (None, Some(u.to_string())),
_ => (None, None),
};
out.push(match wss {
Some(u) => TransportProfile {
id: TransportId::ClearnetWss,
url: Some(u),
dial: Some(DialMode::Tls {
pinned_cert_der: None,
}),
reason: None,
},
None => unavailable(
TransportId::ClearnetWss,
"set a wss:// clearnet relay URL (--clearnet-server / config)",
),
});
out.push(match ws {
Some(u) => TransportProfile {
id: TransportId::ClearnetWs,
url: Some(u),
dial: Some(DialMode::Direct),
reason: None,
},
None => unavailable(
TransportId::ClearnetWs,
"set a ws:// clearnet relay URL (--clearnet-server / config)",
),
});
out
}
pub fn default_fallback_order() -> Vec<TransportId> {
vec![
TransportId::OnionSystemTor,
TransportId::OnionBridge,
TransportId::OnionArti,
TransportId::ClearnetWss,
TransportId::ClearnetWs,
]
}
pub fn parse_order(csv: &str) -> Vec<TransportId> {
csv.split(',')
.filter_map(TransportId::from_str)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transport_id_str_round_trips() {
for id in default_fallback_order() {
assert_eq!(TransportId::from_str(id.as_str()), Some(id));
}
}
#[test]
fn clearnet_scheme_selects_the_right_door() {
let p = builtin_profiles(None, Some("ws://1.2.3.4:8787/ws"), "127.0.0.1:9050", None);
let ws = p.iter().find(|p| p.id == TransportId::ClearnetWs).unwrap();
let wss = p.iter().find(|p| p.id == TransportId::ClearnetWss).unwrap();
assert!(ws.available());
assert!(!wss.available());
let p = builtin_profiles(None, Some("wss://relay.example/ws"), "127.0.0.1:9050", None);
let ws = p.iter().find(|p| p.id == TransportId::ClearnetWs).unwrap();
let wss = p.iter().find(|p| p.id == TransportId::ClearnetWss).unwrap();
assert!(!ws.available());
assert!(wss.available());
}
#[test]
fn onion_available_only_with_url() {
let p = builtin_profiles(Some("ws://x.onion:80/ws"), None, "127.0.0.1:9050", None);
assert!(p
.iter()
.find(|p| p.id == TransportId::OnionSystemTor)
.unwrap()
.available());
let arti = p
.iter()
.find(|p| p.id == TransportId::OnionArti)
.unwrap();
#[cfg(feature = "arti")]
assert!(arti.available());
#[cfg(not(feature = "arti"))]
assert!(!arti.available());
}
}