Tools: Latest: A Cross-OS Port Finder in Rust — One CLI, Three Completely Different Data Formats

Tools: Latest: A Cross-OS Port Finder in Rust — One CLI, Three Completely Different Data Formats

A Cross-OS Port Finder in Rust — One CLI, Three Completely Different Data Formats

The surface

Three operating systems, three data formats

1. macOS — lsof -F, the "one field per line" format

2. Linux — /proc/net/tcp hex and byte order

3. Windows — netstat + tasklist, two passes

Testing all three backends from anywhere

--kill is just shelling out

Release profile A tiny Rust CLI that answers "who is holding port 3000?" on macOS, Linux and Windows with the same flags and the same output shape — and optionally kills the offender. 488 KB binary, 42 tests, zero external crates beyond clap + serde. npm run dev dies with EADDRINUSE: address already in use :::3000 and once again there's a zombie Node sitting on the port. On macOS the incantation is lsof -nP -iTCP:3000 -sTCP:LISTEN. On Linux it's ss -Hlntp "( sport = :3000 )" — unless it's Alpine, in which case ss may not have -p. On Windows it's netstat -ano | findstr :3000 followed by tasklist /FI "PID eq ..." to get the process name. Three operating systems, three completely different commands, three different output shapes. If you ship software that runs on all three — and "ship" here includes "SSH into customer boxes for debugging" — you end up re-learning one of those spells every couple of weeks. I wanted a single tool: Writing it turned out to be more interesting than I expected. Each OS exposes "who is listening on this socket" in a genuinely different shape — not just different flags, but different data formats. The three parsers inside port-finder have almost nothing in common. GitHub: https://github.com/sen-ltd/port-finder Exit codes are three-valued: The fun starts here. Each OS has a completely different way of exposing socket → process attribution. port-finder has one backend per platform, selected at build time via #[cfg(target_os = ...)], but they all land in the same Vec<Listener>. macOS ships lsof. Called plainly, it's column-aligned — which breaks as soon as a command name has a space in it (Code Helper (Plugin), for one real-world example). Use -F to get one field per line instead: Each line starts with a single-letter tag: The parser is a tiny state machine. Track the current (pid, command, user) at the process level and the current address family at the file level. Emit a Listener whenever you see an n. Reset file-level state at every f, reset everything at every p. The dual-stack trap lives here. lsof reports the IPv4 socket and the IPv6 socket of a dual-bound process with identical strings (*:57768 for both). Without the t marker you lose the distinction. The fix is to rewrite the * using the family we just tracked: Skip that step and you spend an evening wondering why killing "the one process on port 57768" doesn't free the port — because there were two sockets, bound to separate address families, and you only killed one of them. Linux shells out to nothing. Everything lives in /proc/net/tcp and /proc/net/tcp6: There are three small traps and one larger design question. Trap 1: Filter on state. The st column's 0A is TCP_LISTEN (defined in include/net/tcp_states.h). Without the filter, your result set includes ESTABLISHED, TIME_WAIT and everything else. Trap 2: The address is a __be32 printed with %X. On a little-endian host, that prints the bytes reversed. 0100007F is 127.0.0.1: The key move is .to_le_bytes(), not Ipv4Addr::from(u32). The From<u32> impl for Ipv4Addr treats the u32 as already in network byte order, which is exactly the wrong assumption here: IPv6 is the same trick four times: 32 hex chars → four 8-char chunks → four u32s → four .to_le_bytes() calls → 16 bytes → Ipv6Addr::from([u8; 16]). Trap 3: The port is not reversed. This one bit me on a previous project and I was ready for it this time. The kernel runs the port through ntohs(inet->inet_sport) before printing it (see get_tcp4_sock in net/ipv4/tcp_ipv4.c), so it comes out in host byte order already: 0x0BB8 is 3000. If you "helpfully" swap bytes here, you get 0xB80B = 47115, and absolutely nothing matches. Design question: inode → PID attribution. /proc/net/tcp gives you a socket inode. It does not tell you which process owns that socket. You get the mapping by walking /proc/[0-9]+/fd/*, readlink()-ing each entry, and matching the socket:[<inode>] form: Running without sudo means you can only read your own processes' fd directories, so inodes owned by other users may appear orphaned. lsof has the exact same constraint — this isn't a deficiency of the approach, it's how the kernel exposes the data. Command name comes from /proc/<pid>/comm. The user name resolves by parsing /etc/passwd for the UID that was right there on the original row. Everything is stdlib. Windows has neither lsof nor /proc, so we combine the output of two built-ins: Trap: the IPv6 literal's internal colons. 127.0.0.1:3000 and [::1]:3000 share one parser. A naïve rsplit(':') tears the IPv6 address apart. Split on the last colon that sits outside brackets: Then tasklist maps PID → image name: Trap: the memory column embeds commas. The last column — "Mem Usage" — formats as 100,032 K. A naïve split(',') on that row shifts every column by one, so the PID you extract is actually the session type. Real CSV parsing is the only answer: Twenty lines. Handles the "" escape rule for good measure, so the parser doesn't fall over if some future column ever embeds a quote. Three backends means CI gets awkward. A GitHub Actions Ubuntu runner can't exercise the macOS parser — unless you structure the code so it can. The pattern: expose the parser as a pub fn parse(input: &str, ...) -> Result<...> that takes a plain string. Keep the live command invocation inside a separate #[cfg(target_os = "...")] function. Now any host can test any parser against fixture strings pulled from real output: Fixtures are straight excerpts from real /proc/net/tcp, lsof -F, netstat -ano runs: This test runs on macOS, on Windows under CI, anywhere. Live verification of the #[cfg(target_os)] paths still needs a real machine for each OS — I run cargo test && ./target/release/port-finder on a macOS laptop, a Linux EC2 box, and a Windows VM — but 95 % of the logic is exercised by the portable fixtures. Killing is OS-specific in detail but trivial to shell out for. On Unix, kill -15 <pid> (or -9 with --force); on Windows, taskkill /PID <pid> /F. No need for libc::kill, no need for an extra crate: port-finder 3000 --kill always prints the table before killing — you see what you're about to kill — and then de-duplicates PIDs before sending signals, so a process bound to 0.0.0.0:3000 and [::]:3000 gets one signal, not two. 42 tests, sub-second runtime. Everything is static fixtures and port-spec arithmetic — no /proc, no sockets, no containers required. The usual Rust size-squeeze: Three deps (clap, serde, serde_json), no TLS, no crypto, no C libraries. macOS (arm64) comes out to 488 KB. Cheap enough to cargo install --path . into every toolbox you've got. port-finder is a tiny tool for a tiny question — "who is holding this port?" — but writing it surfaces three genuinely different worlds of data: lsof's tag-per-line fields, /proc/net/tcp's host-endian hex, and netstat's column output paired with tasklist's CSV-with-commas. The fact that "the same thing" is stored in three such different shapes is operating-system history written directly into the API surface. Cross-platform CLIs are small pieces of unification layered over a lot of legacy. The 488 KB binary that falls out of cargo build --release carries all three decoders and answers a single question consistently. That's a trade I'll take. Next time you yell "who took port 3000?" — try it out. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

