Skip to main content

qualia_cli/
mesh.rs

1//! `qualia-cli mesh-probe` — a two-machine SocialWebNet reachability probe.
2//!
3//! This is the manual, cross-host counterpart to the in-process loopback tests: it stands up a real
4//! userspace-WireGuard tunnel between two *separate machines* over a real network, so a human can
5//! confirm handshake + data (and NAT traversal) end-to-end. Only one AI instrument writes this; a
6//! person runs the two halves.
7//!
8//! ## Zero key-copying: keys derive from a shared passphrase + role
9//!
10//! WireGuard is mutually authenticated — each side must know the other's static public key. To keep
11//! the manual procedure to *one* shared secret, both halves derive **both** keypairs deterministically
12//! from a passphrase and a role tag:
13//!
14//! ```text
15//!   secret(role) = SHA-256("qualia-mesh-probe:v1:" || role || ":" || passphrase)
16//! ```
17//!
18//! The `listen` side is role `a`; the `connect` side is role `b`. Each computes its own secret and the
19//! peer's public key from the same passphrase, so nothing but the passphrase (and the listener's
20//! address) needs to be shared.
21//!
22//! > The passphrase mode is for **testing reachability**, not production peering — production keys come
23//! > from `NodeIdentity` / the connection-identifier exchange, never a shared phrase.
24//!
25//! ## Procedure
26//!
27//! On the machine that will listen (say its public IP is `A_IP`):
28//! ```text
29//!   qualia-cli mesh-probe listen --pass "our-test-2026" --port 51820
30//! ```
31//! On the other machine:
32//! ```text
33//!   qualia-cli mesh-probe connect --pass "our-test-2026" --peer A_IP:51820 --message "hello"
34//! ```
35//! The listener prints each decrypted inner packet; the connector reports handshake + send. For a
36//! machine behind NAT, forward/allow UDP `51820` to the listener (or run the listener on the
37//! public-IP side).
38
39use std::net::{SocketAddr, ToSocketAddrs};
40use std::time::{Duration, Instant};
41
42use clap::Subcommand;
43use sha2::{Digest, Sha256};
44
45use qualia_core_db::p2p::mesh_datagram::{self, ports};
46use qualia_core_db::p2p::wireguard_runtime::{TunnelEvent, WgTunnel};
47use qualia_core_db::p2p::wireguard_userspace::WgKeypair;
48
49#[derive(Subcommand, Debug)]
50pub enum MeshAction {
51    /// Print a fresh random WireGuard keypair (secret + public), for explicit-key setups.
52    Keygen,
53    /// Listen for a probe connection (role A). Prints decrypted inner packets until Ctrl-C.
54    Listen {
55        /// Shared passphrase both sides agree on (derives both keypairs).
56        #[arg(long)]
57        pass: String,
58        /// UDP port to bind (default 51820, WireGuard's conventional port).
59        #[arg(long, default_value_t = 51820)]
60        port: u16,
61        /// Seconds to run before exiting (0 = run until Ctrl-C).
62        #[arg(long, default_value_t = 0)]
63        seconds: u64,
64    },
65    /// Connect to a listening probe (role B), complete the handshake, and send a message.
66    Connect {
67        /// Shared passphrase both sides agree on (must match the listener's).
68        #[arg(long)]
69        pass: String,
70        /// The listener's address, `host:port` (e.g. `203.0.113.5:51820`).
71        #[arg(long)]
72        peer: String,
73        /// Message to send once the tunnel is up.
74        #[arg(long, default_value = "hello from the SocialWebNet probe")]
75        message: String,
76        /// How many times to send the message (1s apart).
77        #[arg(long, default_value_t = 1)]
78        count: u32,
79        /// Seconds to wait for the handshake before giving up.
80        #[arg(long, default_value_t = 15)]
81        timeout: u64,
82    },
83}
84
85/// Derive a deterministic 32-byte WireGuard secret from `(role, passphrase)`.
86fn derive_secret(role: &str, pass: &str) -> [u8; 32] {
87    let mut hasher = Sha256::new();
88    hasher.update(b"qualia-mesh-probe:v1:");
89    hasher.update(role.as_bytes());
90    hasher.update(b":");
91    hasher.update(pass.as_bytes());
92    let digest = hasher.finalize();
93    let mut out = [0u8; 32];
94    out.copy_from_slice(&digest);
95    out
96}
97
98pub fn run(action: &MeshAction) -> Result<(), Box<dyn std::error::Error>> {
99    match action {
100        MeshAction::Keygen => {
101            let kp = qualia_core_db::p2p::wireguard_userspace::generate_keypair();
102            println!("WireGuard keypair (random):");
103            println!("  secret : {}", hex_lower(&kp.private_bytes()));
104            println!("  public : {}", kp.public_hex());
105            Ok(())
106        }
107        MeshAction::Listen {
108            pass,
109            port,
110            seconds,
111        } => run_listen(pass, *port, *seconds),
112        MeshAction::Connect {
113            pass,
114            peer,
115            message,
116            count,
117            timeout,
118        } => run_connect(pass, peer, message, *count, *timeout),
119    }
120}
121
122fn hex_lower(bytes: &[u8]) -> String {
123    let mut s = String::with_capacity(bytes.len() * 2);
124    for b in bytes {
125        s.push_str(&format!("{b:02x}"));
126    }
127    s
128}
129
130fn run_listen(pass: &str, port: u16, seconds: u64) -> Result<(), Box<dyn std::error::Error>> {
131    // Role A: my key = derive("a"); peer (the connector) = derive("b").
132    let my_keys = WgKeypair::from_secret_bytes(derive_secret("a", pass));
133    let peer_keys = WgKeypair::from_secret_bytes(derive_secret("b", pass));
134
135    let bind: SocketAddr = format!("0.0.0.0:{port}").parse()?;
136    let mut tunnel = WgTunnel::bind(&my_keys, peer_keys.public_bytes().into(), bind, 1)?;
137    tunnel.set_read_timeout(Some(Duration::from_millis(500)))?;
138
139    println!("SocialWebNet probe — LISTEN (role A)");
140    println!("  my WG public : {}", my_keys.public_hex());
141    println!("  bound        : {}", tunnel.local_addr()?);
142    println!("  expecting peer WG public : {}", peer_keys.public_hex());
143    if seconds == 0 {
144        println!("  waiting for a connection… (Ctrl-C to stop)");
145    } else {
146        println!("  waiting up to {seconds}s for a connection…");
147    }
148
149    let deadline = if seconds == 0 {
150        None
151    } else {
152        Some(Instant::now() + Duration::from_secs(seconds))
153    };
154    let mut announced_session = false;
155
156    loop {
157        if let Some(d) = deadline {
158            if Instant::now() >= d {
159                println!("  (timeout reached; exiting)");
160                return Ok(());
161            }
162        }
163        match tunnel.pump()? {
164            TunnelEvent::InnerPacket(inner) => match mesh_datagram::decode_datagram(&inner) {
165                Some(d) => println!(
166                    "  ← received {} bytes on port {}: \"{}\"",
167                    d.payload.len(),
168                    d.dst_port,
169                    String::from_utf8_lossy(&d.payload)
170                ),
171                None => println!(
172                    "  ← received a {}-byte inner packet (not a UDP datagram)",
173                    inner.len()
174                ),
175            },
176            TunnelEvent::Progressed => {
177                if !announced_session && tunnel.has_session() {
178                    announced_session = true;
179                    println!(
180                        "  ✓ handshake complete with {:?} — tunnel is up",
181                        tunnel
182                            .peer_endpoint()
183                            .map(|e| e.to_string())
184                            .unwrap_or_default()
185                    );
186                }
187            }
188            TunnelEvent::Idle => {
189                // Periodically drive timers (keepalives/rekey) while idle.
190                let _ = tunnel.tick();
191            }
192        }
193    }
194}
195
196fn run_connect(
197    pass: &str,
198    peer: &str,
199    message: &str,
200    count: u32,
201    timeout: u64,
202) -> Result<(), Box<dyn std::error::Error>> {
203    // Role B: my key = derive("b"); peer (the listener) = derive("a").
204    let my_keys = WgKeypair::from_secret_bytes(derive_secret("b", pass));
205    let peer_keys = WgKeypair::from_secret_bytes(derive_secret("a", pass));
206
207    let endpoint = peer
208        .to_socket_addrs()?
209        .next()
210        .ok_or_else(|| format!("could not resolve peer address '{peer}'"))?;
211
212    let bind: SocketAddr = "0.0.0.0:0".parse()?;
213    let mut tunnel = WgTunnel::bind(&my_keys, peer_keys.public_bytes().into(), bind, 2)?;
214    tunnel.set_read_timeout(Some(Duration::from_millis(500)))?;
215    tunnel.set_peer_endpoint(endpoint);
216
217    println!("SocialWebNet probe — CONNECT (role B)");
218    println!("  my WG public : {}", my_keys.public_hex());
219    println!(
220        "  peer         : {endpoint} (WG public {})",
221        peer_keys.public_hex()
222    );
223    println!("  initiating handshake…");
224    tunnel.initiate_handshake()?;
225
226    // Drive the handshake to completion. `pump` processes the peer's response; `tick` drives
227    // WireGuard's timers so a *lost* initiation is retransmitted (e.g. the listener wasn't ready
228    // when our first init went out) — without it a single dropped init would hang until timeout.
229    let deadline = Instant::now() + Duration::from_secs(timeout);
230    while !tunnel.has_session() {
231        if Instant::now() >= deadline {
232            return Err(format!(
233                "handshake did not complete within {timeout}s — check the peer address, that the \
234                 listener is running with the same --pass, and that UDP is reachable (NAT/firewall)"
235            )
236            .into());
237        }
238        let _ = tunnel.pump()?;
239        let _ = tunnel.tick()?;
240    }
241    println!("  ✓ handshake complete — tunnel is up");
242
243    // Send the message `count` times. `send_packet` returns whether ciphertext actually went on the
244    // wire (vs. being held pending a (re)handshake), so report honestly rather than assuming.
245    for i in 1..=count {
246        let packet = mesh_datagram::encode_datagram(ports::CHAT, ports::CHAT, message.as_bytes());
247        let transmitted = tunnel.send_packet(&packet)?;
248        if transmitted {
249            println!("  → sent [{i}/{count}]: \"{message}\"");
250        } else {
251            println!("  → queued [{i}/{count}] (awaiting session): \"{message}\"");
252        }
253        // Pump briefly so any keepalive/response is processed before the next send.
254        let until = Instant::now() + Duration::from_millis(900);
255        while Instant::now() < until {
256            let _ = tunnel.pump()?;
257        }
258    }
259    println!("  done. Delivery is best-effort UDP; the listener prints each datagram it decrypts.");
260    Ok(())
261}