huddle 1.0.1

Terminal-native chat over a self-hosted Tor onion relay (opt-in LAN mDNS / direct dial), all Megolm end-to-end encrypted.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
use anyhow::{anyhow, Result};
use clap::{Parser, Subcommand};
use libp2p::Multiaddr;
use tracing_appender::rolling;
use tracing_subscriber::EnvFilter;

use huddle_core::network::NetworkMode;
use huddle_core::storage::keychain;

mod app;
mod clipboard;
mod input;
mod keybindings;
mod notifier;
mod ui;

#[derive(Parser)]
#[command(name = "huddle", version, about = "Huddle — decentralized encrypted chat (TUI)")]
struct Cli {
    #[arg(long, help = "Override data directory")]
    data_dir: Option<String>,

    /// Connection mode. By default huddle runs LAN discovery (libp2p
    /// mDNS) AND the relay together — no flag needed. Pass `mdns`,
    /// `direct` (libp2p without LAN broadcast), or `server` (relay only,
    /// no libp2p) to override. When absent, LAN follows the Settings →
    /// Network toggle (on by default).
    #[arg(long, value_parser = parse_mode)]
    mode: Option<NetworkMode>,

    /// TCP port to listen on (0 = random). Pick a stable one if you want
    /// people to be able to dial you reliably from outside the LAN.
    #[arg(long, default_value_t = 0u16)]
    port: u16,

    /// Optional human-readable display name shown alongside your short
    /// fingerprint in chat.
    #[arg(long)]
    name: Option<String>,

    /// Skip the master passphrase prompt and run with an unencrypted
    /// at-rest database (Phase 1 behavior). For testing only.
    #[arg(long)]
    no_master_passphrase: bool,

    /// Phase D: register with a libp2p Circuit Relay v2 server so peers
    /// behind NAT can dial us via `<this-addr>/p2p-circuit/p2p/<our-id>`.
    /// Repeat to register with multiple relays. The multiaddr MUST
    /// include `/p2p/<peer-id>` so the dial enforces pubkey match.
    /// No defaults — you opt in.
    #[arg(long = "relay", value_name = "MULTIADDR")]
    relays: Vec<String>,

    /// Phase D: opt out of relay registration even if `config.toml` has
    /// entries. LAN-only operation.
    #[arg(long)]
    no_relay: bool,

    /// huddle 0.8: override the centralized server (a Tor onion relay)
    /// WebSocket URL. Defaults to the baked-in onion. `.onion` hosts are
    /// dialed through the local Tor SOCKS5 proxy (127.0.0.1:9050).
    #[arg(long = "server", value_name = "WS_URL")]
    server: Option<String>,

    /// Don't connect to the relay. LAN (libp2p) still works by default,
    /// so this gives you LAN-only operation. Combined with an explicit
    /// `--mode server` it leaves no transport (offline inspection only).
    #[arg(long)]
    no_server: bool,

    /// huddle 0.8: override the local Tor SOCKS5 proxy used to reach
    /// `.onion` server URLs (default 127.0.0.1:9050) — e.g. the Tor
    /// Browser bundle's 127.0.0.1:9150. Also settable in config.toml as
    /// `tor_socks = "..."`; this flag wins.
    #[arg(long = "tor-socks", value_name = "HOST:PORT")]
    tor_socks: Option<String>,

    /// huddle 1.0: a clearnet relay URL onto the SAME backend as the onion —
    /// `ws://<ip>:<port>/ws` (plain, fast) or `wss://host/ws` (TLS). For VPN
    /// users or networks where Tor is blocked. Also `clearnet_url` in config.
    #[arg(long = "clearnet-server", value_name = "WS_URL")]
    clearnet_server: Option<String>,

    /// huddle 1.0: pin a single transport "door" by id (run
    /// `huddle transports` to list them): onion-tor, onion-bridge,
    /// onion-arti, clearnet-wss, clearnet-ws. Default tries them in order.
    #[arg(long = "transport", value_name = "ID")]
    transport: Option<String>,

    /// huddle 1.0: explicit fallback order, comma-separated transport ids
    /// (most-preferred first). Overrides the default most-private-first order.
    #[arg(long = "transport-order", value_name = "ID,ID,...")]
    transport_order: Option<String>,

