Skip to main content

purple_ssh/
tunnel_live.rs

1//! Live-data layer for active SSH tunnels. Two worker types:
2//!
3//! - One stderr-parser thread per tunnel that reads `ssh -v` output and
4//!   emits `ChannelEvent`s when ssh logs `debug1: channel N: new` and
5//!   `debug1: channel N: free` lines. Also keeps the last few stderr
6//!   lines so `poll_tunnels()` can surface a meaningful exit reason.
7//!
8//! - One shared lsof poller that runs `lsof -iTCP -P -n` every 2s,
9//!   filters by the active tunnel bind ports and emits `LsofMessage`
10//!   snapshots with connected clients and port conflicts.
11//!
12//! All communication is via `std::sync::mpsc` channels. No tokio.
13
14use std::collections::{HashMap, VecDeque};
15use std::io::{BufRead, BufReader};
16#[cfg(target_os = "macos")]
17use std::process::Stdio;
18use std::process::{ChildStderr, Command};
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::mpsc::Sender;
21use std::sync::{Arc, Mutex};
22use std::thread::{self, JoinHandle};
23use std::time::{Duration, Instant};
24
25/// Maximum number of channel events kept in the per-tunnel ringbuffer.
26pub const MAX_EVENTS: usize = 50;
27/// Bucket width of the rolling activity history, in seconds. Two seconds
28/// matches the lsof poll interval, so the sparkline is fed at the same
29/// rate it is sampled. Each bucket carries the peak concurrent client
30/// count observed within its window.
31pub const BUCKET_SECS: u64 = 2;
32
33/// Number of buckets in the rolling activity history. Combined with
34/// `BUCKET_SECS` this is `HISTORY_BUCKETS * BUCKET_SECS = 300s ≈ 5 min`
35/// of history. Wide window so sparse traffic still has visible content
36/// across the chart. Bucket `HISTORY_BUCKETS - 1` is "now".
37pub const HISTORY_BUCKETS: usize = 150;
38/// Number of stderr lines kept per tunnel for exit-reason display.
39pub const STDERR_BUFFER_LINES: usize = 10;
40/// Maximum number of clients reported per bind port. The card only
41/// renders a handful and lsof can list hundreds of peers on a busy
42/// load balancer, so we cap to keep allocation bounded.
43pub const MAX_CLIENTS_PER_PORT: usize = 64;
44
45/// Channel-open or -close event observed in `ssh -v` stderr.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ChannelEventKind {
48    Open,
49    Close,
50}
51
52/// What kind of ssh channel this event belongs to. The bracketed token
53/// after `channel N: new` in `ssh -v` output ("port listener",
54/// "direct-tcpip", "dynamic-tcpip" etc.) maps onto these variants.
55///
56/// Only `Direct`, `Forwarded` and `Dynamic` represent end-user traffic;
57/// the rest are ssh-internal bookkeeping (the listener that binds the
58/// local port, mux master channels, agent forwarding) and are filtered
59/// out before rendering the EVENTS card.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum ChannelKind {
62    /// `direct-tcpip` — outgoing client connection through a LocalForward.
63    Direct,
64    /// `forwarded-tcpip` — incoming client connection arriving via a
65    /// RemoteForward.
66    Forwarded,
67    /// `dynamic-tcpip` — single SOCKS request through a DynamicForward.
68    Dynamic,
69    /// Anything else (`port listener`, `client-session`, `mux-master`,
70    /// `auth-agent@openssh.com`, `x11`, …).
71    Other,
72}
73
74impl ChannelKind {
75    /// Parse the bracketed token from a `channel N: new [<type>]` line.
76    pub fn from_bracket(token: &str) -> Self {
77        match token {
78            "direct-tcpip" => Self::Direct,
79            "forwarded-tcpip" => Self::Forwarded,
80            "dynamic-tcpip" => Self::Dynamic,
81            _ => Self::Other,
82        }
83    }
84
85    /// True if this kind represents end-user traffic that should appear
86    /// in the EVENTS card.
87    pub fn is_user_visible(self) -> bool {
88        matches!(self, Self::Direct | Self::Forwarded | Self::Dynamic)
89    }
90}
91
92#[derive(Debug, Clone)]
93pub struct ChannelEvent {
94    pub at: Instant,
95    pub channel_id: u32,
96    pub kind: ChannelEventKind,
97    /// Bracketed channel type from the `new` line, or the kind that
98    /// was recorded when the channel opened (for `Close` events).
99    /// `None` if neither side could be parsed (defensive — the
100    /// regression suite expects every event to have a kind).
101    pub channel_kind: Option<ChannelKind>,
102    /// For `Close` events, the matching open time (so the UI can render
103    /// the channel duration). `None` if the open was missed.
104    pub opened_at: Option<Instant>,
105}
106
107/// Number of buckets in the per-client throughput history. The renderer
108/// uses `VIZ_TICK_MS` cadence (one shift per 100ms), so 12 cells covers
109/// the most recent ~1.2 seconds. The latest `current_rx_bps + current_tx_bps`
110/// reading is pushed into the rightmost cell every tick, which gives the
111/// braille wave continuous leftward motion at terminal frame rate even
112/// when underlying lsof samples land only every few seconds.
113pub const PEER_VIZ_BUCKETS: usize = 12;
114
115/// One peer observed by the lsof poller as connected to a forwarded port.
116#[derive(Debug, Clone)]
117pub struct ClientPeer {
118    pub src: String,
119    pub process: String,
120    /// Owner pid of the client socket. Surfaced in the CLIENTS card so
121    /// users can correlate a connection with a local process.
122    pub pid: u32,
123    pub since: Instant,
124    /// On macOS, the user-facing app that "owns" this socket according
125    /// to the kernel-tracked responsible-pid. Lets the CLIENTS card show
126    /// `Safari` for a `WebKit.Networking` XPC daemon, or `Ghostty` for
127    /// a `psql` started from a Ghostty terminal. `None` when responsible
128    /// pid resolution is unavailable, equals self, or the lookup failed.
129    pub responsible_app: Option<String>,
130    /// Most-recent rx/tx bytes-per-second sample, derived by diffing
131    /// per-socket cumulative byte counters between lsof polls. Zero
132    /// until a second sample arrives. macOS uses a per-pid fallback
133    /// when per-socket counters are not available.
134    pub current_rx_bps: u64,
135    pub current_tx_bps: u64,
136    /// Cumulative byte counters at the most recent sample. Used to diff
137    /// against the next sample. `None` until the first sample arrives.
138    pub bytes_rcvd: Option<u64>,
139    pub bytes_sent: Option<u64>,
140    /// Time of the most recent sample. `None` until the first sample.
141    pub last_sample_at: Option<Instant>,
142}
143
144/// Another process bound to the same port as our tunnel. Detected when
145/// lsof shows a LISTEN row on a tunnel bind port owned by a pid that
146/// is not our tunnel pid. Reserved for future port-conflict surfacing
147/// (see `app::tunnel_state::TunnelsState::conflicts`); the minimal
148/// option-A detail panel does not show conflicts inline.
149#[derive(Debug, Clone)]
150#[allow(dead_code)]
151pub struct PortConflict {
152    pub port: u16,
153    pub process: String,
154    pub pid: u32,
155}
156
157/// Per-tunnel live state. Lives on `ActiveTunnel` so a HashMap remove()
158/// drops the state and joins the parser thread (no zijkanaal map that
159/// could leak after `tunnels.active.remove()`).
160pub struct TunnelLiveState {
161    pub events: VecDeque<ChannelEvent>,
162    pub opens_history: [u8; HISTORY_BUCKETS],
163    /// Wall-clock time of the most recent rotate. Used by `rotate_if_due`
164    /// to advance the history buckets one slot per elapsed minute.
165    pub history_last_rotate: Instant,
166    pub peak_concurrent: u32,
167    pub total_opens: u32,
168    pub last_event_at: Option<Instant>,
169    pub active_channels: u32,
170    /// Map of currently-open channel id -> open time + kind, used to
171    /// compute duration and restore the kind when a `Close` arrives.
172    pub channel_open: HashMap<u32, (Instant, ChannelKind)>,
173    /// Filled by `poll_tunnels()` when the child exits unexpectedly.
174    pub last_exit: Option<(i32, String)>,
175    /// Last few stderr lines, written by the parser thread, read by
176    /// `poll_tunnels()` on exit to compose `last_exit`.
177    pub stderr_buffer: Arc<Mutex<VecDeque<String>>>,
178    /// Joined when the `ActiveTunnel` is dropped.
179    pub parser_thread: Option<JoinHandle<()>>,
180    /// Set to true on `ActiveTunnel` drop so the parser thread can exit
181    /// promptly even if stderr is still readable. The pipe close from
182    /// `child.kill()` is the primary signal; this is a belt-and-braces.
183    pub parser_stop: Arc<AtomicBool>,
184    /// Set by the parser thread once ssh says the target let it in.
185    pub authenticated: Arc<AtomicBool>,
186    /// Set by the parser thread when ssh gave up on authentication.
187    pub auth_refused: Arc<AtomicBool>,
188    /// Set by the parser thread once it read stderr to the end, so an exit
189    /// is judged on every line ssh wrote.
190    pub drained: Arc<AtomicBool>,
191    /// When `poll` first saw the child gone.
192    pub exited_at: Option<Instant>,
193
194    /// Rolling history of bytes per second received (downstream from
195    /// remote to laptop). Same bucket layout as `opens_history`.
196    pub rx_history: [u64; HISTORY_BUCKETS],
197    /// Rolling history of bytes per second sent (upstream from laptop
198    /// to remote).
199    pub tx_history: [u64; HISTORY_BUCKETS],
200    /// Aggregated bytes-per-second across every connected client. Set
201    /// from the per-peer lsof samples in `TunnelState::poll` so this
202    /// value matches what the roster's per-peer rows report.
203    pub current_rx_bps: u64,
204    pub current_tx_bps: u64,
205    /// Peak observed since tunnel start. Tracks the running max of
206    /// `current_rx_bps` / `current_tx_bps` across polls.
207    pub peak_rx_bps: u64,
208    pub peak_tx_bps: u64,
209    /// Wall-clock of the most recent throughput aggregation that saw
210    /// at least one sampled peer. `None` until the first sample
211    /// arrives — UI shows `sampling…` in that gap.
212    pub last_throughput_at: Option<Instant>,
213}
214
215impl TunnelLiveState {
216    pub fn new(started_at: Instant) -> Self {
217        Self {
218            events: VecDeque::with_capacity(MAX_EVENTS),
219            opens_history: [0u8; HISTORY_BUCKETS],
220            history_last_rotate: started_at,
221            peak_concurrent: 0,
222            total_opens: 0,
223            last_event_at: None,
224            active_channels: 0,
225            channel_open: HashMap::new(),
226            last_exit: None,
227            stderr_buffer: Arc::new(Mutex::new(VecDeque::with_capacity(STDERR_BUFFER_LINES))),
228            parser_thread: None,
229            parser_stop: Arc::new(AtomicBool::new(false)),
230            authenticated: Arc::new(AtomicBool::new(false)),
231            auth_refused: Arc::new(AtomicBool::new(false)),
232            drained: Arc::new(AtomicBool::new(false)),
233            exited_at: None,
234            rx_history: [0u64; HISTORY_BUCKETS],
235            tx_history: [0u64; HISTORY_BUCKETS],
236            current_rx_bps: 0,
237            current_tx_bps: 0,
238            peak_rx_bps: 0,
239            peak_tx_bps: 0,
240            last_throughput_at: None,
241        }
242    }
243
244    /// Record an open or close event from the stderr parser thread.
245    /// Updates counters and the bounded ringbuffer. The rolling
246    /// `opens_history` is fed by `sample_activity` rather than by
247    /// individual events: the sparkline tracks ongoing concurrency, not
248    /// just channel-open bursts. End-user channels (Direct, Forwarded,
249    /// Dynamic) bump `total_opens` and `peak_concurrent`; ssh-internal
250    /// listeners and master channels stay in the ringbuffer for
251    /// diagnostics only.
252    pub fn record_event(&mut self, mut event: ChannelEvent) {
253        self.rotate_if_due(event.at);
254        match event.kind {
255            ChannelEventKind::Open => {
256                let kind = event.channel_kind.unwrap_or(ChannelKind::Other);
257                if kind.is_user_visible() {
258                    self.total_opens = self.total_opens.saturating_add(1);
259                    self.active_channels = self.active_channels.saturating_add(1);
260                    self.peak_concurrent = self.peak_concurrent.max(self.active_channels);
261                }
262                self.channel_open.insert(event.channel_id, (event.at, kind));
263            }
264            ChannelEventKind::Close => {
265                if let Some((opened_at, kind)) = self.channel_open.remove(&event.channel_id) {
266                    event.opened_at = Some(opened_at);
267                    event.channel_kind = Some(kind);
268                    if kind.is_user_visible() {
269                        self.active_channels = self.active_channels.saturating_sub(1);
270                    }
271                }
272            }
273        }
274        self.last_event_at = Some(event.at);
275        if self.events.len() == MAX_EVENTS {
276            self.events.pop_front();
277        }
278        self.events.push_back(event);
279        log::debug!(
280            "[purple] Tunnel live event: total_opens={} active={} peak={}",
281            self.total_opens,
282            self.active_channels,
283            self.peak_concurrent
284        );
285    }
286
287    /// Advance the rolling history if at least one bucket-width has
288    /// elapsed since the previous rotate. Per-elapsed-bucket we shift
289    /// left by one and push a fresh `0` at the right (now) edge.
290    /// Rotates `opens_history`, `rx_history` and `tx_history` together
291    /// so the three sparklines stay aligned in the UI.
292    pub fn rotate_if_due(&mut self, now: Instant) {
293        let elapsed = now.saturating_duration_since(self.history_last_rotate);
294        let ticks = elapsed.as_secs() / BUCKET_SECS;
295        if ticks == 0 {
296            return;
297        }
298        let shift = (ticks as usize).min(HISTORY_BUCKETS);
299        if shift >= HISTORY_BUCKETS {
300            self.opens_history.fill(0);
301            self.rx_history.fill(0);
302            self.tx_history.fill(0);
303        } else {
304            self.opens_history.rotate_left(shift);
305            for slot in self.opens_history.iter_mut().rev().take(shift) {
306                *slot = 0;
307            }
308            self.rx_history.rotate_left(shift);
309            for slot in self.rx_history.iter_mut().rev().take(shift) {
310                *slot = 0;
311            }
312            self.tx_history.rotate_left(shift);
313            for slot in self.tx_history.iter_mut().rev().take(shift) {
314                *slot = 0;
315            }
316        }
317        self.history_last_rotate += Duration::from_secs(ticks * BUCKET_SECS);
318    }
319
320    /// Write the peak concurrent client count for the current bucket.
321    /// Called once per `TunnelState::poll` after the lsof snapshot has
322    /// been drained, with `concurrent` = `max(lsof_clients_for_alias,
323    /// active_channels)`. Uses `max` rather than `+=` so repeated polls
324    /// inside one bucket converge on the bucket's peak rather than
325    /// double-counting.
326    pub fn sample_activity(&mut self, concurrent: u32) {
327        let sample = u8::try_from(concurrent).unwrap_or(u8::MAX);
328        if let Some(last) = self.opens_history.last_mut() {
329            *last = (*last).max(sample);
330        }
331    }
332}
333
334/// Message sent from a per-tunnel parser thread to the main loop drain.
335#[derive(Debug, Clone)]
336pub struct ParserMessage {
337    pub alias: String,
338    pub event: ChannelEvent,
339}
340
341/// Snapshot emitted by the lsof poller every poll cycle. Keys are the
342/// tunnel bind ports.
343#[derive(Debug, Clone)]
344pub struct LsofMessage {
345    pub at: Instant,
346    pub clients: HashMap<u16, Vec<ClientPeer>>,
347    pub conflicts: HashMap<u16, PortConflict>,
348}
349
350impl LsofMessage {
351    pub fn empty(at: Instant) -> Self {
352        Self {
353            at,
354            clients: HashMap::new(),
355            conflicts: HashMap::new(),
356        }
357    }
358}
359
360/// Public wrapper around an in-flight lsof poller thread.
361pub struct LsofPollerHandle {
362    pub stop: Arc<AtomicBool>,
363    pub bind_ports: Arc<Mutex<Vec<(String, u16, u32)>>>,
364    pub thread: Option<JoinHandle<()>>,
365}
366
367impl LsofPollerHandle {
368    pub fn shutdown(&mut self) {
369        self.stop.store(true, Ordering::Relaxed);
370        if let Some(handle) = self.thread.take() {
371            let _ = handle.join();
372        }
373    }
374}
375
376/// What the parser thread watches for while ssh authenticates.
377#[derive(Debug, Clone, Default)]
378pub struct AuthWatch {
379    /// Host ssh names on the target's `Authenticated to` line. `None` takes
380    /// any such line, which only fits a tunnel without a jump host: there a
381    /// bastion authenticates first.
382    pub target_host: Option<String>,
383    /// PINs the run carries, so a refusal of one can be recorded.
384    pub security_key: crate::security_key::SecurityKeyRun,
385    pub paths: Option<crate::runtime::env::Paths>,
386}
387
388impl AuthWatch {
389    /// True when `line` says the target let the tunnel in.
390    pub fn is_target_authenticated(&self, line: &str) -> bool {
391        let Some(host) = parse_authenticated_host(line) else {
392            return false;
393        };
394        self.target_host
395            .as_deref()
396            .is_none_or(|target| target.eq_ignore_ascii_case(host))
397    }
398}
399
400/// The host on an `Authenticated to <host> (...) using "<method>".` line,
401/// which ssh prints with `-v` once a host accepted it.
402pub fn parse_authenticated_host(line: &str) -> Option<&str> {
403    let rest = line.trim_start().strip_prefix("Authenticated to ")?;
404    let (host, _) = rest.split_once(" (")?;
405    (!host.is_empty()).then_some(host)
406}
407
408/// Shared flags the parser thread raises.
409#[derive(Clone)]
410pub struct ParserFlags {
411    pub stop: Arc<AtomicBool>,
412    pub authenticated: Arc<AtomicBool>,
413    pub auth_refused: Arc<AtomicBool>,
414    pub drained: Arc<AtomicBool>,
415}
416
417/// Spawn the per-tunnel stderr parser thread. Reads the child's stderr
418/// line-by-line, captures channel events into `tx` and stores the last
419/// few raw lines in `stderr_buffer` for exit-reason display. It also
420/// raises `authenticated` once the target let ssh in and records a
421/// security key refusing a PIN the run carried.
422pub fn spawn_parser_thread(
423    stderr: ChildStderr,
424    alias: String,
425    tx: Sender<ParserMessage>,
426    stderr_buffer: Arc<Mutex<VecDeque<String>>>,
427    flags: ParserFlags,
428    watch: AuthWatch,
429) -> JoinHandle<()> {
430    thread::Builder::new()
431        .name(format!("purple-tunnel-parser-{alias}"))
432        .spawn(move || {
433            let reader = BufReader::new(stderr);
434            for line in reader.lines() {
435                if flags.stop.load(Ordering::Relaxed) {
436                    break;
437                }
438                let Ok(line) = line else { break };
439                if let Ok(mut buf) = stderr_buffer.lock() {
440                    if buf.len() == STDERR_BUFFER_LINES {
441                        buf.pop_front();
442                    }
443                    buf.push_back(line.clone());
444                }
445                if watch.is_target_authenticated(&line) {
446                    flags.authenticated.store(true, Ordering::Relaxed);
447                    log::debug!("[purple] Tunnel authenticated: alias={alias}");
448                }
449                if crate::connection::auth_refused(&line) {
450                    flags.auth_refused.store(true, Ordering::Relaxed);
451                }
452                if let Some(failure) = crate::security_key::parse_sign_failure(&line)
453                    && watch.security_key.carries(&failure.key_path)
454                {
455                    crate::security_key::note_refusal(watch.paths.as_ref(), &failure);
456                }
457                if let Some(event) = parse_channel_line(&line) {
458                    let msg = ParserMessage {
459                        alias: alias.clone(),
460                        event,
461                    };
462                    if tx.send(msg).is_err() {
463                        break;
464                    }
465                }
466            }
467            flags.drained.store(true, Ordering::Relaxed);
468            log::debug!("[purple] Tunnel parser thread exit: alias={alias}");
469        })
470        .expect("spawn purple-tunnel-parser thread")
471}
472
473/// Parse one stderr line. Recognises both OpenSSH log formats:
474///
475/// Modern (8.x+, 9.x, 10.x): `channel N: new <ctype> [<rname>] ...`.
476/// ctype sits BEFORE the brackets and the brackets carry the remote
477/// name; the `(inactive timeout: T)` suffix added in 9.x is ignored.
478///
479/// Legacy/test: `channel N: new [<ctype>]`. Kept for backwards
480/// compatibility with test fixtures and any patched ssh build that
481/// emits the older shape.
482///
483/// Both formats also produce `channel N: free: ...` for Close.
484/// All other lines return `None`.
485pub fn parse_channel_line(line: &str) -> Option<ChannelEvent> {
486    let trimmed = line.trim_start();
487    let rest = trimmed.strip_prefix("debug1: channel ")?;
488    let (id_str, after) = rest.split_once(':')?;
489    let channel_id: u32 = id_str.trim().parse().ok()?;
490    let after = after.trim_start();
491    let (kind, channel_kind) = if let Some(after_new) = after.strip_prefix("new") {
492        let after_new = after_new.trim_start();
493        let ctype = if let Some(rest) = after_new.strip_prefix('[') {
494            // Legacy `new [<ctype>]` — ctype is inside the brackets.
495            rest.split_once(']').map(|(t, _)| t.trim().to_string())
496        } else {
497            // Modern `new <ctype> [<rname>] (…)` — first whitespace-
498            // delimited token is the ctype; everything after is the
499            // remote name plus optional parenthesised attributes.
500            after_new
501                .split_whitespace()
502                .next()
503                .map(|s| s.to_string())
504                .filter(|s| !s.is_empty())
505        };
506        let chan_kind = ctype.as_deref().map(ChannelKind::from_bracket);
507        (ChannelEventKind::Open, chan_kind)
508    } else if after.starts_with("free") {
509        (ChannelEventKind::Close, None)
510    } else {
511        return None;
512    };
513    Some(ChannelEvent {
514        at: Instant::now(),
515        channel_id,
516        kind,
517        channel_kind,
518        opened_at: None,
519    })
520}
521
522/// Spawn the shared lsof poller. Polls `lsof -iTCP -P -n` every 2s,
523/// filters by the bind ports passed via `bind_ports`, and emits a
524/// `LsofMessage` per poll. macOS + Linux only — purple is unix-only.
525#[cfg(any(target_os = "macos", target_os = "linux"))]
526pub fn spawn_lsof_poller(
527    bind_ports: Arc<Mutex<Vec<(String, u16, u32)>>>,
528    tx: Sender<LsofMessage>,
529    stop: Arc<AtomicBool>,
530) -> JoinHandle<()> {
531    thread::Builder::new()
532        .name("purple-tunnel-lsof".into())
533        .spawn(move || {
534            // Track first-seen times per (port, src) so the CLIENTS card
535            // can render a real "age" value across polls.
536            let mut first_seen: HashMap<(u16, String), Instant> = HashMap::new();
537            // Cache responsible-pid lookups across polls so the same client
538            // does not pay the FFI cost every 2s tick.
539            let mut responsible_cache = ResponsibleAppCache::default();
540            // Per-peer throughput history. Key matches `first_seen` so a
541            // peer that goes away and comes back resets cleanly.
542            let mut peer_state: HashMap<(u16, String), PeerSampleCache> = HashMap::new();
543            while !stop.load(Ordering::Relaxed) {
544                let ports: Vec<(String, u16, u32)> = match bind_ports.lock() {
545                    Ok(g) => g.clone(),
546                    Err(p) => p.into_inner().clone(),
547                };
548                if ports.is_empty() {
549                    thread::sleep(Duration::from_millis(500));
550                    continue;
551                }
552                let now = Instant::now();
553                let mut msg = run_lsof_once(&ports, &mut first_seen, now);
554                annotate_responsible_apps(&mut msg, &mut responsible_cache);
555                annotate_peer_throughput(&mut msg, &mut peer_state, now);
556                if tx.send(msg).is_err() {
557                    break;
558                }
559                for _ in 0..20 {
560                    if stop.load(Ordering::Relaxed) {
561                        break;
562                    }
563                    thread::sleep(Duration::from_millis(100));
564                }
565            }
566            log::debug!("[purple] Tunnel lsof poller thread exit");
567        })
568        .expect("spawn purple-tunnel-lsof thread")
569}
570
571/// Stub lsof poller for non-unix builds. Purple does not officially
572/// support Windows but the cfg keeps the codebase compilable.
573#[cfg(not(any(target_os = "macos", target_os = "linux")))]
574pub fn spawn_lsof_poller(
575    _bind_ports: Arc<Mutex<Vec<(String, u16, u32)>>>,
576    _tx: Sender<LsofMessage>,
577    stop: Arc<AtomicBool>,
578) -> JoinHandle<()> {
579    thread::spawn(move || {
580        while !stop.load(Ordering::Relaxed) {
581            thread::sleep(Duration::from_millis(500));
582        }
583    })
584}
585
586// macOS-only FFI to the kernel-tracked "responsible pid" for a process.
587// `responsibility_get_pid_responsible_for_pid` is exported by libsystem
588// (libquarantine) and used by Activity Monitor and the TCC subsystem.
589// Stable across macOS releases and reachable without root or special
590// entitlements for processes owned by the calling user. Returns the
591// pid that "speaks for" `pid` when the kernel attributes resource use,
592// sandbox decisions or TCC prompts. That mapping is what lets us label
593// a `WebKit.Networking` daemon as `Safari`, or a shell/`psql` started
594// inside Ghostty as `Ghostty`. Returns `0` or a negative value when no
595// responsibility chain exists (most system daemons), and `pid` itself
596// when the process is its own responsible (terminal apps, foreground
597// GUI apps).
598#[cfg(target_os = "macos")]
599unsafe extern "C" {
600    fn responsibility_get_pid_responsible_for_pid(pid: libc::pid_t) -> libc::pid_t;
601}
602
603/// Resolve a client pid to a user-facing app name via the macOS
604/// responsibility API. Returns `None` when:
605///   * the lookup failed (eg. process exited or kernel returned <= 0),
606///   * the responsible pid equals the client pid (self-responsible —
607///     the process name from lsof is already the right label), or
608///   * the responsible pid maps to the same name as the client process
609///     (no extra signal to add).
610#[cfg(target_os = "macos")]
611fn lookup_responsible_app(pid: u32, client_process: &str) -> Option<String> {
612    // SAFETY: the FFI takes a pid_t by value and returns one. No memory
613    // is exchanged. Calling with an exited pid is well-defined: the
614    // kernel returns -1 / 0 which we treat as "no responsible".
615    let rpid = unsafe { responsibility_get_pid_responsible_for_pid(pid as libc::pid_t) };
616    if rpid <= 0 {
617        return None;
618    }
619    if rpid as u32 == pid {
620        return None;
621    }
622    let name = process_name(rpid as u32)?;
623    if name.eq_ignore_ascii_case(client_process) {
624        return None;
625    }
626    Some(name)
627}
628
629/// Look up the bare process name for a pid via libproc's `proc_name`,
630/// which gives the truncated `comm` value (matches what `ps -o comm=`
631/// returns without the full executable path). Used to label the
632/// responsible-pid in the CLIENTS card.
633#[cfg(target_os = "macos")]
634fn process_name(pid: u32) -> Option<String> {
635    unsafe extern "C" {
636        fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int;
637    }
638    let mut buf = [0u8; 256];
639    // SAFETY: `buf` is a valid writeable region of `buf.len()` bytes.
640    // `proc_name` writes a NUL-terminated string into it and returns
641    // the byte count. Negative or zero means failure / unknown pid.
642    let n = unsafe {
643        proc_name(
644            pid as libc::c_int,
645            buf.as_mut_ptr().cast(),
646            buf.len() as u32,
647        )
648    };
649    if n <= 0 {
650        return None;
651    }
652    let bytes = &buf[..(n as usize).min(buf.len())];
653    let s = std::str::from_utf8(bytes).ok()?.trim_end_matches('\0');
654    if s.is_empty() {
655        None
656    } else {
657        Some(beautify_process(s))
658    }
659}
660
661/// Linux: walk to the process's session leader via `/proc/PID/stat`
662/// and use its comm as the user-facing app label. The session leader
663/// is the process whose pid equals the session id; for shell-spawned
664/// tools that leader is typically the terminal (`ghostty`, `konsole`,
665/// `gnome-terminal-`); for GUI clients it is the app itself
666/// (`dbeaver`, `firefox`, `code`). Generic — any process ancestry
667/// rooted in a user session works without a hardcoded app list.
668///
669/// Returns `None` when the process exited mid-poll, when the session
670/// leader equals the client pid (already its own app), or when the
671/// leader's comm matches the client process name (no extra signal).
672#[cfg(target_os = "linux")]
673fn lookup_responsible_app(pid: u32, client_process: &str) -> Option<String> {
674    let session_id = read_session_leader(pid)?;
675    if session_id == pid {
676        return None;
677    }
678    let comm = std::fs::read_to_string(format!("/proc/{}/comm", session_id)).ok()?;
679    let name = comm.trim();
680    if name.is_empty() || name.eq_ignore_ascii_case(client_process) {
681        return None;
682    }
683    Some(beautify_process(name))
684}
685
686/// Parse `/proc/PID/stat` and return field 5 (the session id). The
687/// `comm` field is parenthesized and may contain spaces, so we slice
688/// at the LAST `)` and tokenize what follows: `state ppid pgid sid ...`
689/// → field index 3 of the post-comm slice is the session id.
690#[cfg(target_os = "linux")]
691fn read_session_leader(pid: u32) -> Option<u32> {
692    let stat = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?;
693    let close = stat.rfind(')')?;
694    let after = stat[close + 1..].trim();
695    let fields: Vec<&str> = after.split_whitespace().collect();
696    fields.get(3).and_then(|s| s.parse().ok())
697}
698
699#[cfg(not(any(target_os = "macos", target_os = "linux")))]
700fn lookup_responsible_app(_pid: u32, _client_process: &str) -> Option<String> {
701    None
702}
703
704/// Cache of `pid -> Option<responsible_app>` so the lsof poller does not
705/// re-resolve the same connection on every 2s tick. Keyed on pid; a
706/// stale entry is at worst a stale label until the pid is reused, which
707/// is acceptable for an observability surface. Size-bounded by the
708/// per-port client cap times a generous fan-out — in practice the lsof
709/// pid set turns over slowly.
710#[derive(Default)]
711struct ResponsibleAppCache {
712    map: HashMap<u32, Option<String>>,
713}
714
715impl ResponsibleAppCache {
716    fn resolve(&mut self, pid: u32, client_process: &str) -> Option<String> {
717        if let Some(cached) = self.map.get(&pid) {
718            return cached.clone();
719        }
720        let resolved = lookup_responsible_app(pid, client_process);
721        self.map.insert(pid, resolved.clone());
722        resolved
723    }
724
725    /// Drop entries for pids no longer present in `live`, so the cache
726    /// cannot grow unbounded across many short-lived clients.
727    fn retain_pids(&mut self, live: &std::collections::HashSet<u32>) {
728        self.map.retain(|pid, _| live.contains(pid));
729    }
730}
731
732/// Walk every client peer in `msg` and fill in `responsible_app` from
733/// the macOS responsibility API. Cache hits are O(1); cache misses pay
734/// one FFI plus one libproc call per pid. After resolution the cache is
735/// trimmed to the still-live set of pids so it cannot grow unbounded.
736fn annotate_responsible_apps(msg: &mut LsofMessage, cache: &mut ResponsibleAppCache) {
737    let mut live: std::collections::HashSet<u32> = std::collections::HashSet::new();
738    for peers in msg.clients.values_mut() {
739        for peer in peers.iter_mut() {
740            live.insert(peer.pid);
741            peer.responsible_app = cache.resolve(peer.pid, &peer.process);
742        }
743    }
744    cache.retain_pids(&live);
745}
746
747/// Per-peer sampling state retained across lsof polls. Holds the last
748/// observed cumulative byte counters so the next poll can derive a
749/// bytes-per-second rate. The braille sparkline history is owned by
750/// `TunnelState::peer_viz` on the main thread (ticked at 100ms).
751#[derive(Debug, Clone, Default)]
752struct PeerSampleCache {
753    last_rcvd: u64,
754    last_sent: u64,
755    last_at: Option<Instant>,
756}
757
758/// Per-peer sample shape. Linux returns cumulative byte counters that
759/// the diff path turns into a rate. macOS returns per-pid bps deltas
760/// directly because the underlying nettop call already emits a 1s
761/// delta sample. The annotator picks the right path per peer.
762#[derive(Debug, Default)]
763struct PerPeerSamples {
764    /// Cumulative byte counters per client local port from
765    /// `NETLINK_SOCK_DIAG` on Linux (see `crate::tcp_diag`). Netlink
766    /// returns per-socket data without pid attribution, so we key on
767    /// the local port alone and rely on the lsof-driven join in
768    /// `annotate_peer_throughput` to attribute counters back to the
769    /// owning client process.
770    per_local_port_cumulative: HashMap<u16, (u64, u64)>,
771    /// Bytes-per-second deltas per pid from `nettop` on macOS. The
772    /// kernel does not expose per-socket counters here, so per-pid is
773    /// the finest-grained truth available — adequate for the typical
774    /// "one app, one tunnel client" case.
775    per_pid_bps: HashMap<u32, (u64, u64)>,
776}
777
778/// Walk every client peer in `msg`, run a per-platform sampler, and
779/// fill in `current_rx_bps`, `current_tx_bps`, and the cumulative
780/// `bytes_rcvd`/`bytes_sent` where available. Stale cache entries are
781/// pruned to the still-live peer set so the map cannot grow unbounded.
782/// The braille sparkline history is owned by `TunnelState::peer_viz`
783/// on the main thread; this function only refreshes the bps readout
784/// that the main thread feeds into the rolling 100ms-tick history.
785fn annotate_peer_throughput(
786    msg: &mut LsofMessage,
787    cache: &mut HashMap<(u16, String), PeerSampleCache>,
788    now: Instant,
789) {
790    let samples = sample_peer_throughput();
791    let mut live: std::collections::HashSet<(u16, String)> = std::collections::HashSet::new();
792
793    for (port, peers) in msg.clients.iter_mut() {
794        for peer in peers.iter_mut() {
795            let key = (*port, peer.src.clone());
796            live.insert(key.clone());
797            let entry = cache.entry(key).or_default();
798            let src_port = src_port_from(&peer.src);
799
800            // Path A: Linux per-socket cumulative counters → diff against cache.
801            let cumulative =
802                src_port.and_then(|p| samples.per_local_port_cumulative.get(&p).copied());
803            if let Some((rcvd, sent)) = cumulative {
804                if let Some(prev_at) = entry.last_at {
805                    let dt = now.saturating_duration_since(prev_at).as_secs_f64();
806                    if dt > 0.0 {
807                        let rx_bps = ((rcvd.saturating_sub(entry.last_rcvd)) as f64 / dt) as u64;
808                        let tx_bps = ((sent.saturating_sub(entry.last_sent)) as f64 / dt) as u64;
809                        peer.current_rx_bps = rx_bps;
810                        peer.current_tx_bps = tx_bps;
811                    }
812                }
813                entry.last_rcvd = rcvd;
814                entry.last_sent = sent;
815                entry.last_at = Some(now);
816                peer.bytes_rcvd = Some(rcvd);
817                peer.bytes_sent = Some(sent);
818                peer.last_sample_at = Some(now);
819                continue;
820            }
821
822            // Path B: macOS per-pid bps directly — no diff math needed.
823            if let Some((rx_bps, tx_bps)) = samples.per_pid_bps.get(&peer.pid).copied() {
824                peer.current_rx_bps = rx_bps;
825                peer.current_tx_bps = tx_bps;
826                entry.last_at = Some(now);
827                peer.last_sample_at = Some(now);
828            }
829        }
830    }
831
832    cache.retain(|key, _| live.contains(key));
833}
834
835/// Run the per-platform peer-throughput sampler. On Linux this talks
836/// directly to the kernel via `NETLINK_SOCK_DIAG` (see `crate::tcp_diag`)
837/// and returns per-socket cumulative byte counters. On macOS it spawns
838/// a short `nettop` subprocess that yields one 1-second delta sample
839/// per pid. Returns an empty struct on unsupported platforms or when
840/// the underlying mechanism fails — the caller falls back to
841/// status-only display.
842fn sample_peer_throughput() -> PerPeerSamples {
843    let mut out = PerPeerSamples::default();
844    #[cfg(target_os = "linux")]
845    {
846        out.per_local_port_cumulative = crate::tcp_diag::sample_per_local_port();
847    }
848    #[cfg(target_os = "macos")]
849    {
850        out.per_pid_bps = sample_nettop_per_pid_macos();
851    }
852    out
853}
854
855/// Spawn `nettop -P -L 2 -d -x -s 1` to capture one 1-second delta
856/// sample per process and parse it into a `pid -> (rx_bps, tx_bps)`
857/// map. Blocking call: returns after ~1.2 seconds. Falls back to an
858/// empty map if the subprocess fails to spawn or parse.
859///
860/// `-d` enables delta mode (each emitted sample is the diff over the
861/// previous interval). `-L 2` exits after two samples. The first
862/// sample is cumulative-since-process-start (we drop it); the second
863/// is the 1-second delta we want.
864#[cfg(target_os = "macos")]
865fn sample_nettop_per_pid_macos() -> HashMap<u32, (u64, u64)> {
866    use std::time::Duration;
867    let output = Command::new("/usr/bin/nettop")
868        .args(["-P", "-d", "-x", "-s", "1", "-L", "2"])
869        .stdin(Stdio::null())
870        .stderr(Stdio::null())
871        .output();
872    let text = match output {
873        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
874        _ => return HashMap::new(),
875    };
876    // Discard the warmup-sample block (everything before the second
877    // header) and parse the delta block. `nettop` re-emits the
878    // `time,proc.pid,...` header before each new sample, so the second
879    // header line marks the start of the delta block.
880    let mut blocks: Vec<&str> = Vec::new();
881    let mut start = 0;
882    for (i, line) in text.lines().enumerate() {
883        if line.starts_with("time,") {
884            blocks.push(&text[start..]);
885            start = text.lines().take(i).map(|l| l.len() + 1).sum();
886        }
887    }
888    let _ = Duration::ZERO; // silence unused import on platforms with no Duration use
889    let delta_block = if text.matches("\ntime,").count() >= 1 {
890        // Find the second header line and slice from there.
891        let first = text.find("time,").unwrap_or(0);
892        let second_rel = text[first + 5..].find("\ntime,");
893        match second_rel {
894            Some(off) => &text[first + 5 + off + 1..],
895            None => &text[first..],
896        }
897    } else {
898        text.as_str()
899    };
900    let mut out: HashMap<u32, (u64, u64)> = HashMap::new();
901    for line in delta_block.lines() {
902        if let Some((pid, rx, tx)) = parse_nettop_csv_row_per_pid(line) {
903            // nettop -d sample interval is 1s (`-s 1`), so the bytes
904            // in this row are bytes-per-second already.
905            let entry = out.entry(pid).or_insert((0, 0));
906            entry.0 = entry.0.saturating_add(rx);
907            entry.1 = entry.1.saturating_add(tx);
908        }
909    }
910    out
911}
912
913/// Parse one `nettop -P -d -x` CSV row into `(pid, rx_bytes, tx_bytes)`.
914/// Returns `None` for header rows, blanks, or rows with too few
915/// columns. Mirrors `parse_nettop_csv_row` but does not filter on a
916/// specific pid. macOS-only because the only caller is the nettop
917/// sampler.
918#[cfg(target_os = "macos")]
919fn parse_nettop_csv_row_per_pid(line: &str) -> Option<(u32, u64, u64)> {
920    let line = line.trim();
921    if line.is_empty() || line.starts_with("time,") {
922        return None;
923    }
924    let cols: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
925    if cols.len() < 6 {
926        return None;
927    }
928    let proc_pid = cols[1];
929    let dot = proc_pid.rfind('.')?;
930    let pid_str = &proc_pid[dot + 1..];
931    let pid: u32 = pid_str.parse().ok()?;
932    let rx: u64 = cols[4].parse().ok()?;
933    let tx: u64 = cols[5].parse().ok()?;
934    Some((pid, rx, tx))
935}
936
937/// Extract the port portion of an `addr:port` socket string.
938fn src_port_from(src: &str) -> Option<u16> {
939    src.rsplit_once(':').and_then(|(_, port)| port.parse().ok())
940}
941
942#[cfg(any(target_os = "macos", target_os = "linux"))]
943fn run_lsof_once(
944    ports: &[(String, u16, u32)],
945    first_seen: &mut HashMap<(u16, String), Instant>,
946    now: Instant,
947) -> LsofMessage {
948    // `+c 0` widens the COMMAND column to the full process name.
949    // macOS defaults to 9 characters which collapses every Apple
950    // framework process down to `com.apple`; Linux defaults to 15.
951    // Neither is useful in the CLIENTS card.
952    let output = Command::new("lsof")
953        .args(["-iTCP", "-P", "-n", "-w", "+c", "0"])
954        .output();
955    let stdout = match output {
956        Ok(o) if o.status.success() || !o.stdout.is_empty() => o.stdout,
957        _ => return LsofMessage::empty(now),
958    };
959    let text = String::from_utf8_lossy(&stdout);
960    parse_lsof_output(&text, ports, first_seen, now)
961}
962
963/// Parse `lsof -iTCP -P -n` output into a `LsofMessage`. Public for tests.
964pub fn parse_lsof_output(
965    text: &str,
966    ports: &[(String, u16, u32)],
967    first_seen: &mut HashMap<(u16, String), Instant>,
968    now: Instant,
969) -> LsofMessage {
970    let mut clients: HashMap<u16, Vec<ClientPeer>> = HashMap::new();
971    let mut conflicts: HashMap<u16, PortConflict> = HashMap::new();
972    let bind_ports: Vec<u16> = ports.iter().map(|(_, p, _)| *p).collect();
973    let tunnel_pids: Vec<u32> = ports.iter().map(|(_, _, pid)| *pid).collect();
974
975    for line in text.lines().skip(1) {
976        let row = match parse_lsof_row(line) {
977            Some(r) => r,
978            None => continue,
979        };
980        // LISTEN rows owned by another process on a tunnel bind port → conflict.
981        if row.is_listen && bind_ports.contains(&row.local_port) && !tunnel_pids.contains(&row.pid)
982        {
983            conflicts
984                .entry(row.local_port)
985                .or_insert_with(|| PortConflict {
986                    port: row.local_port,
987                    process: row.command.clone(),
988                    pid: row.pid,
989                });
990            continue;
991        }
992        if row.is_listen {
993            continue;
994        }
995        // ESTABLISHED rows where the client process owns a socket whose
996        // remote port is one of our bind ports. The client process != ssh.
997        if let Some(remote_port) = row.remote_port
998            && bind_ports.contains(&remote_port)
999            && !tunnel_pids.contains(&row.pid)
1000        {
1001            let src = row.local_addr_port().unwrap_or_else(|| "?".to_string());
1002            let key = (remote_port, src.clone());
1003            let since = *first_seen.entry(key).or_insert(now);
1004            let entry = clients.entry(remote_port).or_default();
1005            if entry.len() >= MAX_CLIENTS_PER_PORT {
1006                continue;
1007            }
1008            entry.push(ClientPeer {
1009                src,
1010                process: beautify_process(&row.command),
1011                pid: row.pid,
1012                since,
1013                responsible_app: None,
1014                current_rx_bps: 0,
1015                current_tx_bps: 0,
1016                bytes_rcvd: None,
1017                bytes_sent: None,
1018                last_sample_at: None,
1019            });
1020        }
1021    }
1022    // Drop first-seen entries for sockets we no longer see, so their age
1023    // counters do not grow unbounded across reconnects.
1024    let live: std::collections::HashSet<(u16, String)> = clients
1025        .iter()
1026        .flat_map(|(port, peers)| peers.iter().map(move |p| (*port, p.src.clone())))
1027        .collect();
1028    first_seen.retain(|key, _| live.contains(key));
1029
1030    LsofMessage {
1031        at: now,
1032        clients,
1033        conflicts,
1034    }
1035}
1036
1037/// One row of `lsof -iTCP -P -n` output, fields we care about.
1038#[derive(Debug)]
1039struct LsofRow {
1040    command: String,
1041    pid: u32,
1042    is_listen: bool,
1043    local_addr: String,
1044    local_port: u16,
1045    remote_addr: Option<String>,
1046    remote_port: Option<u16>,
1047}
1048
1049impl LsofRow {
1050    fn local_addr_port(&self) -> Option<String> {
1051        if let (Some(addr), Some(port)) = (self.remote_addr.as_deref(), self.remote_port) {
1052            // The CLIENTS card shows the *peer* of our tunnel bind port,
1053            // i.e. the side connecting in. From the client process row,
1054            // remote = our loopback bind. So the peer to show is the row's
1055            // local end.
1056            let _ = (addr, port);
1057        }
1058        Some(format!("{}:{}", self.local_addr, self.local_port))
1059    }
1060}
1061
1062fn parse_lsof_row(line: &str) -> Option<LsofRow> {
1063    if line.trim().is_empty() {
1064        return None;
1065    }
1066    // lsof columns are whitespace-separated. Format:
1067    //   COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
1068    // NAME may contain spaces only inside parens (state). The address
1069    // tokens never contain spaces because we pass `-n` and `-P`.
1070    let mut fields = line.split_whitespace();
1071    let command = fields.next()?.to_string();
1072    let pid: u32 = fields.next()?.parse().ok()?;
1073    let _user = fields.next()?;
1074    let _fd = fields.next()?;
1075    let _ty = fields.next()?;
1076    let _dev = fields.next()?;
1077    let _size = fields.next()?;
1078    let _node = fields.next()?;
1079    let name = fields.next()?;
1080    let state = fields.next();
1081    if !name.contains(':') {
1082        return None;
1083    }
1084    let is_listen = matches!(state, Some(s) if s.contains("LISTEN"));
1085    let is_established = matches!(state, Some(s) if s.contains("ESTABLISHED"));
1086    if !is_listen && !is_established {
1087        return None;
1088    }
1089    let (local, remote) = match name.split_once("->") {
1090        Some((l, r)) => (l, Some(r)),
1091        None => (name, None),
1092    };
1093    let (local_addr, local_port) = split_addr_port(local)?;
1094    let (remote_addr, remote_port) = match remote {
1095        Some(r) => match split_addr_port(r) {
1096            Some((a, p)) => (Some(a), Some(p)),
1097            None => (None, None),
1098        },
1099        None => (None, None),
1100    };
1101    Some(LsofRow {
1102        command,
1103        pid,
1104        is_listen,
1105        local_addr,
1106        local_port,
1107        remote_addr,
1108        remote_port,
1109    })
1110}
1111
1112/// Trim noise that lsof reports for macOS Apple-framework processes
1113/// so the CLIENTS card shows useful identifiers. Examples:
1114///   `com.apple.WebKit.Networking` → `WebKit.Networking`
1115///   `com.apple.Safari`            → `Safari`
1116///   `curl`                        → `curl`
1117/// Other names pass through unchanged.
1118pub fn beautify_process(raw: &str) -> String {
1119    if let Some(rest) = raw.strip_prefix("com.apple.")
1120        && !rest.is_empty()
1121    {
1122        return rest.to_string();
1123    }
1124    raw.to_string()
1125}
1126
1127/// Split a `host:port` token. Handles bracketed IPv6: `[::1]:8080`.
1128fn split_addr_port(s: &str) -> Option<(String, u16)> {
1129    if let Some(rest) = s.strip_prefix('[') {
1130        let end = rest.find(']')?;
1131        let addr = &rest[..end];
1132        let after = &rest[end + 1..];
1133        let port_str = after.strip_prefix(':')?;
1134        let port: u16 = port_str.parse().ok()?;
1135        return Some((addr.to_string(), port));
1136    }
1137    let colon = s.rfind(':')?;
1138    let addr = &s[..colon];
1139    let port: u16 = s[colon + 1..].parse().ok()?;
1140    Some((addr.to_string(), port))
1141}
1142
1143// ---------------------------------------------------------------------------
1144// Snapshot path for demo and visual regression tests.
1145// ---------------------------------------------------------------------------
1146
1147/// Deterministic snapshot of a tunnel's live state. Used in `--demo` and
1148/// in visual regression tests so goldens do not depend on wall-clock or
1149/// background workers.
1150#[derive(Debug, Clone)]
1151#[allow(dead_code)]
1152pub struct TunnelLiveSnapshot {
1153    pub uptime_secs: u64,
1154    pub active_channels: u32,
1155    pub peak_concurrent: u32,
1156    pub total_opens: u32,
1157    pub idle_secs: u64,
1158    pub rx_history: [u64; HISTORY_BUCKETS],
1159    pub tx_history: [u64; HISTORY_BUCKETS],
1160    pub current_rx_bps: u64,
1161    pub current_tx_bps: u64,
1162    pub peak_rx_bps: u64,
1163    pub peak_tx_bps: u64,
1164    /// True when the throughput aggregator has produced at least one
1165    /// sample. UI shows `sampling…` until then.
1166    pub throughput_ready: bool,
1167    pub clients: Vec<DisplayClient>,
1168    pub events: Vec<DisplayEvent>,
1169    /// Currently-open channels at snapshot time. Each entry is
1170    /// `(channel_id, open_age_secs, kind)`. Drives the channel
1171    /// lifeline swimlane in the detail panel.
1172    pub currently_open: Vec<(u32, u64, ChannelKind)>,
1173    pub conflict: Option<PortConflict>,
1174    pub last_exit: Option<(i32, String)>,
1175}
1176
1177#[derive(Debug, Clone)]
1178pub struct DisplayClient {
1179    /// Source `addr:port` of the connected client. Reserved for the
1180    /// expanded clients-list view; the heartbeat-dial dashboard does
1181    /// not show it inline.
1182    #[allow(dead_code)]
1183    pub src: String,
1184    pub process: String,
1185    pub age_secs: u64,
1186    /// Connecting process PID. Reserved for the expanded clients-list
1187    /// view.
1188    #[allow(dead_code)]
1189    pub pid: u32,
1190    /// User-facing app that owns this socket on macOS. `None` if equal
1191    /// to `process` or unavailable. See `ClientPeer::responsible_app`.
1192    pub responsible_app: Option<String>,
1193    /// Per-client throughput readouts. Zero when no sample has been
1194    /// taken yet, or when the platform sampler does not produce
1195    /// per-client data.
1196    pub current_rx_bps: u64,
1197    pub current_tx_bps: u64,
1198    /// Rolling history of combined rx+tx bytes-per-second. Cell 0 is
1199    /// the oldest sample, the last cell is "now".
1200    pub viz_history: [u64; PEER_VIZ_BUCKETS],
1201    /// True after the per-client sampler has produced at least one
1202    /// sample. Lets the renderer distinguish "0 B/s, idle" from
1203    /// "no sampler available".
1204    pub throughput_ready: bool,
1205}
1206
1207#[derive(Debug, Clone)]
1208#[allow(dead_code)]
1209pub struct DisplayEvent {
1210    pub age_secs: u64,
1211    /// Surfaced through the runtime path for completeness; the
1212    /// renderer currently does not show channel ids because the
1213    /// numbers are noisy without context. Reserved for future use.
1214    #[allow(dead_code)]
1215    pub channel_id: u32,
1216    pub kind: ChannelEventKind,
1217    /// Filtered to `Direct | Forwarded | Dynamic` — internal channels
1218    /// are dropped by the UI before this struct is built, so this is
1219    /// always one of the user-visible kinds.
1220    pub channel_kind: ChannelKind,
1221    /// Open→close duration when known. Reserved for the expanded
1222    /// events-list view.
1223    #[allow(dead_code)]
1224    pub duration_secs: Option<u64>,
1225    /// Number of co-occurring events of the same kind/age that were
1226    /// folded into this row. `1` means "just one event"; higher values
1227    /// render as a `(3x)` suffix.
1228    pub count: u32,
1229}
1230
1231// ---------------------------------------------------------------------------
1232// Tests.
1233// ---------------------------------------------------------------------------
1234
1235#[cfg(test)]
1236mod tests {
1237    use super::*;
1238
1239    fn user_open(channel_id: u32, at: Instant) -> ChannelEvent {
1240        ChannelEvent {
1241            at,
1242            channel_id,
1243            kind: ChannelEventKind::Open,
1244            channel_kind: Some(ChannelKind::Direct),
1245            opened_at: None,
1246        }
1247    }
1248
1249    #[test]
1250    fn parse_channel_open_simple() {
1251        let ev = parse_channel_line("debug1: channel 0: new [direct-tcpip]").unwrap();
1252        assert_eq!(ev.channel_id, 0);
1253        assert_eq!(ev.kind, ChannelEventKind::Open);
1254        assert_eq!(ev.channel_kind, Some(ChannelKind::Direct));
1255    }
1256
1257    #[test]
1258    fn parse_channel_open_records_listener_kind() {
1259        let ev = parse_channel_line("debug1: channel 1: new [port listener]").unwrap();
1260        assert_eq!(ev.channel_kind, Some(ChannelKind::Other));
1261    }
1262
1263    #[test]
1264    fn parse_channel_open_records_dynamic_kind() {
1265        let ev = parse_channel_line("debug1: channel 4: new [dynamic-tcpip]").unwrap();
1266        assert_eq!(ev.channel_kind, Some(ChannelKind::Dynamic));
1267    }
1268
1269    #[test]
1270    fn parse_channel_close_simple() {
1271        let ev = parse_channel_line("debug1: channel 12: free: blah blah").unwrap();
1272        assert_eq!(ev.channel_id, 12);
1273        assert_eq!(ev.kind, ChannelEventKind::Close);
1274        // Close lines do not carry the kind; the recorder fills it in
1275        // from the open-channel map.
1276        assert_eq!(ev.channel_kind, None);
1277    }
1278
1279    #[test]
1280    fn parse_channel_with_leading_whitespace() {
1281        let ev = parse_channel_line("   debug1: channel 5: new [forwarded-tcpip]").unwrap();
1282        assert_eq!(ev.channel_id, 5);
1283        assert_eq!(ev.kind, ChannelEventKind::Open);
1284        assert_eq!(ev.channel_kind, Some(ChannelKind::Forwarded));
1285    }
1286
1287    #[test]
1288    fn parse_channel_modern_openssh_format_with_inactive_timeout() {
1289        // OpenSSH 10.x: `channel N: new <ctype> [<rname>] (inactive timeout: T)`.
1290        // The ctype sits BEFORE the brackets; the brackets hold the
1291        // remote endpoint name (which we ignore). Without this branch
1292        // the parser silently set channel_kind=None and counters never
1293        // advanced past zero on real-world OpenSSH ≥ 9.x.
1294        let ev = parse_channel_line(
1295            "debug1: channel 3: new direct-tcpip [127.0.0.1:54321] (inactive timeout: 0)",
1296        )
1297        .unwrap();
1298        assert_eq!(ev.channel_id, 3);
1299        assert_eq!(ev.kind, ChannelEventKind::Open);
1300        assert_eq!(ev.channel_kind, Some(ChannelKind::Direct));
1301    }
1302
1303    #[test]
1304    fn parse_channel_modern_openssh_format_forwarded() {
1305        let ev = parse_channel_line(
1306            "debug1: channel 7: new forwarded-tcpip [10.0.0.1:443] (inactive timeout: 0)",
1307        )
1308        .unwrap();
1309        assert_eq!(ev.channel_kind, Some(ChannelKind::Forwarded));
1310    }
1311
1312    #[test]
1313    fn parse_channel_modern_openssh_format_dynamic() {
1314        let ev = parse_channel_line("debug1: channel 9: new dynamic-tcpip [client] (timeout: 5)")
1315            .unwrap();
1316        assert_eq!(ev.channel_kind, Some(ChannelKind::Dynamic));
1317    }
1318
1319    #[test]
1320    fn parse_channel_modern_openssh_format_internal_listener_is_other() {
1321        // The local-port listener that ssh creates at startup uses
1322        // ctype "port-listener" or "listener" depending on version.
1323        // Either way it must NOT be promoted to a user-visible kind.
1324        let ev = parse_channel_line(
1325            "debug1: channel 0: new port-listener [::1:8080] (inactive timeout: 0)",
1326        )
1327        .unwrap();
1328        assert_eq!(ev.channel_kind, Some(ChannelKind::Other));
1329    }
1330
1331    #[test]
1332    fn parse_channel_unrelated_line_returns_none() {
1333        assert!(parse_channel_line("debug1: client_input_global_request").is_none());
1334        assert!(parse_channel_line("not even ssh output").is_none());
1335        assert!(parse_channel_line("debug1: channel abc: new").is_none());
1336        assert!(parse_channel_line("debug1: channel 1: confirm").is_none());
1337    }
1338
1339    #[test]
1340    fn reads_the_host_from_an_authenticated_line() {
1341        assert_eq!(
1342            parse_authenticated_host(
1343                "Authenticated to db.example.com ([10.0.0.5]:22) using \"publickey\"."
1344            ),
1345            Some("db.example.com")
1346        );
1347        assert_eq!(
1348            parse_authenticated_host("Authenticated to db (via proxy) using \"publickey\"."),
1349            Some("db")
1350        );
1351        assert_eq!(
1352            parse_authenticated_host("debug1: Authentication succeeded"),
1353            None
1354        );
1355    }
1356
1357    #[test]
1358    fn behind_a_jump_host_only_the_target_counts_as_in() {
1359        // ssh passes `-v` to the hop, which authenticates first on the
1360        // same pipe.
1361        let watch = AuthWatch {
1362            target_host: Some("db.example.com".into()),
1363            ..AuthWatch::default()
1364        };
1365        assert!(!watch.is_target_authenticated(
1366            "Authenticated to bastion ([10.0.0.1]:22) using \"publickey\"."
1367        ));
1368        assert!(watch.is_target_authenticated(
1369            "Authenticated to DB.example.com (via proxy) using \"publickey\"."
1370        ));
1371        let direct = AuthWatch::default();
1372        assert!(direct.is_target_authenticated(
1373            "Authenticated to anything ([1.2.3.4]:22) using \"publickey\"."
1374        ));
1375    }
1376
1377    #[cfg(unix)]
1378    #[test]
1379    fn the_parser_flags_authentication_and_records_a_refused_pin() {
1380        let dir = tempfile::tempdir().expect("tempdir");
1381        let paths = crate::runtime::env::Paths::new(dir.path());
1382        let mut child = Command::new("sh")
1383            .arg("-c")
1384            .arg(
1385                "printf '%s\\n' \
1386                 'sign_and_send_pubkey: signing failed for ED25519-SK \"/k\": incorrect passphrase supplied to decrypt private key' \
1387                 'Authenticated to web ([1.2.3.4]:22) using \"password\".' \
1388                 'ops@bastion: Permission denied (publickey).' >&2",
1389            )
1390            .stderr(std::process::Stdio::piped())
1391            .spawn()
1392            .expect("spawn sh");
1393        let stderr = child.stderr.take().expect("stderr");
1394        let flags = ParserFlags {
1395            stop: Arc::new(AtomicBool::new(false)),
1396            authenticated: Arc::new(AtomicBool::new(false)),
1397            auth_refused: Arc::new(AtomicBool::new(false)),
1398            drained: Arc::new(AtomicBool::new(false)),
1399        };
1400        let watch = AuthWatch {
1401            target_host: None,
1402            security_key: crate::security_key::SecurityKeyRun {
1403                pins: vec![crate::security_key::SecurityKeyPin {
1404                    key_path: "/k".into(),
1405                    pin: "0000".into(),
1406                }],
1407                flow: None,
1408            },
1409            paths: Some(paths.clone()),
1410        };
1411        let (tx, _rx) = std::sync::mpsc::channel();
1412        let handle = spawn_parser_thread(
1413            stderr,
1414            "web".into(),
1415            tx,
1416            Arc::new(Mutex::new(VecDeque::new())),
1417            flags.clone(),
1418            watch,
1419        );
1420        handle.join().expect("parser thread");
1421        let _ = child.wait();
1422        assert!(flags.drained.load(Ordering::Relaxed));
1423        assert!(flags.authenticated.load(Ordering::Relaxed));
1424        assert!(flags.auth_refused.load(Ordering::Relaxed));
1425        assert_eq!(
1426            crate::security_key::refusal_for(Some(&paths), "/k"),
1427            Some(crate::security_key::RefusalReason::WrongPin)
1428        );
1429    }
1430
1431    #[test]
1432    fn record_event_open_increments_counters_for_user_visible_kinds() {
1433        let now = Instant::now();
1434        let mut state = TunnelLiveState::new(now);
1435        state.record_event(user_open(1, now));
1436        assert_eq!(state.total_opens, 1);
1437        assert_eq!(state.active_channels, 1);
1438        assert_eq!(state.peak_concurrent, 1);
1439        // Activity history is fed by sample_activity, not record_event.
1440        assert_eq!(state.opens_history[HISTORY_BUCKETS - 1], 0);
1441    }
1442
1443    #[test]
1444    fn record_event_skips_counters_for_internal_channels() {
1445        // Listener / session / mux-master channels should NOT inflate
1446        // peak_concurrent or total_opens — only user-visible traffic
1447        // counts towards the activity figures.
1448        let now = Instant::now();
1449        let mut state = TunnelLiveState::new(now);
1450        state.record_event(ChannelEvent {
1451            at: now,
1452            channel_id: 0,
1453            kind: ChannelEventKind::Open,
1454            channel_kind: Some(ChannelKind::Other),
1455            opened_at: None,
1456        });
1457        assert_eq!(state.total_opens, 0);
1458        assert_eq!(state.active_channels, 0);
1459        assert_eq!(state.opens_history[HISTORY_BUCKETS - 1], 0);
1460        // The event is still kept in the ringbuffer for diagnostics.
1461        assert_eq!(state.events.len(), 1);
1462    }
1463
1464    #[test]
1465    fn sample_activity_writes_peak_into_current_bucket() {
1466        let now = Instant::now();
1467        let mut state = TunnelLiveState::new(now);
1468        state.sample_activity(2);
1469        assert_eq!(state.opens_history[HISTORY_BUCKETS - 1], 2);
1470        // A lower sample inside the same bucket must not erase the peak.
1471        state.sample_activity(1);
1472        assert_eq!(state.opens_history[HISTORY_BUCKETS - 1], 2);
1473        // A higher sample raises the peak.
1474        state.sample_activity(5);
1475        assert_eq!(state.opens_history[HISTORY_BUCKETS - 1], 5);
1476    }
1477
1478    #[test]
1479    fn sample_activity_clamps_to_u8_max() {
1480        let now = Instant::now();
1481        let mut state = TunnelLiveState::new(now);
1482        state.sample_activity(u32::MAX);
1483        assert_eq!(state.opens_history[HISTORY_BUCKETS - 1], u8::MAX);
1484    }
1485
1486    #[test]
1487    fn record_event_close_pairs_with_open_for_duration() {
1488        let t0 = Instant::now();
1489        let t1 = t0 + Duration::from_secs(5);
1490        let mut state = TunnelLiveState::new(t0);
1491        state.record_event(user_open(7, t0));
1492        state.record_event(ChannelEvent {
1493            at: t1,
1494            channel_id: 7,
1495            kind: ChannelEventKind::Close,
1496            channel_kind: None,
1497            opened_at: None,
1498        });
1499        assert_eq!(state.active_channels, 0);
1500        let last = state.events.back().unwrap();
1501        assert_eq!(last.kind, ChannelEventKind::Close);
1502        assert_eq!(last.opened_at, Some(t0));
1503        // The close event picks up the kind that was recorded on open.
1504        assert_eq!(last.channel_kind, Some(ChannelKind::Direct));
1505    }
1506
1507    #[test]
1508    fn record_event_caps_ringbuffer_at_max() {
1509        let now = Instant::now();
1510        let mut state = TunnelLiveState::new(now);
1511        for i in 0..(MAX_EVENTS as u32 + 5) {
1512            state.record_event(user_open(i, now));
1513        }
1514        assert_eq!(state.events.len(), MAX_EVENTS);
1515    }
1516
1517    #[test]
1518    fn rotate_if_due_shifts_buckets_per_tick() {
1519        let t0 = Instant::now();
1520        let mut state = TunnelLiveState::new(t0);
1521        state.opens_history[HISTORY_BUCKETS - 1] = 7;
1522        state.rotate_if_due(t0 + Duration::from_secs(BUCKET_SECS));
1523        // After one rotate the value moved one slot left.
1524        assert_eq!(state.opens_history[HISTORY_BUCKETS - 2], 7);
1525        assert_eq!(state.opens_history[HISTORY_BUCKETS - 1], 0);
1526    }
1527
1528    #[test]
1529    fn rotate_if_due_clamps_at_full_window() {
1530        let t0 = Instant::now();
1531        let mut state = TunnelLiveState::new(t0);
1532        state.opens_history[HISTORY_BUCKETS - 1] = 9;
1533        // Far beyond the window — rotation should clear it entirely.
1534        state.rotate_if_due(t0 + Duration::from_secs(BUCKET_SECS * HISTORY_BUCKETS as u64 * 4));
1535        assert!(state.opens_history.iter().all(|&v| v == 0));
1536    }
1537
1538    #[test]
1539    fn rotate_if_due_noop_within_one_bucket() {
1540        let t0 = Instant::now();
1541        let mut state = TunnelLiveState::new(t0);
1542        state.opens_history[HISTORY_BUCKETS - 1] = 3;
1543        // Less than one BUCKET_SECS elapsed — no rotation should happen.
1544        state.rotate_if_due(t0 + Duration::from_millis(BUCKET_SECS * 1000 / 2));
1545        assert_eq!(state.opens_history[HISTORY_BUCKETS - 1], 3);
1546    }
1547
1548    #[test]
1549    fn parse_lsof_listen_row() {
1550        let line = "ssh    12345 user 3u IPv4 0xabc 0t0 TCP 127.0.0.1:8080 (LISTEN)";
1551        let row = parse_lsof_row(line).unwrap();
1552        assert_eq!(row.command, "ssh");
1553        assert_eq!(row.pid, 12345);
1554        assert!(row.is_listen);
1555        assert_eq!(row.local_addr, "127.0.0.1");
1556        assert_eq!(row.local_port, 8080);
1557        assert!(row.remote_port.is_none());
1558    }
1559
1560    #[test]
1561    fn parse_lsof_established_row() {
1562        let line =
1563            "curl   23456 user 4u IPv4 0xdef 0t0 TCP 127.0.0.1:54321->127.0.0.1:8080 (ESTABLISHED)";
1564        let row = parse_lsof_row(line).unwrap();
1565        assert_eq!(row.command, "curl");
1566        assert_eq!(row.pid, 23456);
1567        assert!(!row.is_listen);
1568        assert_eq!(row.local_port, 54321);
1569        assert_eq!(row.remote_port, Some(8080));
1570    }
1571
1572    #[test]
1573    fn parse_lsof_other_states_skipped() {
1574        // CLOSE_WAIT, TIME_WAIT, etc. are not interesting for our display.
1575        let line = "x 1 u 0u IPv4 0 0t0 TCP 1.2.3.4:1->5.6.7.8:9 (CLOSE_WAIT)";
1576        assert!(parse_lsof_row(line).is_none());
1577    }
1578
1579    #[test]
1580    fn parse_lsof_output_finds_clients_for_bind_port() {
1581        let txt = "\
1582COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
1583ssh    12345 u 3u IPv4 0xa 0t0 TCP 127.0.0.1:8080 (LISTEN)
1584curl   23456 u 4u IPv4 0xb 0t0 TCP 127.0.0.1:54321->127.0.0.1:8080 (ESTABLISHED)
1585ssh    12345 u 5u IPv4 0xc 0t0 TCP 127.0.0.1:8080->127.0.0.1:54321 (ESTABLISHED)
1586";
1587        let ports = vec![("foo".into(), 8080u16, 12345u32)];
1588        let mut seen = HashMap::new();
1589        let now = Instant::now();
1590        let msg = parse_lsof_output(txt, &ports, &mut seen, now);
1591        let peers = msg.clients.get(&8080).expect("clients on 8080");
1592        assert_eq!(peers.len(), 1);
1593        assert_eq!(peers[0].process, "curl");
1594        assert_eq!(peers[0].pid, 23456);
1595        assert!(peers[0].src.contains("54321"));
1596        assert!(msg.conflicts.is_empty());
1597    }
1598
1599    #[test]
1600    fn parse_lsof_output_detects_port_conflict() {
1601        let txt = "\
1602COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
1603nginx  99999 u 3u IPv4 0xa 0t0 TCP 127.0.0.1:8080 (LISTEN)
1604";
1605        let ports = vec![("foo".into(), 8080u16, 12345u32)];
1606        let mut seen = HashMap::new();
1607        let now = Instant::now();
1608        let msg = parse_lsof_output(txt, &ports, &mut seen, now);
1609        let conflict = msg.conflicts.get(&8080).expect("conflict on 8080");
1610        assert_eq!(conflict.process, "nginx");
1611        assert_eq!(conflict.pid, 99999);
1612    }
1613
1614    #[test]
1615    fn parse_lsof_output_skips_own_listen() {
1616        let txt = "\
1617COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
1618ssh    12345 u 3u IPv4 0xa 0t0 TCP 127.0.0.1:8080 (LISTEN)
1619";
1620        let ports = vec![("foo".into(), 8080u16, 12345u32)];
1621        let mut seen = HashMap::new();
1622        let now = Instant::now();
1623        let msg = parse_lsof_output(txt, &ports, &mut seen, now);
1624        assert!(msg.conflicts.is_empty());
1625    }
1626
1627    #[test]
1628    fn parse_lsof_output_first_seen_persists_across_polls() {
1629        let txt = "\
1630COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
1631ssh    12345 u 3u IPv4 0xa 0t0 TCP 127.0.0.1:8080 (LISTEN)
1632curl   23456 u 4u IPv4 0xb 0t0 TCP 127.0.0.1:54321->127.0.0.1:8080 (ESTABLISHED)
1633";
1634        let ports = vec![("foo".into(), 8080u16, 12345u32)];
1635        let mut seen = HashMap::new();
1636        let t0 = Instant::now();
1637        let msg1 = parse_lsof_output(txt, &ports, &mut seen, t0);
1638        let t1 = t0 + Duration::from_secs(5);
1639        let msg2 = parse_lsof_output(txt, &ports, &mut seen, t1);
1640        let p1 = &msg1.clients[&8080][0];
1641        let p2 = &msg2.clients[&8080][0];
1642        assert_eq!(p1.since, p2.since, "first_seen should be sticky");
1643    }
1644
1645    #[test]
1646    fn split_addr_port_handles_ipv6_brackets() {
1647        let (a, p) = split_addr_port("[::1]:8080").unwrap();
1648        assert_eq!(a, "::1");
1649        assert_eq!(p, 8080);
1650    }
1651
1652    #[test]
1653    fn split_addr_port_handles_ipv4() {
1654        let (a, p) = split_addr_port("127.0.0.1:8080").unwrap();
1655        assert_eq!(a, "127.0.0.1");
1656        assert_eq!(p, 8080);
1657    }
1658
1659    #[test]
1660    fn beautify_process_strips_com_apple_prefix() {
1661        assert_eq!(
1662            beautify_process("com.apple.WebKit.Networking"),
1663            "WebKit.Networking"
1664        );
1665        assert_eq!(beautify_process("com.apple.Safari"), "Safari");
1666    }
1667
1668    #[test]
1669    fn beautify_process_passes_other_names_through_unchanged() {
1670        assert_eq!(beautify_process("curl"), "curl");
1671        assert_eq!(beautify_process("nginx"), "nginx");
1672        assert_eq!(beautify_process("python3"), "python3");
1673    }
1674
1675    #[test]
1676    fn beautify_process_does_not_strip_when_only_prefix() {
1677        // Edge case: the bare `com.apple.` string would otherwise
1678        // collapse to "" and disappear from the card. Keep the raw
1679        // value so the user at least sees that lsof reported something.
1680        assert_eq!(beautify_process("com.apple."), "com.apple.");
1681    }
1682
1683    #[test]
1684    fn parse_lsof_output_unwraps_apple_framework_names() {
1685        let txt = "\
1686COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
1687ssh    12345 u 3u IPv4 0xa 0t0 TCP 127.0.0.1:8080 (LISTEN)
1688com.apple.WebKit.Networking 23456 u 4u IPv4 0xb 0t0 TCP 127.0.0.1:54321->127.0.0.1:8080 (ESTABLISHED)
1689";
1690        let ports = vec![("foo".into(), 8080u16, 12345u32)];
1691        let mut seen = HashMap::new();
1692        let now = Instant::now();
1693        let msg = parse_lsof_output(txt, &ports, &mut seen, now);
1694        let peers = msg.clients.get(&8080).expect("clients on 8080");
1695        assert_eq!(peers.len(), 1);
1696        // Process name is shown without the noisy `com.apple.` prefix.
1697        assert_eq!(peers[0].process, "WebKit.Networking");
1698    }
1699}