Skip to main content

zwire_host/
session.rs

1//! The capability router shared by every transport.
2//!
3//! A [`Session`] is the per-connection state: its open PTYs and its sysinfo
4//! stream. [`Session::handle`] takes one decoded request plus the connection's
5//! [`Out`] sink and does the work — reply for RPC, or set up a background stream.
6//! Both the Chrome stdio loop and the socket daemon drive it the same way, so
7//! every capability is reachable from every client with no per-transport code.
8use crate::proto::{respond, send_msg, Out};
9use crate::store;
10use crate::{bus, exec, fsops, jobs, osops, peer, watch};
11use serde_json::{json, Value};
12use std::collections::HashMap;
13
14/// Capability tags advertised in the `hello` reply so a client can feature-test.
15/// The set reflects which optional capabilities were compiled in.
16pub fn caps() -> Vec<&'static str> {
17    #[cfg_attr(not(any(feature = "sysinfo-caps", feature = "pty")), allow(unused_mut))]
18    let mut c = vec![
19        "hello",
20        "kv",
21        "fs",
22        "exec",
23        "jobs",
24        "bus",
25        "peer",
26        "watch",
27        "open",
28        "clipboard",
29        "notify",
30        "hostinfo",
31        "scheme",
32        "ui",
33    ];
34    #[cfg(feature = "sysinfo-caps")]
35    {
36        c.push("sysinfo");
37        c.push("procs");
38    }
39    #[cfg(feature = "pty")]
40    c.push("pty");
41    c
42}
43
44/// Per-connection state. Dropping it tears down every PTY, stops the sysinfo
45/// stream, and drops the connection's bus subscription + peer link, so a client
46/// disconnect never leaks a shell, a thread, a subscriber, or a stale link.
47pub struct Session {
48    #[cfg(feature = "pty")]
49    ptys: HashMap<String, crate::pty::PtySession>,
50    watchers: HashMap<String, watch::Watcher>,
51    #[cfg(feature = "sysinfo-caps")]
52    sysmon: Option<crate::sysmon::Monitor>,
53    /// Bus subscriber handle, allocated lazily on the first `sub`.
54    sub_id: Option<u64>,
55    /// Peer link handle, set when an inbound peer completes `peer_hello`.
56    peer_id: Option<u64>,
57    /// Whether this connection may run privileged commands. Local clients are
58    /// trusted; TCP clients must authenticate first when a token is configured.
59    authed: bool,
60}
61
62impl Drop for Session {
63    fn drop(&mut self) {
64        if let Some(id) = self.sub_id {
65            bus::unregister(id);
66        }
67        if let Some(id) = self.peer_id {
68            peer::unregister_link(id);
69        }
70    }
71}
72
73impl Default for Session {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl Session {
80    /// Fresh, trusted session (local socket / stdio).
81    pub fn new() -> Self {
82        Self::guarded(false)
83    }
84
85    /// Session for a connection that must authenticate before privileged use
86    /// when `require_auth` is set (inbound TCP with a token configured).
87    pub fn guarded(require_auth: bool) -> Self {
88        Session {
89            #[cfg(feature = "pty")]
90            ptys: HashMap::new(),
91            watchers: HashMap::new(),
92            #[cfg(feature = "sysinfo-caps")]
93            sysmon: None,
94            sub_id: None,
95            peer_id: None,
96            authed: !require_auth,
97        }
98    }
99
100    /// The session key for a PTY/stream request: its `id` string, or `""` for
101    /// the legacy single-terminal default.
102    fn key(req: &Value) -> String {
103        req["id"].as_str().unwrap_or("").to_string()
104    }
105
106    /// The app namespace for state ops: `app` field, or `zwire` by default.
107    fn app(req: &Value) -> String {
108        req["app"].as_str().unwrap_or("zwire").to_string()
109    }
110
111    /// Handle one request. `out` is the connection's write sink; background
112    /// capabilities (sysinfo, PTY output) keep writing to it after this returns.
113    pub fn handle(&mut self, out: &Out, msg: &Value) {
114        if let Some(cmd) = msg["cmd"].as_str() {
115            self.handle_cmd(out, msg, cmd);
116            return;
117        }
118        // Legacy commandless messages: {ui:{…}} and {scheme:"…"}.
119        if !msg["ui"].is_null() {
120            let ui = store::write_ui(&store::app_dir("zwire"), &msg["ui"]);
121            // Notify local subscribers and every peer so all apps on all
122            // machines keep their UI prefs in sync live.
123            bus::publish("ui", &ui);
124            peer::broadcast("ui", &ui);
125            respond(out, msg, json!({"ok": true, "ui": ui}));
126        } else if let Some(s) = msg["scheme"].as_str() {
127            self.set_scheme(out, msg, s);
128        } else {
129            respond(out, msg, json!({"ok": false, "err": "empty"}));
130        }
131    }
132
133    fn handle_cmd(&mut self, out: &Out, msg: &Value, cmd: &str) {
134        // Untrusted (TCP) connections may only authenticate or do harmless
135        // discovery until they present the token.
136        if !self.authed && !matches!(cmd, "auth" | "peer_hello" | "hello" | "ping") {
137            respond(out, msg, json!({"ok": false, "err": "unauthorized"}));
138            return;
139        }
140        match cmd {
141            "hello" | "ping" => respond(
142                out,
143                msg,
144                json!({
145                    "ok": true, "host": "zwire-host", "version": crate::VERSION,
146                    "os": std::env::consts::OS, "arch": std::env::consts::ARCH,
147                    "pid": std::process::id(), "caps": caps(),
148                }),
149            ),
150
151            /* ---- namespaced key/value store ---- */
152            "kv_get" => {
153                let v = store::kv_get(&Self::app(msg), msg["key"].as_str().unwrap_or(""));
154                respond(out, msg, json!({"ok": true, "value": v}));
155            }
156            "kv_set" => {
157                let ok = store::kv_set(
158                    &Self::app(msg),
159                    msg["key"].as_str().unwrap_or(""),
160                    &msg["value"],
161                );
162                respond(out, msg, json!({ "ok": ok }));
163            }
164            "kv_merge" => {
165                let v = store::kv_merge(
166                    &Self::app(msg),
167                    msg["key"].as_str().unwrap_or(""),
168                    &msg["value"],
169                );
170                respond(out, msg, json!({"ok": true, "value": v}));
171            }
172            "kv_del" => {
173                let ok = store::kv_del(&Self::app(msg), msg["key"].as_str().unwrap_or(""));
174                respond(out, msg, json!({ "ok": ok }));
175            }
176            "kv_keys" => {
177                let keys = store::kv_keys(&Self::app(msg));
178                respond(out, msg, json!({"ok": true, "keys": keys}));
179            }
180
181            /* ---- legacy zwire scheme + ui + get ---- */
182            "get" => {
183                let d = store::app_dir("zwire");
184                respond(
185                    out,
186                    msg,
187                    json!({"ok": true, "scheme": store::current_scheme(&d), "ui": store::current_ui(&d)}),
188                );
189            }
190
191            /* ---- streaming file observers ---- */
192            "fs_watch" | "fs_tail" => {
193                let key = Self::key(msg);
194                let watcher = if cmd == "fs_watch" {
195                    watch::Watcher::fs_watch(out, msg, key.clone())
196                } else {
197                    watch::Watcher::fs_tail(out, msg, key.clone())
198                };
199                self.watchers.insert(key, watcher);
200                respond(out, msg, json!({"ok": true, "watching": true}));
201            }
202            "watch_stop" => {
203                self.watchers.remove(&Self::key(msg));
204                respond(out, msg, json!({"ok": true}));
205            }
206            "watch_list" => {
207                let mut ids: Vec<&String> = self.watchers.keys().collect();
208                ids.sort();
209                respond(out, msg, json!({"ok": true, "watchers": ids}));
210            }
211
212            /* ---- filesystem ---- */
213            c if c.starts_with("fs_") => {
214                let reply = fsops::handle(c, msg);
215                respond(out, msg, reply);
216            }
217
218            /* ---- subprocess ---- */
219            "exec" => respond(out, msg, exec::run(msg)),
220
221            /* ---- background jobs ---- */
222            c if c.starts_with("job_") => {
223                let reply = jobs::handle(c, msg);
224                respond(out, msg, reply);
225            }
226
227            /* ---- process tools ---- */
228            #[cfg(feature = "sysinfo-caps")]
229            "ps" | "kill" | "which" => respond(out, msg, crate::procs::handle(cmd, msg)),
230
231            /* ---- pub/sub event bus ---- */
232            "sub" => {
233                let id = *self.sub_id.get_or_insert_with(|| bus::register(out));
234                if let Some(topic) = msg["topic"].as_str() {
235                    bus::subscribe(id, topic);
236                    respond(out, msg, json!({"ok": true, "topic": topic}));
237                } else {
238                    respond(out, msg, json!({"ok": false, "err": "no_topic"}));
239                }
240            }
241            "unsub" => {
242                if let (Some(id), Some(topic)) = (self.sub_id, msg["topic"].as_str()) {
243                    bus::unsubscribe(id, topic);
244                }
245                respond(out, msg, json!({"ok": true}));
246            }
247            "pub" => match msg["topic"].as_str() {
248                Some(topic) => {
249                    let delivered = bus::publish(topic, &msg["data"]);
250                    let forwarded = peer::broadcast(topic, &msg["data"]);
251                    respond(
252                        out,
253                        msg,
254                        json!({"ok": true, "delivered": delivered, "forwarded": forwarded}),
255                    );
256                }
257                None => respond(out, msg, json!({"ok": false, "err": "no_topic"})),
258            },
259
260            /* ---- host-to-host peering ---- */
261            "auth" => {
262                if peer::token_ok(msg["token"].as_str()) {
263                    self.authed = true;
264                    respond(out, msg, json!({"ok": true}));
265                } else {
266                    respond(out, msg, json!({"ok": false, "err": "unauthorized"}));
267                }
268            }
269            "peer_hello" => {
270                if peer::token_ok(msg["token"].as_str()) {
271                    self.authed = true;
272                    let name = msg["name"].as_str().unwrap_or("peer");
273                    self.peer_id = Some(peer::register_link(out, name));
274                    respond(out, msg, json!({"ok": true, "name": peer::local_name()}));
275                } else {
276                    respond(out, msg, json!({"ok": false, "err": "unauthorized"}));
277                }
278            }
279            // A federated event from a peer: deliver locally only (never
280            // re-forward) so events can't loop around the mesh.
281            "peer_pub" => {
282                if let Some(topic) = msg["topic"].as_str() {
283                    bus::publish(topic, &msg["data"]);
284                }
285            }
286            "peers" => respond(
287                out,
288                msg,
289                json!({"ok": true, "self": peer::local_name(), "peers": peer::peer_names()}),
290            ),
291            "peer_connect" => match msg["addr"].as_str() {
292                Some(addr) => {
293                    peer::dial(addr.to_string());
294                    respond(out, msg, json!({"ok": true, "dialing": addr}));
295                }
296                None => respond(out, msg, json!({"ok": false, "err": "no_addr"})),
297            },
298            "remote" => match msg["peer"].as_str() {
299                Some(addr) => match peer::remote(addr, &msg["request"]) {
300                    Ok(reply) => {
301                        respond(out, msg, json!({"ok": true, "peer": addr, "reply": reply}))
302                    }
303                    Err(e) => respond(out, msg, json!({"ok": false, "err": e})),
304                },
305                None => respond(out, msg, json!({"ok": false, "err": "no_peer"})),
306            },
307
308            /* ---- os integration ---- */
309            "open" | "clipboard_get" | "clipboard_set" | "notify" | "hostinfo" => {
310                respond(out, msg, osops::handle(cmd, msg));
311            }
312
313            /* ---- live system stats ---- */
314            #[cfg(feature = "sysinfo-caps")]
315            "sysinfo_start" => {
316                let interval = msg["interval_ms"].as_u64().unwrap_or(2000);
317                self.sysmon = Some(crate::sysmon::Monitor::start(out, interval, Self::key(msg)));
318                respond(out, msg, json!({"ok": true, "streaming": true}));
319            }
320            #[cfg(feature = "sysinfo-caps")]
321            "sysinfo_stop" => {
322                self.sysmon = None;
323                respond(out, msg, json!({"ok": true, "streaming": false}));
324            }
325            #[cfg(feature = "sysinfo-caps")]
326            "sysinfo_once" => {
327                use sysinfo::{Disks, Networks, System};
328                let mut sys = System::new();
329                sys.refresh_cpu_usage();
330                sys.refresh_memory();
331                let nets = Networks::new_with_refreshed_list();
332                // A disk's `usage()` reports the raw counter on its first read,
333                // so a second back-to-back refresh makes the one-shot I/O delta
334                // ~0 — an honest "no rate from a single instant" rather than a
335                // lifetime total mislabelled as bytes-per-second.
336                let mut disks = Disks::new_with_refreshed_list();
337                disks.refresh(true);
338                respond(
339                    out,
340                    msg,
341                    json!({"ok": true, "sys": crate::sysmon::snapshot(1.0, &nets, &disks, &sys)}),
342                );
343            }
344
345            /* ---- multiplexed PTY terminals ---- */
346            #[cfg(feature = "pty")]
347            "pty_spawn" => {
348                let key = Self::key(msg);
349                match crate::pty::PtySession::spawn(out, msg, key.clone()) {
350                    Some(s) => {
351                        self.ptys.insert(key, s);
352                        respond(out, msg, json!({"ok": true, "spawned": true}));
353                    }
354                    None => respond(out, msg, json!({"ok": false, "err": "pty_spawn_failed"})),
355                }
356            }
357            #[cfg(feature = "pty")]
358            "pty_write" => {
359                if let Some(s) = self.ptys.get_mut(&Self::key(msg)) {
360                    s.write(msg);
361                }
362            }
363            #[cfg(feature = "pty")]
364            "pty_resize" => {
365                if let Some(s) = self.ptys.get(&Self::key(msg)) {
366                    s.resize(msg);
367                }
368            }
369            #[cfg(feature = "pty")]
370            "pty_kill" => {
371                // Dropping the session kills the child; its reader thread emits
372                // the `exit` frame.
373                self.ptys.remove(&Self::key(msg));
374            }
375
376            _ => {
377                let _ = send_msg(out, &json!({"ok": false, "err": "unknown_cmd", "cmd": cmd}));
378            }
379        }
380    }
381
382    fn set_scheme(&self, out: &Out, msg: &Value, s: &str) {
383        if store::SCHEMES.contains(&s) {
384            store::write_scheme(&store::app_dir("zwire"), s);
385            // Push the change to every local subscriber and every peer for live
386            // cross-app, cross-machine theme sync.
387            let data = json!({ "scheme": s });
388            bus::publish("scheme", &data);
389            peer::broadcast("scheme", &data);
390            respond(out, msg, json!({"ok": true, "scheme": s}));
391        } else {
392            respond(out, msg, json!({"ok": false, "err": "bad_scheme"}));
393        }
394    }
395}