    /// huddle 1.0: a Tor bridge line (obfs4/WebTunnel) for the bridge door,
    /// to reach Tor where it's blocked. Also `tor_bridge` in config.
    #[arg(long = "tor-bridge", value_name = "LINE")]
    tor_bridge: Option<String>,

    #[command(subcommand)]
    command: Option<Commands>,
}

#[derive(Subcommand)]
enum Commands {
    /// huddle 0.6: print version, paths, and config for bug reports.
    /// Useful for "what does my install look like?" — runs without
    /// the TUI, doesn't touch the network, and never asks for the
    /// master passphrase.
    Doctor,
    /// huddle 1.0: list the transport "doors" onto the relay (onion / bridge
    /// / Arti / clearnet), each with its privacy tradeoff and whether it's
    /// usable in this build + config. Runs without the TUI.
    Transports,
}

fn parse_mode(s: &str) -> std::result::Result<NetworkMode, String> {
    NetworkMode::from_str(s).ok_or_else(|| format!("unknown mode `{s}` (try mdns or direct)"))
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();

    // huddle 0.6: `huddle doctor` runs without the TUI, log appender,
    // or network. Just a pretty diagnostic dump for bug reports.
    // huddle 1.0: `huddle transports` lists the anti-censorship doors.
    match &cli.command {
        Some(Commands::Doctor) => return run_doctor(),
        Some(Commands::Transports) => return run_transports(&cli),
        None => {}
    }

    let log_path = huddle_core::config::log_path();
    if let Some(parent) = log_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let file_appender = rolling::never(
        log_path.parent().unwrap(),
        log_path.file_name().unwrap(),
    );
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::from_default_env().add_directive("huddle=debug".parse()?))
        .with_writer(file_appender)
        .with_ansi(false)
        .init();

    // Restore the terminal on panic before the message prints, so a crash
    // inside the TUI doesn't leave the shell in raw mode / alt screen.
    app::install_panic_hook();

    let cli_mode_explicit = cli.mode.is_some();
    // huddle 0.9.2: the startup mode is resolved AFTER the master key is
    // available (below), so we can honor the persisted in-app mDNS toggle
    // without the user hand-editing config.toml. `--mode`, when given, still
    // wins; the welcome card is skipped for explicit-mode launches.

    // Skip the welcome card if a mode was given explicitly — power users
    // who script `--mode direct` don't want a prompt in the way.
    if !cli_mode_explicit && !app::show_welcome()? {
        return Ok(());
    }

    // Master passphrase: derives the SQLCipher DB key + Megolm persist
    // subkey. `--no-master-passphrase` falls back to an unencrypted DB
    // (Phase 1 behavior).
    //
    // First-launch detection happens BEFORE we create the salt — once
    // the salt exists on disk, the file's there even if no DB is.
    let (master_key, signup_name) = if cli.no_master_passphrase {
        (None, None)
    } else {
        let salt_path = keychain::keychain_salt_path();
        let salt_exists = salt_path.exists();
        // A database with no keychain salt was created by a previous
        // `--no-master-passphrase` run and is unencrypted. Taking the
        // normal (encrypted) path here would derive a fresh key, fail to
        // unlock that DB, and trap the user behind a cryptic SQLCipher
        // error. Refuse early with actionable guidance instead.
        if !salt_exists && huddle_core::config::db_path().exists() {
            anyhow::bail!(
                "found an existing database with no keychain salt — it was \
                 created with --no-master-passphrase and is unencrypted. \
                 Re-run with --no-master-passphrase, or move {} aside to \
                 start fresh.",
                huddle_core::config::db_path().display()
            );
        }
        let is_new = !salt_exists;
        let prompt = app::prompt_master_passphrase(is_new)?;
        if prompt.passphrase.is_empty() {
            return Ok(());
        }
        // Only persist the salt once the user has committed a
        // passphrase — otherwise pressing Esc on first launch leaves
        // a salt file behind and a future launch would think the DB
        // already existed.
        let salt = keychain::load_or_create_salt().map_err(|e| anyhow!(e))?;
        let key =
            keychain::derive_master_key(&prompt.passphrase, &salt).map_err(|e| anyhow!(e))?;
        (Some(key), prompt.username)
    };

    // huddle 0.9.2: resolve the startup network mode. An explicit `--mode`
    // wins; otherwise honor the persisted in-app "run LAN mDNS alongside the
    // relay" toggle (Settings → Network). `Mdns` runs libp2p AND the onion
    // relay together; `Server` (the default when the toggle is off) is
    // relay-only.
    let mode = if let Some(m) = cli.mode {
        m
    } else if huddle_core::app::AppHandle::peek_mdns_enabled(master_key.as_ref())
        .unwrap_or(false)
    {
        NetworkMode::Mdns
    } else {
        NetworkMode::Server
    };

    // Phase D: assemble the relay multiaddr list. CLI flags override
    // config.toml. `--no-relay` wins over everything else.
    let relays: Vec<Multiaddr> = if cli.no_relay {
        Vec::new()
    } else {
        let mut from_cli: Vec<String> = cli.relays.clone();
        if from_cli.is_empty() {
            from_cli = huddle_core::config::load_relays()
                .unwrap_or_default();
        }
        let mut parsed = Vec::new();
        for s in &from_cli {
            match s.parse::<Multiaddr>() {
                Ok(m) => parsed.push(m),
                Err(e) => tracing::warn!(%e, addr = %s, "ignoring invalid --relay addr"),
            }
        }
        parsed
    };

    // huddle 0.9.2: libp2p is opt-in either via `--mode mdns|direct` OR the
    // persisted in-app mDNS toggle (resolved into `mode` above). When on,
    // the libp2p swarm runs alongside the onion relay; otherwise relay-only.

    // huddle 0.8: resolve the centralized-server URL. `--no-server` wins;
    // otherwise precedence is `--server` flag → config.toml `server_url` →
    // the baked-in default onion. The onion address is self-authenticating
    // (it IS the server's key), so baking it in pins the server identity;
    // the override exists so you can repoint without recompiling.
    let server_url: Option<String> = if cli.no_server {
        None
    } else {
        Some(
            cli.server
                .clone()
                .or_else(huddle_core::config::server_url)
                .unwrap_or_else(|| huddle_core::app::DEFAULT_SERVER_URL.to_string()),
        )
    };
    // SOCKS proxy override: `--tor-socks` flag → config.toml `tor_socks` →
    // (core default 127.0.0.1:9050). `None` lets core pick the default.
    let tor_socks: Option<String> = cli.tor_socks.clone().or_else(huddle_core::config::tor_socks);

    if server_url.is_none() && !mode.uses_libp2p() {
        tracing::warn!(
            "no transport configured (--no-server with --mode server) — \
             huddle will run offline; drop --mode server (LAN is on by \
             default) or remove --no-server"
        );
    }

    // huddle 1.0: bundle the transport inputs. The onion url + tor_socks are
    // resolved above (CLI → config → default); clearnet / bridge / pin / order
    // pass the raw CLI values and the core folds in config.toml + saved
    // settings before building the ordered set of doors.
    let transports = huddle_core::app::TransportConfig {
        onion_url: server_url,
        clearnet_url: cli.clearnet_server.clone(),
        tor_socks,
        tor_bridge: cli.tor_bridge.clone(),
        pin: cli.transport.clone(),
        order: cli.transport_order.as_ref().map(|s| {
            s.split(',')
                .map(|t| t.trim().to_string())
                .filter(|t| !t.is_empty())
                .collect()
        }),
    };

    let handle = huddle_core::app::AppHandle::start_with_options(
        mode,
        cli.port,
        master_key.as_ref(),
        relays,
        transports,
    )
    .await
    .map_err(|e| anyhow!(e))?;

    // CLI --name wins over the prompt-supplied username.
    let name_to_set = cli.name.clone().or(signup_name);
    if let Some(name) = name_to_set {
        let trimmed = name.trim();
        if !trimmed.is_empty() {
            handle
                .set_display_name(Some(trimmed))
                .map_err(|e| anyhow!(e))?;
        }
    }
    app::run_tui(handle).await
}

