Skip to main content

zwire_host/
peer.rs

1//! Host-to-host peering — a mesh of `zwire-host` daemons.
2//!
3//! A daemon can listen for peers on TCP (`serve --tcp <addr>`) and/or dial out
4//! to peers (`--peer <addr>`, or the `peer_connect` command). Peer links carry
5//! two things:
6//!   * **bus federation** — a `pub` (or a `scheme`/`ui` change) on one host is
7//!     forwarded to every peer, whose local subscribers then receive it, so the
8//!     event bus spans all your machines; and
9//!   * **remote requests** — `{"cmd":"remote","peer":"host:port","request":{…}}`
10//!     runs a request on another host and returns its reply.
11//!
12//! TCP is guarded by a shared `--token` (or `$ZWIRE_HOST_TOKEN`): inbound TCP
13//! connections must `auth` / `peer_hello` with it before doing anything
14//! privileged. Local Unix-socket clients are trusted and never need it.
15//!
16//! Federation is single-hop: a forwarded event is delivered locally but not
17//! re-forwarded, which covers star and fully-meshed topologies without loops.
18use crate::proto::{read_ndjson, send_msg, Out, Peer};
19use serde_json::{json, Value};
20use std::collections::HashMap;
21use std::io::BufReader;
22use std::net::{TcpListener, TcpStream};
23use std::sync::{Mutex, OnceLock};
24use std::time::Duration;
25
26struct Config {
27    token: Option<String>,
28    name: String,
29}
30
31fn config() -> &'static Mutex<Config> {
32    static C: OnceLock<Mutex<Config>> = OnceLock::new();
33    C.get_or_init(|| {
34        Mutex::new(Config {
35            token: std::env::var("ZWIRE_HOST_TOKEN")
36                .ok()
37                .filter(|t| !t.is_empty()),
38            name: hostname(),
39        })
40    })
41}
42
43fn hostname() -> String {
44    crate::osops::hostname().unwrap_or_else(|| "host".to_string())
45}
46
47/// Set the peering token and this host's advertised name (from CLI flags).
48pub fn configure(token: Option<String>, name: Option<String>) {
49    let mut c = config().lock().unwrap();
50    if let Some(t) = token.filter(|t| !t.is_empty()) {
51        c.token = Some(t);
52    }
53    if let Some(n) = name {
54        c.name = n;
55    }
56}
57
58fn token() -> Option<String> {
59    config().lock().unwrap().token.clone()
60}
61
62/// This host's name, sent in handshakes and shown in `peers`.
63pub fn local_name() -> String {
64    config().lock().unwrap().name.clone()
65}
66
67/// Whether TCP connections must authenticate (a token is configured).
68pub fn auth_required() -> bool {
69    token().is_some()
70}
71
72/// Validate a presented token against the configured one (always ok if none).
73pub fn token_ok(presented: Option<&str>) -> bool {
74    match token() {
75        None => true,
76        Some(tok) => presented == Some(tok.as_str()),
77    }
78}
79
80/* ---- peer link registry ---- */
81
82struct Link {
83    out: Out,
84    name: String,
85}
86
87fn links() -> &'static Mutex<(u64, HashMap<u64, Link>)> {
88    static L: OnceLock<Mutex<(u64, HashMap<u64, Link>)>> = OnceLock::new();
89    L.get_or_init(|| Mutex::new((0, HashMap::new())))
90}
91
92/// Register a peer link, replacing any existing link with the same name (a
93/// reconnect, or the reverse direction of a mutual peering). Returns its id.
94pub fn register_link(out: &Out, name: &str) -> u64 {
95    let mut g = links().lock().unwrap();
96    let stale: Vec<u64> =
97        g.1.iter()
98            .filter(|(_, l)| l.name == name)
99            .map(|(id, _)| *id)
100            .collect();
101    for id in stale {
102        g.1.remove(&id);
103    }
104    g.0 += 1;
105    let id = g.0;
106    g.1.insert(
107        id,
108        Link {
109            out: out.clone(),
110            name: name.to_string(),
111        },
112    );
113    id
114}
115
116/// Drop a peer link (its connection closed).
117pub fn unregister_link(id: u64) {
118    links().lock().unwrap().1.remove(&id);
119}
120
121/// Names of currently connected peers.
122pub fn peer_names() -> Vec<String> {
123    let mut names: Vec<String> = links()
124        .lock()
125        .unwrap()
126        .1
127        .values()
128        .map(|l| l.name.clone())
129        .collect();
130    names.sort();
131    names
132}
133
134/// Forward a published event to every peer; returns how many links took it.
135pub fn broadcast(topic: &str, data: &Value) -> usize {
136    let outs: Vec<Out> = {
137        links()
138            .lock()
139            .unwrap()
140            .1
141            .values()
142            .map(|l| l.out.clone())
143            .collect()
144    };
145    let frame = json!({ "cmd": "peer_pub", "topic": topic, "data": data });
146    outs.iter().filter(|o| send_msg(o, &frame).is_ok()).count()
147}
148
149/* ---- inbound: accept peers over TCP ---- */
150
151/// Spawn the TCP listener that accepts peer/remote connections.
152pub fn listen_tcp(addr: String) {
153    std::thread::spawn(move || {
154        let listener = match TcpListener::bind(&addr) {
155            Ok(l) => l,
156            Err(e) => {
157                eprintln!("zwire-host: tcp bind {addr}: {e}");
158                return;
159            }
160        };
161        eprintln!("zwire-host: peering on tcp {addr}");
162        for conn in listener.incoming().flatten() {
163            std::thread::spawn(move || handle_inbound(conn));
164        }
165    });
166}
167
168fn handle_inbound(stream: TcpStream) {
169    let Ok(rclone) = stream.try_clone() else {
170        return;
171    };
172    let out = Peer::ndjson(Box::new(stream));
173    // Inbound TCP is untrusted until it authenticates (if a token is set).
174    let sess = crate::session::Session::guarded(auth_required());
175    crate::transport::serve_conn(BufReader::new(rclone), out, sess);
176}
177
178/* ---- outbound: dial peers and keep the link up ---- */
179
180/// Spawn a task that keeps a link to `addr` open, reconnecting on failure.
181pub fn dial(addr: String) {
182    std::thread::spawn(move || loop {
183        if let Err(e) = try_dial(&addr) {
184            eprintln!("zwire-host: peer {addr}: {e}");
185        }
186        std::thread::sleep(Duration::from_secs(5));
187    });
188}
189
190fn try_dial(addr: &str) -> Result<(), String> {
191    let stream = TcpStream::connect(addr).map_err(|e| e.to_string())?;
192    let rclone = stream.try_clone().map_err(|e| e.to_string())?;
193    let out = Peer::ndjson(Box::new(stream));
194    let mut reader = BufReader::new(rclone);
195
196    // Handshake: introduce ourselves and prove the token.
197    send_msg(
198        &out,
199        &json!({ "cmd": "peer_hello", "token": token(), "name": local_name() }),
200    )
201    .map_err(|e| e.to_string())?;
202    let reply = read_ndjson(&mut reader).ok_or("peer closed during handshake")?;
203    if reply["ok"] != json!(true) {
204        return Err(reply["err"]
205            .as_str()
206            .unwrap_or("handshake rejected")
207            .to_string());
208    }
209    let peer_name = reply["name"].as_str().unwrap_or(addr).to_string();
210    let link = register_link(&out, &peer_name);
211    eprintln!("zwire-host: peered with {peer_name} ({addr})");
212
213    // We initiated a trusted dial, so this session is pre-authenticated; it
214    // handles the peer's forwarded events (peer_pub) for the life of the link.
215    let mut sess = crate::session::Session::guarded(false);
216    while let Some(msg) = read_ndjson(&mut reader) {
217        sess.handle(&out, &msg);
218    }
219    unregister_link(link);
220    Ok(())
221}
222
223/* ---- remote requests ---- */
224
225/// Run one request on the peer at `addr` and return its reply. Uses a fresh
226/// connection (authenticating if a token is set), so it is independent of the
227/// long-lived federation link. Intended for RPC commands, not streams.
228pub fn remote(addr: &str, request: &Value) -> Result<Value, String> {
229    let stream = TcpStream::connect(addr).map_err(|e| format!("connect {addr}: {e}"))?;
230    let _ = stream.set_read_timeout(Some(Duration::from_secs(15)));
231    let rclone = stream.try_clone().map_err(|e| e.to_string())?;
232    let out = Peer::ndjson(Box::new(stream));
233    let mut reader = BufReader::new(rclone);
234
235    if let Some(tok) = token() {
236        send_msg(&out, &json!({ "cmd": "auth", "token": tok })).map_err(|e| e.to_string())?;
237        let _ = read_ndjson(&mut reader); // consume the auth ack
238    }
239    send_msg(&out, request).map_err(|e| e.to_string())?;
240    read_ndjson(&mut reader).ok_or_else(|| "no reply from remote".to_string())
241}