# single port $ port-finder 3000 # several $ port-finder 3000 8080 5432 # range $ port-finder 3000-3100 # show everything $ port-finder # kill after showing $ port-finder 3000 --kill # SIGTERM $ port-finder 3000 --force # SIGKILL # into a pipeline $ port-finder --json 8080 | jq '.listeners[] | {pid, command}' # single port $ port-finder 3000 # several $ port-finder 3000 8080 5432 # range $ port-finder 3000-3100 # show everything $ port-finder # kill after showing $ port-finder 3000 --kill # SIGTERM $ port-finder 3000 --force # SIGKILL # into a pipeline $ port-finder --json 8080 | jq '.listeners[] | {pid, command}' # single port $ port-finder 3000 # several $ port-finder 3000 8080 5432 # range $ port-finder 3000-3100 # show everything $ port-finder # kill after showing $ port-finder 3000 --kill # SIGTERM $ port-finder 3000 --force # SIGKILL # into a pipeline $ port-finder --json 8080 | jq '.listeners[] | {pid, command}' $ lsof -nP -iTCP -sTCP:LISTEN -F pcLnt p1103 crapportd Lme f12 tIPv4 n*:57768 f14 tIPv6 n*:57768 $ lsof -nP -iTCP -sTCP:LISTEN -F pcLnt p1103 crapportd Lme f12 tIPv4 n*:57768 f14 tIPv6 n*:57768 $ lsof -nP -iTCP -sTCP:LISTEN -F pcLnt p1103 crapportd Lme f12 tIPv4 n*:57768 f14 tIPv6 n*:57768 for raw in input.lines() { let (tag, value) = raw.split_at(1); match tag { "p" => { pid = Some(value.parse()?); command = None; user = None; ipv6 = false; } "c" => command = Some(value.into()), "L" => user = Some(value.into()), "f" => ipv6 = false, // new fd → family unknown until `t` arrives "t" => ipv6 = value == "IPv6", "n" => { /* split `addr:port`, push a Listener */ } _ => {} } } for raw in input.lines() { let (tag, value) = raw.split_at(1); match tag { "p" => { pid = Some(value.parse()?); command = None; user = None; ipv6 = false; } "c" => command = Some(value.into()), "L" => user = Some(value.into()), "f" => ipv6 = false, // new fd → family unknown until `t` arrives "t" => ipv6 = value == "IPv6", "n" => { /* split `addr:port`, push a Listener */ } _ => {} } } for raw in input.lines() { let (tag, value) = raw.split_at(1); match tag { "p" => { pid = Some(value.parse()?); command = None; user = None; ipv6 = false; } "c" => command = Some(value.into()), "L" => user = Some(value.into()), "f" => ipv6 = false, // new fd → family unknown until `t` arrives "t" => ipv6 = value == "IPv6", "n" => { /* split `addr:port`, push a Listener */ } _ => {} } } let address = match addr { "*" if ipv6 => "[::]".to_string(), "*" => "0.0.0.0".to_string(), other => other.to_string(), }; let address = match addr { "*" if ipv6 => "[::]".to_string(), "*" => "0.0.0.0".to_string(), other => other.to_string(), }; let address = match addr { "*" if ipv6 => "[::]".to_string(), "*" => "0.0.0.0".to_string(), other => other.to_string(), }; sl local_address rem_address st ... uid ... inode 0: 00000000:0BB8 00000000:0000 0A ... 1000 ... 987654 ... sl local_address rem_address st ... uid ... inode 0: 00000000:0BB8 00000000:0000 0A ... 1000 ... 987654 ... sl local_address rem_address st ... uid ... inode 0: 00000000:0BB8 00000000:0000 0A ... 1000 ... 987654 ... let word = u32::from_str_radix(addr_hex, 16)?; Ipv4Addr::from(word.to_le_bytes()) // not Ipv4Addr::from(word) let word = u32::from_str_radix(addr_hex, 16)?; Ipv4Addr::from(word.to_le_bytes()) // not Ipv4Addr::from(word) let word = u32::from_str_radix(addr_hex, 16)?; Ipv4Addr::from(word.to_le_bytes()) // not Ipv4Addr::from(word) let port = u16::from_str_radix(port_hex, 16)?; // just parse let port = u16::from_str_radix(port_hex, 16)?; // just parse let port = u16::from_str_radix(port_hex, 16)?; // just parse fn scan_proc_sockets() -> HashMap<u64, u32> { let mut map = HashMap::new(); for entry in std::fs::read_dir("/proc")?.flatten() { let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else { continue }; let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) else { continue }; for fd in fds.flatten() { let Ok(link) = std::fs::read_link(fd.path()) else { continue }; if let Some(inode) = extract_socket_inode(&link.to_string_lossy()) { map.entry(inode).or_insert(pid); } } } map } fn scan_proc_sockets() -> HashMap<u64, u32> { let mut map = HashMap::new(); for entry in std::fs::read_dir("/proc")?.flatten() { let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else { continue }; let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) else { continue }; for fd in fds.flatten() { let Ok(link) = std::fs::read_link(fd.path()) else { continue }; if let Some(inode) = extract_socket_inode(&link.to_string_lossy()) { map.entry(inode).or_insert(pid); } } } map } fn scan_proc_sockets() -> HashMap<u64, u32> { let mut map = HashMap::new(); for entry in std::fs::read_dir("/proc")?.flatten() { let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else { continue }; let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) else { continue }; for fd in fds.flatten() { let Ok(link) = std::fs::read_link(fd.path()) else { continue }; if let Some(inode) = extract_socket_inode(&link.to_string_lossy()) { map.entry(inode).or_insert(pid); } } } map } > netstat -ano -p TCP TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 964 TCP [::]:3000 [::]:0 LISTENING 12345 > netstat -ano -p TCP TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 964 TCP [::]:3000 [::]:0 LISTENING 12345 > netstat -ano -p TCP TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 964 TCP [::]:3000 [::]:0 LISTENING 12345 pub fn split_address(s: &str) -> Option<(&str, &str)> { let mut depth = 0i32; let mut last = None; for (i, ch) in s.char_indices() { match ch { '[' => depth += 1, ']' => depth -= 1, ':' if depth == 0 => last = Some(i), _ => {} } } let i = last?; Some((&s[..i], &s[i + 1..])) } pub fn split_address(s: &str) -> Option<(&str, &str)> { let mut depth = 0i32; let mut last = None; for (i, ch) in s.char_indices() { match ch { '[' => depth += 1, ']' => depth -= 1, ':' if depth == 0 => last = Some(i), _ => {} } } let i = last?; Some((&s[..i], &s[i + 1..])) } pub fn split_address(s: &str) -> Option<(&str, &str)> { let mut depth = 0i32; let mut last = None; for (i, ch) in s.char_indices() { match ch { '[' => depth += 1, ']' => depth -= 1, ':' if depth == 0 => last = Some(i), _ => {} } } let i = last?; Some((&s[..i], &s[i + 1..])) } > tasklist /FO CSV /NH "System","4","Services","0","136 K" "node.exe","12345","Console","1","100,032 K" > tasklist /FO CSV /NH "System","4","Services","0","136 K" "node.exe","12345","Console","1","100,032 K" > tasklist /FO CSV /NH "System","4","Services","0","136 K" "node.exe","12345","Console","1","100,032 K" fn split_csv_row(line: &str) -> Vec<String> { let mut out = Vec::new(); let mut cur = String::new(); let mut in_quotes = false; let mut chars = line.chars().peekable(); while let Some(ch) = chars.next() { match ch { '"' => { if in_quotes && chars.peek() == Some(&'"') { cur.push('"'); chars.next(); // `""` inside a quoted field is a literal quote } else { in_quotes = !in_quotes; } } ',' if !in_quotes => out.push(std::mem::take(&mut cur)), _ => cur.push(ch), } } out.push(cur); out } fn split_csv_row(line: &str) -> Vec<String> { let mut out = Vec::new(); let mut cur = String::new(); let mut in_quotes = false; let mut chars = line.chars().peekable(); while let Some(ch) = chars.next() { match ch { '"' => { if in_quotes && chars.peek() == Some(&'"') { cur.push('"'); chars.next(); // `""` inside a quoted field is a literal quote } else { in_quotes = !in_quotes; } } ',' if !in_quotes => out.push(std::mem::take(&mut cur)), _ => cur.push(ch), } } out.push(cur); out } fn split_csv_row(line: &str) -> Vec<String> { let mut out = Vec::new(); let mut cur = String::new(); let mut in_quotes = false; let mut chars = line.chars().peekable(); while let Some(ch) = chars.next() { match ch { '"' => { if in_quotes && chars.peek() == Some(&'"') { cur.push('"'); chars.next(); // `""` inside a quoted field is a literal quote } else { in_quotes = !in_quotes; } } ',' if !in_quotes => out.push(std::mem::take(&mut cur)), _ => cur.push(ch), } } out.push(cur); out } // src/linux.rs pub fn parse(tcp: &str, tcp6: &str, ports: &[u16]) -> Result<Vec<TcpEntry>, Error> { ... } #[cfg(target_os = "linux")] pub fn find(ports: &[u16]) -> Result<Vec<Listener>, Error> { let tcp = std::fs::read_to_string("/proc/net/tcp").unwrap_or_default(); let tcp6 = std::fs::read_to_string("/proc/net/tcp6").unwrap_or_default(); parse(&tcp, &tcp6, ports).map(/* + inode → pid resolution */) } #[cfg(not(target_os = "linux"))] pub fn find(_: &[u16]) -> Result<Vec<Listener>, Error> { Err(Error::Unsupported) } // src/linux.rs pub fn parse(tcp: &str, tcp6: &str, ports: &[u16]) -> Result<Vec<TcpEntry>, Error> { ... } #[cfg(target_os = "linux")] pub fn find(ports: &[u16]) -> Result<Vec<Listener>, Error> { let tcp = std::fs::read_to_string("/proc/net/tcp").unwrap_or_default(); let tcp6 = std::fs::read_to_string("/proc/net/tcp6").unwrap_or_default(); parse(&tcp, &tcp6, ports).map(/* + inode → pid resolution */) } #[cfg(not(target_os = "linux"))] pub fn find(_: &[u16]) -> Result<Vec<Listener>, Error> { Err(Error::Unsupported) } // src/linux.rs pub fn parse(tcp: &str, tcp6: &str, ports: &[u16]) -> Result<Vec<TcpEntry>, Error> { ... } #[cfg(target_os = "linux")] pub fn find(ports: &[u16]) -> Result<Vec<Listener>, Error> { let tcp = std::fs::read_to_string("/proc/net/tcp").unwrap_or_default(); let tcp6 = std::fs::read_to_string("/proc/net/tcp6").unwrap_or_default(); parse(&tcp, &tcp6, ports).map(/* + inode → pid resolution */) } #[cfg(not(target_os = "linux"))] pub fn find(_: &[u16]) -> Result<Vec<Listener>, Error> { Err(Error::Unsupported) } const TCP4: &str = "\ 0: 00000000:0BB8 00000000:0000 0A ... 1000 ... 987654 ... 1: 0100007F:1F90 0100007F:C442 01 ... 0 ... 111111 ... // ESTABLISHED — skipped 2: 0100007F:0050 00000000:0000 0A ... 0 ... 222222 ... "; #[test] fn listen_rows_only() { let got = parse(TCP4, "", &[]).unwrap(); assert_eq!(got.len(), 2); // the ESTABLISHED row at index 1 is excluded assert_eq!(got[0].port, 3000); assert_eq!(got[0].address, "0.0.0.0"); } const TCP4: &str = "\ 0: 00000000:0BB8 00000000:0000 0A ... 1000 ... 987654 ... 1: 0100007F:1F90 0100007F:C442 01 ... 0 ... 111111 ... // ESTABLISHED — skipped 2: 0100007F:0050 00000000:0000 0A ... 0 ... 222222 ... "; #[test] fn listen_rows_only() { let got = parse(TCP4, "", &[]).unwrap(); assert_eq!(got.len(), 2); // the ESTABLISHED row at index 1 is excluded assert_eq!(got[0].port, 3000); assert_eq!(got[0].address, "0.0.0.0"); } const TCP4: &str = "\ 0: 00000000:0BB8 00000000:0000 0A ... 1000 ... 987654 ... 1: 0100007F:1F90 0100007F:C442 01 ... 0 ... 111111 ... // ESTABLISHED — skipped 2: 0100007F:0050 00000000:0000 0A ... 0 ... 222222 ... "; #[test] fn listen_rows_only() { let got = parse(TCP4, "", &[]).unwrap(); assert_eq!(got.len(), 2); // the ESTABLISHED row at index 1 is excluded assert_eq!(got[0].port, 3000); assert_eq!(got[0].address, "0.0.0.0"); } pub fn kill_pid(pid: u32, force: bool) -> Result<(), Error> { #[cfg(unix)] { let sig = if force { "-9" } else { "-15" }; Command::new("kill").arg(sig).arg(pid.to_string()).-weight: 500;">status()?; } #[cfg(windows)] { let mut cmd = Command::new("taskkill"); cmd.arg("/PID").arg(pid.to_string()); if force { cmd.arg("/F"); } cmd.-weight: 500;">status()?; } Ok(()) } pub fn kill_pid(pid: u32, force: bool) -> Result<(), Error> { #[cfg(unix)] { let sig = if force { "-9" } else { "-15" }; Command::new("kill").arg(sig).arg(pid.to_string()).-weight: 500;">status()?; } #[cfg(windows)] { let mut cmd = Command::new("taskkill"); cmd.arg("/PID").arg(pid.to_string()); if force { cmd.arg("/F"); } cmd.-weight: 500;">status()?; } Ok(()) } pub fn kill_pid(pid: u32, force: bool) -> Result<(), Error> { #[cfg(unix)] { let sig = if force { "-9" } else { "-15" }; Command::new("kill").arg(sig).arg(pid.to_string()).-weight: 500;">status()?; } #[cfg(windows)] { let mut cmd = Command::new("taskkill"); cmd.arg("/PID").arg(pid.to_string()); if force { cmd.arg("/F"); } cmd.-weight: 500;">status()?; } Ok(()) } test result: ok. 31 passed (lib) test result: ok. 5 passed (main) test result: ok. 6 passed (cli integration) test result: ok. 31 passed (lib) test result: ok. 5 passed (main) test result: ok. 6 passed (cli integration) test result: ok. 31 passed (lib) test result: ok. 5 passed (main) test result: ok. 6 passed (cli integration) [profile.release] strip = true lto = true codegen-units = 1 opt-level = "z" panic = "abort" [profile.release] strip = true lto = true codegen-units = 1 opt-level = "z" panic = "abort" [profile.release] strip = true lto = true codegen-units = 1 opt-level = "z" panic = "abort" - Same command on every OS. port-finder 3000 works on macOS, Linux and Windows. - Same output shape. Fixed five columns: PORT / PID / COMMAND / USER / ADDRESS. - Don't lie about dual-stack. A process bound to both 0.0.0.0:3000 and [::]:3000 is two sockets, not one — show both rows. - One-shot kill. --kill (SIGTERM) or --force (SIGKILL) without pulling out a second command. - --json for scripts. Anything I build has a monitoring / automation path. - cargo -weight: 500;">install-able static binary. No C dependency, no OpenSSL. - u32::from_str_radix("0100007F", 16) → 0x0100007F (host-order u32) - .to_le_bytes() → [0x7F, 0x00, 0x00, 0x01] ← the original network-order bytes