1use 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
25pub const MAX_EVENTS: usize = 50;
27pub const BUCKET_SECS: u64 = 2;
32
33pub const HISTORY_BUCKETS: usize = 150;
38pub const STDERR_BUFFER_LINES: usize = 10;
40pub const MAX_CLIENTS_PER_PORT: usize = 64;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ChannelEventKind {
48 Open,
49 Close,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum ChannelKind {
62 Direct,
64 Forwarded,
67 Dynamic,
69 Other,
72}
73
74impl ChannelKind {
75 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 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 pub channel_kind: Option<ChannelKind>,
102 pub opened_at: Option<Instant>,
105}
106
107pub const PEER_VIZ_BUCKETS: usize = 12;
114
115#[derive(Debug, Clone)]
117pub struct ClientPeer {
118 pub src: String,
119 pub process: String,
120 pub pid: u32,
123 pub since: Instant,
124 pub responsible_app: Option<String>,
130 pub current_rx_bps: u64,
135 pub current_tx_bps: u64,
136 pub bytes_rcvd: Option<u64>,
139 pub bytes_sent: Option<u64>,
140 pub last_sample_at: Option<Instant>,
142}
143
144#[derive(Debug, Clone)]
150#[allow(dead_code)]
151pub struct PortConflict {
152 pub port: u16,
153 pub process: String,
154 pub pid: u32,
155}
156
157pub struct TunnelLiveState {
161 pub events: VecDeque<ChannelEvent>,
162 pub opens_history: [u8; HISTORY_BUCKETS],
163 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 pub channel_open: HashMap<u32, (Instant, ChannelKind)>,
173 pub last_exit: Option<(i32, String)>,
175 pub stderr_buffer: Arc<Mutex<VecDeque<String>>>,
178 pub parser_thread: Option<JoinHandle<()>>,
180 pub parser_stop: Arc<AtomicBool>,
184 pub authenticated: Arc<AtomicBool>,
186 pub auth_refused: Arc<AtomicBool>,
188 pub drained: Arc<AtomicBool>,
191 pub exited_at: Option<Instant>,
193
194 pub rx_history: [u64; HISTORY_BUCKETS],
197 pub tx_history: [u64; HISTORY_BUCKETS],
200 pub current_rx_bps: u64,
204 pub current_tx_bps: u64,
205 pub peak_rx_bps: u64,
208 pub peak_tx_bps: u64,
209 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 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 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 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#[derive(Debug, Clone)]
336pub struct ParserMessage {
337 pub alias: String,
338 pub event: ChannelEvent,
339}
340
341#[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
360pub 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#[derive(Debug, Clone, Default)]
378pub struct AuthWatch {
379 pub target_host: Option<String>,
383 pub security_key: crate::security_key::SecurityKeyRun,
385 pub paths: Option<crate::runtime::env::Paths>,
386}
387
388impl AuthWatch {
389 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
400pub 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#[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
417pub 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
473pub 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 rest.split_once(']').map(|(t, _)| t.trim().to_string())
496 } else {
497 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#[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 let mut first_seen: HashMap<(u16, String), Instant> = HashMap::new();
537 let mut responsible_cache = ResponsibleAppCache::default();
540 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#[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#[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#[cfg(target_os = "macos")]
611fn lookup_responsible_app(pid: u32, client_process: &str) -> Option<String> {
612 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#[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 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#[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#[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#[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 fn retain_pids(&mut self, live: &std::collections::HashSet<u32>) {
728 self.map.retain(|pid, _| live.contains(pid));
729 }
730}
731
732fn 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#[derive(Debug, Clone, Default)]
752struct PeerSampleCache {
753 last_rcvd: u64,
754 last_sent: u64,
755 last_at: Option<Instant>,
756}
757
758#[derive(Debug, Default)]
763struct PerPeerSamples {
764 per_local_port_cumulative: HashMap<u16, (u64, u64)>,
771 per_pid_bps: HashMap<u32, (u64, u64)>,
776}
777
778fn 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 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 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
835fn 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#[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 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; let delta_block = if text.matches("\ntime,").count() >= 1 {
890 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 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#[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
937fn 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 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
963pub 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 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 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 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#[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 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 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
1112pub 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
1127fn 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#[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 pub throughput_ready: bool,
1167 pub clients: Vec<DisplayClient>,
1168 pub events: Vec<DisplayEvent>,
1169 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 #[allow(dead_code)]
1183 pub src: String,
1184 pub process: String,
1185 pub age_secs: u64,
1186 #[allow(dead_code)]
1189 pub pid: u32,
1190 pub responsible_app: Option<String>,
1193 pub current_rx_bps: u64,
1197 pub current_tx_bps: u64,
1198 pub viz_history: [u64; PEER_VIZ_BUCKETS],
1201 pub throughput_ready: bool,
1205}
1206
1207#[derive(Debug, Clone)]
1208#[allow(dead_code)]
1209pub struct DisplayEvent {
1210 pub age_secs: u64,
1211 #[allow(dead_code)]
1215 pub channel_id: u32,
1216 pub kind: ChannelEventKind,
1217 pub channel_kind: ChannelKind,
1221 #[allow(dead_code)]
1224 pub duration_secs: Option<u64>,
1225 pub count: u32,
1229}
1230
1231#[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 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 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 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 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 assert_eq!(state.opens_history[HISTORY_BUCKETS - 1], 0);
1441 }
1442
1443 #[test]
1444 fn record_event_skips_counters_for_internal_channels() {
1445 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 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 state.sample_activity(1);
1472 assert_eq!(state.opens_history[HISTORY_BUCKETS - 1], 2);
1473 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 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 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 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 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 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 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 assert_eq!(peers[0].process, "WebKit.Networking");
1698 }
1699}