Skip to main content

huddle_core/network/
transport.rs

1//! huddle 1.0: transport "doors" onto the relay backend.
2//!
3//! The relay (`huddle-server`) can be reached through several different
4//! transports, each a different tradeoff between privacy / censorship
5//! resistance and speed / simplicity. Because one relay process can be
6//! exposed as a Tor onion AND on a clearnet IP at the same time, these are
7//! interchangeable doors onto the *same* set of rooms and mailboxes. The app
8//! tries them in a fallback order (most private first) — or a user-pinned
9//! one — and surfaces the active door plus each door's tradeoff in the TUI
10//! and CLI, so the anti-censorship effort is legible rather than hidden.
11
12/// How to physically open the WebSocket to a relay URL. Generalizes the old
13/// `if url.contains(".onion") { socks } else { direct }` branch.
14#[derive(Debug, Clone)]
15pub enum DialMode {
16    /// Plain TCP → `ws://` (clearnet, no Tor, no TLS). Fast; the relay sees
17    /// your IP and on-path observers see the WebSocket (the payload is still
18    /// end-to-end encrypted). The easiest thing for a censor to block.
19    Direct,
20    /// rustls TLS → `wss://` (clearnet, TLS). `pinned_cert_der` would pin a
21    /// self-signed cert; `None` uses the system trust store (a real cert via
22    /// Caddy / Let's Encrypt / Cloudflare — the recommended clearnet setup).
23    Tls { pinned_cert_der: Option<Vec<u8>> },
24    /// SOCKS5 to a local Tor (the system `tor`, optionally configured with a
25    /// bridge to punch through censorship). Hides your IP from the relay.
26    Socks5 { proxy: String },
27    /// huddle 1.0: in-process Tor (Arti) — no separate tor daemon. `bridge`
28    /// is reserved for a future obfs4/WebTunnel line (needs an external PT
29    /// binary, so it's not wired yet); plain onion connect works today.
30    #[cfg(feature = "arti")]
31    Arti { bridge: Option<String> },
32}
33
34/// huddle 1.0: a process-global, lazily-bootstrapped Arti Tor client, reused
35/// across reconnects (bootstrapping is expensive). Returns a cheap clone.
36#[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/// Stable identifier for each door — persisted as a setting and accepted on
57/// the CLI (`--transport`, `--transport-order`).
58#[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    /// One-line privacy / anti-censorship tradeoff, shown in the UI + CLI.
100    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/// A resolved door: identity/label/description (always present) plus, when
122/// usable in this build + config, the URL and dial parameters. `dial == None`
123/// means the door is shown in the UI but isn't currently usable — `reason`
124/// explains why (so the user can act on it).
125#[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
148/// How to dial an "onion" relay URL. A real `.onion` goes through Tor's
149/// SOCKS5 proxy; a plain `ws://` / `wss://` host pointed at by `--server`
150/// (tests, or a non-Tor relay) is dialed directly / over TLS. This preserves
151/// the pre-1.0 `if url.contains(".onion")` behavior.
152fn 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
166/// Build the full set of doors from resolved config. Always returns all five
167/// (so the UI/CLI can show every option + its availability); unusable ones
168/// carry `dial = None` and a `reason`.
169pub 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    // Onion via system Tor.
178    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    // Onion via bridge — same SOCKS path; the user's system Tor carries the
189    // bridge line. (With the arti build this can map to Arti+bridge instead.)
190    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    // Onion via Arti (in-process Tor) — only with the `arti` build.
201    #[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    // Clearnet — availability follows the configured URL's scheme so one
220    // `clearnet_url` lights up exactly one clearnet door.
221    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
256/// Default order to try doors in: most private first, clearnet last.
257pub 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
267/// Parse a comma-separated id list (`--transport-order`) into ids, dropping
268/// unknown tokens.
269pub 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        // Arti availability tracks the build feature: available (with an
310        // onion url) under `--features arti`, otherwise off-with-a-reason.
311        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}