use std::io::IsTerminal;
use std::process::Command;
use super::proctree::{Proc, ancestor_in, basename, parent_map, table};
use super::tmux_query::{Pane, poll};
struct Row {
peer: String,
pid: i64,
command: String,
pane: String,
loc: String,
}
pub(crate) fn run(socket: &str) -> i32 {
let snap = poll(socket);
if let Some(e) = &snap.error {
eprintln!("ztmux net: {e}");
return 1;
}
let procs = table();
let conns = gather_conns();
let rows = attribute(&conns, &procs, &snap.panes);
let json = std::env::args().any(|a| a == "--json")
|| std::env::args()
.collect::<Vec<_>>()
.windows(2)
.any(|w| w[0] == "-o" && w[1] == "json");
if json {
print!("{}", render_json(&rows));
} else {
print!("{}", render_text(&rows, std::io::stdout().is_terminal()));
}
0
}
fn gather_conns() -> Vec<(i64, String)> {
let Ok(out) = Command::new("lsof")
.args(["-nP", "-iTCP", "-sTCP:ESTABLISHED", "-Fpn"])
.output()
else {
return Vec::new();
};
parse_lsof(&String::from_utf8_lossy(&out.stdout))
}
fn parse_lsof(text: &str) -> Vec<(i64, String)> {
let mut out: Vec<(i64, String)> = Vec::new();
let mut pid: Option<i64> = None;
for line in text.lines() {
let Some((tag, rest)) = line.split_at_checked(1) else {
continue;
};
match tag {
"p" => pid = rest.parse().ok(),
"n" => {
if let (Some(pid), Some(peer)) = (pid, remote_of(rest)) {
let pair = (pid, peer);
if !out.contains(&pair) {
out.push(pair);
}
}
}
_ => {}
}
}
out
}
fn remote_of(name: &str) -> Option<String> {
name.split_once("->").map(|(_, r)| r.to_string())
}
fn attribute(conns: &[(i64, String)], procs: &[Proc], panes: &[Pane]) -> Vec<Row> {
let pane_pids: Vec<i64> = panes.iter().map(|p| p.pid).collect();
let pm = parent_map(procs);
let comm: std::collections::HashMap<i64, &str> =
procs.iter().map(|p| (p.pid, p.comm.as_str())).collect();
let mut rows: Vec<Row> = conns
.iter()
.filter_map(|(pid, peer)| {
let owner = if pane_pids.contains(pid) {
Some(*pid)
} else {
ancestor_in(*pid, &pm, &pane_pids)
}?;
let pane = panes.iter().find(|p| p.pid == owner)?;
Some(Row {
peer: peer.clone(),
pid: *pid,
command: comm
.get(pid)
.map_or_else(|| "?".to_string(), |c| basename(c).to_string()),
pane: pane.id.clone(),
loc: format!("{}:{}.{}", pane.session, pane.window, pane.index),
})
})
.collect();
rows.sort_by(|a, b| a.loc.cmp(&b.loc).then(a.peer.cmp(&b.peer)));
rows
}
fn render_text(rows: &[Row], color: bool) -> String {
let paint = |s: &str, code: &str| -> String {
if color {
format!("\x1b[{code}m{s}\x1b[0m")
} else {
s.to_string()
}
};
let mut out = String::new();
out.push_str(&format!(
"{}\n",
paint(
&format!(
"{:<8} {:<16} {:>7} {:<12} {}",
"PANE", "LOCATION", "PID", "COMMAND", "PEER"
),
"1"
)
));
for r in rows {
out.push_str(&format!(
"{:<8} {:<16} {:>7} {:<12} {}\n",
r.pane, r.loc, r.pid, r.command, r.peer,
));
}
out
}
fn render_json(rows: &[Row]) -> String {
let arr: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"pane": r.pane,
"location": r.loc,
"pid": r.pid,
"command": r.command,
"peer": r.peer,
})
})
.collect();
format!(
"{}\n",
serde_json::to_string_pretty(&serde_json::Value::Array(arr)).unwrap_or_default()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remote_of_takes_the_far_endpoint() {
assert_eq!(
remote_of("127.0.0.1:5000->1.2.3.4:443").as_deref(),
Some("1.2.3.4:443")
);
assert_eq!(
remote_of("[::1]:5000->[2606::1]:443").as_deref(),
Some("[2606::1]:443")
);
assert_eq!(remote_of("*:7000"), None); }
#[test]
fn parse_lsof_pairs_pid_with_remote_and_dedupes() {
let text = "p100\nn10.0.0.2:50->1.1.1.1:443\nn10.0.0.2:50->1.1.1.1:443\n\
p200\nn10.0.0.2:51->8.8.8.8:53\n";
let got = parse_lsof(text);
assert_eq!(
got,
vec![
(100, "1.1.1.1:443".to_string()),
(200, "8.8.8.8:53".to_string()),
]
);
}
fn procs() -> Vec<Proc> {
vec![
Proc {
pid: 100,
ppid: 1,
comm: "zsh".into(),
},
Proc {
pid: 300,
ppid: 100,
comm: "curl".into(),
},
]
}
fn panes() -> Vec<Pane> {
vec![Pane {
id: "%0".into(),
session: "net".into(),
window: 1,
index: 0,
pid: 100,
..Default::default()
}]
}
#[test]
fn attributes_a_descendant_connection_to_its_pane() {
let rows = attribute(&[(300, "1.1.1.1:443".into())], &procs(), &panes());
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].pane, "%0");
assert_eq!(rows[0].loc, "net:1.0");
assert_eq!(rows[0].command, "curl");
assert_eq!(rows[0].peer, "1.1.1.1:443");
}
#[test]
fn connection_under_no_pane_is_dropped() {
let rows = attribute(&[(999, "9.9.9.9:443".into())], &procs(), &panes());
assert!(rows.is_empty());
}
#[test]
fn rows_sorted_by_location_then_peer() {
let panes = vec![
Pane {
id: "%0".into(),
session: "a".into(),
window: 0,
index: 0,
pid: 100,
..Default::default()
},
Pane {
id: "%1".into(),
session: "a".into(),
window: 1,
index: 0,
pid: 200,
..Default::default()
},
];
let procs = vec![
Proc {
pid: 100,
ppid: 1,
comm: "zsh".into(),
},
Proc {
pid: 200,
ppid: 1,
comm: "zsh".into(),
},
];
let rows = attribute(
&[
(200, "2.2.2.2:80".into()),
(100, "3.3.3.3:80".into()),
(100, "1.1.1.1:80".into()),
],
&procs,
&panes,
);
assert_eq!(
rows.iter()
.map(|r| (r.loc.as_str(), r.peer.as_str()))
.collect::<Vec<_>>(),
vec![
("a:0.0", "1.1.1.1:80"),
("a:0.0", "3.3.3.3:80"),
("a:1.0", "2.2.2.2:80"),
],
);
}
#[test]
fn text_has_header_and_a_row() {
let rows = attribute(&[(300, "1.1.1.1:443".into())], &procs(), &panes());
let s = render_text(&rows, false);
assert!(s.contains("PEER") && s.contains("COMMAND"));
assert!(s.contains("1.1.1.1:443") && s.contains("%0") && s.contains("curl"));
}
}