1#[derive(Debug, Clone)]
15pub enum DialMode {
16 Direct,
20 Tls { pinned_cert_der: Option<Vec<u8>> },
24 Socks5 { proxy: String },
27 #[cfg(feature = "arti")]
31 Arti { bridge: Option<String> },
32}
33
34#[cfg(feature = "arti")]
37pub async fn arti_client(
38 _bridge: Option<&str>,
39) -> crate::error::Result<arti_client::TorClient<tor_rtcompat::PreferredRuntime>> {
40 use arti_client::{TorClient, TorClientConfig};
41 use tokio::sync::OnceCell;
42 use tor_rtcompat::PreferredRuntime;
43
44 static ARTI: OnceCell<TorClient<PreferredRuntime>> = OnceCell::const_new();
45 let client = ARTI
46 .get_or_try_init(|| async {
47 let config = TorClientConfig::default();
48 TorClient::create_bootstrapped(config).await.map_err(|e| {
49 crate::error::HuddleError::Network(format!("arti bootstrap: {e}"))
50 })
51 })
52 .await?;
53 Ok(client.clone())
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum TransportId {
60 OnionSystemTor,
61 OnionBridge,
62 OnionArti,
63 ClearnetWss,
64 ClearnetWs,
65}
66
67impl TransportId {
68 pub fn as_str(&self) -> &'static str {
69 match self {
70 TransportId::OnionSystemTor => "onion-tor",
71 TransportId::OnionBridge => "onion-bridge",
72 TransportId::OnionArti => "onion-arti",
73 TransportId::ClearnetWss => "clearnet-wss",
74 TransportId::ClearnetWs => "clearnet-ws",
75 }
76 }
77
78 pub fn from_str(s: &str) -> Option<Self> {
79 Some(match s.trim().to_ascii_lowercase().as_str() {
80 "onion-tor" | "onion" | "tor" => TransportId::OnionSystemTor,
81 "onion-bridge" | "bridge" => TransportId::OnionBridge,
82 "onion-arti" | "arti" => TransportId::OnionArti,
83 "clearnet-wss" | "wss" => TransportId::ClearnetWss,
84 "clearnet-ws" | "ws" | "clearnet" => TransportId::ClearnetWs,
85 _ => return None,
86 })
87 }
88
89 pub fn label(&self) -> &'static str {
90 match self {
91 TransportId::OnionSystemTor => "Tor onion (system Tor)",
92 TransportId::OnionBridge => "Tor onion via bridge (obfs4/WebTunnel)",
93 TransportId::OnionArti => "Tor onion (built-in Arti)",
94 TransportId::ClearnetWss => "Clearnet TLS (wss)",
95 TransportId::ClearnetWs => "Clearnet plain (ws)",
96 }
97 }
98
99 pub fn description(&self) -> &'static str {
101 match self {
102 TransportId::OnionSystemTor => {
103 "Routes through Tor via your system tor daemon. Hides your IP from the relay. Needs Tor running. Most private."
104 }
105 TransportId::OnionBridge => {
106 "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)."
107 }
108 TransportId::OnionArti => {
109 "Tor built into huddle (Arti) — same IP protection, no separate Tor install. Requires the `arti` build."
110 }
111 TransportId::ClearnetWss => {
112 "Direct TLS to the relay. Fast and works behind most VPNs, but the relay sees your IP (messages stay end-to-end encrypted)."
113 }
114 TransportId::ClearnetWs => {
115 "Direct WebSocket, no transport encryption (messages still E2E). Fastest; relay + on-path observers see your IP. LAN / testing / last resort."
116 }
117 }
118 }
119}
120
121#[derive(Debug, Clone)]
126pub struct TransportProfile {
127 pub id: TransportId,
128 pub url: Option<String>,
129 pub dial: Option<DialMode>,
130 pub reason: Option<&'static str>,
131}
132
133impl TransportProfile {
134 pub fn available(&self) -> bool {
135 self.dial.is_some()
136 }
137}
138
139fn unavailable(id: TransportId, reason: &'static str) -> TransportProfile {
140 TransportProfile {
141 id,
142 url: None,
143 dial: None,
144 reason: Some(reason),
145 }
146}
147
148fn onion_dial(url: &str, tor_socks: &str) -> DialMode {
153 if url.contains(".onion") {
154 DialMode::Socks5 {
155 proxy: tor_socks.to_string(),
156 }
157 } else if url.starts_with("wss://") {
158 DialMode::Tls {
159 pinned_cert_der: None,
160 }
161 } else {
162 DialMode::Direct
163 }
164}
165
166pub fn builtin_profiles(
170 onion_url: Option<&str>,
171 clearnet_url: Option<&str>,
172 tor_socks: &str,
173 _tor_bridge: Option<&str>,
174) -> Vec<TransportProfile> {
175 let mut out = Vec::new();
176
177 out.push(match onion_url {
179 Some(u) => TransportProfile {
180 id: TransportId::OnionSystemTor,
181 url: Some(u.to_string()),
182 dial: Some(onion_dial(u, tor_socks)),
183 reason: None,
184 },
185 None => unavailable(TransportId::OnionSystemTor, "no onion relay URL configured"),
186 });
187
188 out.push(match onion_url {
191 Some(u) => TransportProfile {
192 id: TransportId::OnionBridge,
193 url: Some(u.to_string()),
194 dial: Some(onion_dial(u, tor_socks)),
195 reason: None,
196 },
197 None => unavailable(TransportId::OnionBridge, "no onion relay URL configured"),
198 });
199
200 #[cfg(feature = "arti")]
202 out.push(match onion_url {
203 Some(u) => TransportProfile {
204 id: TransportId::OnionArti,
205 url: Some(u.to_string()),
206 dial: Some(DialMode::Arti {
207 bridge: _tor_bridge.map(|s| s.to_string()),
208 }),
209 reason: None,
210 },
211 None => unavailable(TransportId::OnionArti, "no onion relay URL configured"),
212 });
213 #[cfg(not(feature = "arti"))]
214 out.push(unavailable(
215 TransportId::OnionArti,
216 "rebuild huddle with --features arti to enable",
217 ));
218
219 let (wss, ws) = match clearnet_url {
222 Some(u) if u.starts_with("wss://") => (Some(u.to_string()), None),
223 Some(u) if u.starts_with("ws://") => (None, Some(u.to_string())),
224 _ => (None, None),
225 };
226 out.push(match wss {
227 Some(u) => TransportProfile {
228 id: TransportId::ClearnetWss,
229 url: Some(u),
230 dial: Some(DialMode::Tls {
231 pinned_cert_der: None,
232 }),
233 reason: None,
234 },
235 None => unavailable(
236 TransportId::ClearnetWss,
237 "set a wss:// clearnet relay URL (--clearnet-server / config)",
238 ),
239 });
240 out.push(match ws {
241 Some(u) => TransportProfile {
242 id: TransportId::ClearnetWs,
243 url: Some(u),
244 dial: Some(DialMode::Direct),
245 reason: None,
246 },
247 None => unavailable(
248 TransportId::ClearnetWs,
249 "set a ws:// clearnet relay URL (--clearnet-server / config)",
250 ),
251 });
252
253 out
254}
255
256pub fn default_fallback_order() -> Vec<TransportId> {
258 vec![
259 TransportId::OnionSystemTor,
260 TransportId::OnionBridge,
261 TransportId::OnionArti,
262 TransportId::ClearnetWss,
263 TransportId::ClearnetWs,
264 ]
265}
266
267pub fn parse_order(csv: &str) -> Vec<TransportId> {
270 csv.split(',')
271 .filter_map(TransportId::from_str)
272 .collect()
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn transport_id_str_round_trips() {
281 for id in default_fallback_order() {
282 assert_eq!(TransportId::from_str(id.as_str()), Some(id));
283 }
284 }
285
286 #[test]
287 fn clearnet_scheme_selects_the_right_door() {
288 let p = builtin_profiles(None, Some("ws://1.2.3.4:8787/ws"), "127.0.0.1:9050", None);
289 let ws = p.iter().find(|p| p.id == TransportId::ClearnetWs).unwrap();
290 let wss = p.iter().find(|p| p.id == TransportId::ClearnetWss).unwrap();
291 assert!(ws.available());
292 assert!(!wss.available());
293
294 let p = builtin_profiles(None, Some("wss://relay.example/ws"), "127.0.0.1:9050", None);
295 let ws = p.iter().find(|p| p.id == TransportId::ClearnetWs).unwrap();
296 let wss = p.iter().find(|p| p.id == TransportId::ClearnetWss).unwrap();
297 assert!(!ws.available());
298 assert!(wss.available());
299 }
300
301 #[test]
302 fn onion_available_only_with_url() {
303 let p = builtin_profiles(Some("ws://x.onion:80/ws"), None, "127.0.0.1:9050", None);
304 assert!(p
305 .iter()
306 .find(|p| p.id == TransportId::OnionSystemTor)
307 .unwrap()
308 .available());
309 let arti = p
312 .iter()
313 .find(|p| p.id == TransportId::OnionArti)
314 .unwrap();
315 #[cfg(feature = "arti")]
316 assert!(arti.available());
317 #[cfg(not(feature = "arti"))]
318 assert!(!arti.available());
319 }
320}