/// huddle 0.6: print a diagnostic snapshot — version, build target,
/// data paths, file sizes, config contents. Output is plain text
/// (no TUI) so users can copy-paste into bug reports.
fn run_doctor() -> Result<()> {
    use huddle_core::config;
    use std::fs;

    println!("huddle {}", env!("CARGO_PKG_VERSION"));
    println!("tui version: 2.0 (sidebar + pane)");
    println!("repository: https://github.com/richer-richard/huddle");
    println!();
    println!("paths:");
    let data_dir = config::data_dir();
    let db_path = config::db_path();
    let log_path = config::log_path();
    let config_path = config::config_path();
    println!("  data dir:   {}", data_dir.display());
    println!("  database:   {}", db_path.display());
    println!("  log file:   {}", log_path.display());
    println!("  config:     {}", config_path.display());
    println!();

    let exists = |p: &std::path::Path| {
        if let Ok(meta) = fs::metadata(p) {
            let kb = meta.len() / 1024;
            format!("present ({} KB)", kb)
        } else {
            "absent".to_string()
        }
    };
    println!("data files:");
    for name in &[
        "huddle.db",
        "huddle.db-shm",
        "huddle.db-wal",
        "keychain.salt",
        "identity.key",
        "huddle.log",
    ] {
        let p = data_dir.join(name);
        println!("  {:<16} {}", format!("{}:", name), exists(&p));
    }
    println!();

    // Config: just print the relay list if any.
    match config::load_relays() {
        Some(list) if !list.is_empty() => {
            println!("relays configured (from config.toml):");
            for r in list {
                println!("  {}", r);
            }
        }
        _ => {
            println!("relays: none configured");
        }
    }
    println!();

    println!("for support, open an issue at:");
    println!("  https://github.com/richer-richard/huddle/issues");
    Ok(())
}

/// huddle 1.0: print the transport "doors" onto the relay — each one's
/// privacy / anti-censorship tradeoff and whether it's usable in this build +
/// config — in the order the app would try them. No TUI, no network.
fn run_transports(cli: &Cli) -> Result<()> {
    use huddle_core::network::transport::{builtin_profiles, default_fallback_order, TransportId};

    let onion = if cli.no_server {
        None
    } else {
        Some(
            cli.server
                .clone()
                .or_else(huddle_core::config::server_url)
                .unwrap_or_else(|| huddle_core::app::DEFAULT_SERVER_URL.to_string()),
        )
    };
    let clearnet = cli
        .clearnet_server
        .clone()
        .or_else(huddle_core::config::clearnet_url);
    let tor_socks = cli
        .tor_socks
        .clone()
        .or_else(huddle_core::config::tor_socks)
        .unwrap_or_else(|| huddle_core::app::DEFAULT_TOR_SOCKS.to_string());
    let bridge = cli.tor_bridge.clone().or_else(huddle_core::config::tor_bridge);

    let profiles = builtin_profiles(onion.as_deref(), clearnet.as_deref(), &tor_socks, bridge.as_deref());

    let order: Vec<TransportId> =
        if let Some(pin) = cli.transport.as_deref().and_then(TransportId::from_str) {
            vec![pin]
        } else if let Some(o) = cli.transport_order.as_deref() {
            let v: Vec<TransportId> = o.split(',').filter_map(TransportId::from_str).collect();
            if v.is_empty() {
                default_fallback_order()
            } else {
                v
            }
        } else {
            default_fallback_order()
        };

    println!("huddle transports — doors onto the relay (most private first)\n");
    for id in &order {
        if let Some(p) = profiles.iter().find(|p| p.id == *id) {
            let status = if p.available() {
                format!("AVAILABLE   {}", p.url.as_deref().unwrap_or(""))
            } else {
                format!("unavailable ({})", p.reason.unwrap_or(""))
            };
            println!("  [{}] {}", p.id.as_str(), p.id.label());
            println!("      {status}");
            println!("      {}", p.id.description());
            println!();
        }
    }
    if cli.transport.is_some() {
        println!("(pinned to a single door via --transport)");
    } else {
        println!("Tried top-to-bottom until one connects — onion is most private, clearnet-ws the least.");
    }
    Ok(())
}