@michelsalib/nethound
v0.10.0
Published
See which process listens on each port, which interfaces reach it, and how everything on your machine connects
Maintainers
Readme
What is on port 5432, a local postgres or the one in docker? Can my laptop's neighbours reach it, or only
this machine? Which process keeps a connection open to that server, and through which interface and
gateway does it leave? nethound answers those in one command, by reading what the system already
knows (/proc and /sys on Linux, netstat and lsof on macOS, PowerShell on Windows) and asking
docker which container sits behind each published port.
Try it, nothing to install:
npx @michelsalib/nethound # every listening port
npx @michelsalib/nethound map # how everything connects
npx @michelsalib/nethound who 5432 # what is on 5432
npx @michelsalib/nethound doctor # what is exposed that shouldn't be$ nethound doctor
! PostgreSQL on tcp 5432 is reachable from every interface
container pg, bound to 0.0.0.0, ::
any machine that can reach eth0 can try passwords, or log in where pg_hba.conf trusts
firewall: ufw is on, but docker's rules let published ports in before the firewall's own (only a DOCKER-USER rule would stop it)
fix: publish it on this machine only: -p 127.0.0.1:5432:5432 (in compose: "127.0.0.1:5432:5432")
! localhost:3000 reaches node 812 over IPv4 and bun 977 over IPv6
node 812 on 127.0.0.1
bun 977 on ::1
localhost over IPv4 (127.0.0.1:3000) → node 812
localhost over IPv6 ([::1]:3000) → bun 977
other machines → nobody
fix: a client may pick either (browsers and node try IPv6 first, many others IPv4): stop one, or move it to another port
2 warnings$ nethound
PORT ADDRESS REACHABLE FROM CLIENTS OWNER
tcp 22 * every interface 1 root's process (sudo to see it)
udp 53 127.0.0.53 this machine only systemd-resolve's process (sudo to see it)
tcp 5000 127.0.0.1 this machine only 1 node 300
tcp 5432 0.0.0.0 every interface container pg :5432
tcp 8080 192.168.1.10 eth0 node 300$ nethound map
box
listening on every interface
tcp 22 root's process (sudo to see it) ← 1 client
tcp 5432 container pg :5432
lo loopback up 127.0.0.1/8 ::1/128
routes ::1/128
listen udp 53 systemd-resolve's process (sudo to see it)
listen tcp 5000 node 300 ← 1 client
local curl 400 → :5000 node 300
eth0 ethernet up 192.168.1.10/24 fe80::5054:ff:fe12:3456/64
routes default via 192.168.1.1, 192.168.1.0/24
listen tcp 8080 node 300
out curl 400 → 140.82.121.4:443
br-abc bridge up 172.18.0.1/16 docker network app_default
routes 172.18.0.0/16
holds pg 172.18.0.2 ← host :5432
1 port: veth1
down, no address: docker0
dns 127.0.0.53 (local resolver)Dev servers in several worktrees
Two checkouts of the same app, each running bun run dev, look the same to ps: node …/vite. nethound
tells them apart by the git checkout and branch each runs in:
$ nethound who 5173
tcp 5173 node 41207
process node /home/me/src/app-feature/node_modules/.bin/vite (pid 41207, me)
checkout ~/src/app-feature branch feature-x (worktree)
started by bun 41190 ← zsh 40122
…
$ nethound kill 5173 --tree
tcp 5173 node 41207 app-feature (feature-x) started by bun 41190
and bun 41190 app-feature (feature-x) started by zsh 40122
stop them? [y/N] y
stopped 41190, 41207nethound watch shows the same live, and stops the selected one with k (its process) or K (with the
bun run dev, npm or watcher that started it, so nothing starts it again; never your shell).
Which of these is a web page? nethound open asks each port, and nethound open PORT opens one:
$ nethound open
http://localhost:3000 page (302) node 41207 app-feature (feature-x)
http://localhost:3002 API (404 application/json) container mock-server
http://localhost:16686 page container jaegerInstall
nethound is a single JavaScript file that runs on Node ≥ 20 or Bun, on Linux (WSL included), macOS and Windows. Without either, the installers fetch a standalone binary instead, which needs nothing.
npx @michelsalib/nethound map # or: bunx @michelsalib/nethound map, nothing to install
npm install -g @michelsalib/nethound # or: bun add -g @michelsalib/nethound
curl -fsSL https://github.com/michelsalib/nethound/releases/latest/download/install.sh | shTo pin a version, or to read the script before running it:
curl -fsSL https://github.com/michelsalib/nethound/releases/download/v0.10.0/install.sh -o install.sh
less install.sh && sh install.sh
gh attestation verify install.sh -R michelsalib/nethound # optional: proves GitHub Actions built itOn Windows, in PowerShell:
irm https://github.com/michelsalib/nethound/releases/latest/download/install.ps1 | iexWith a package manager, which installs the standalone binary:
brew install michelsalib/nethound/nethound # Homebrew, macOS and Linux
scoop bucket add nethound https://github.com/michelsalib/scoop-nethound
scoop install nethound/nethound # Scoop, Windowsinstall.sh puts nethound in ~/.local/bin (NETHOUND_INSTALL_DIR to change it, NETHOUND_VERSION=0.10.0
to pin a version), checks it against the release's SHA256SUMS, and runs it on Node, or on Bun when there
is no Node ≥ 20. With neither, or with NETHOUND_BINARY=1, it installs the standalone binary for your
system (Linux and macOS, x64 and arm64). install.ps1 does the same on Windows, in
%LOCALAPPDATA%\Programs\nethound: nethound.mjs and a nethound.cmd launcher, or nethound.exe.
Neither edits your PATH: they print what to run.
Completion. install.sh and Homebrew also install bash and fish completion; install.ps1 prints the
line for PowerShell. See Shell completion.
Formerly netbeagle. Up to 0.7.0, nethound was called netbeagle (npm install -g netbeagle). A copy
installed under that name updates to nethound like any other, then says after each command how to
switch to the new name. Environment variables are now NETHOUND_*.
Updates. Once a week nethound asks the npm registry, in the background, whether a newer version
exists, and when there is one says so after a command, with the update command for how you installed it:
nethound upgrade for install.sh and install.ps1, brew upgrade nethound, scoop update nethound,
npm install -g @michelsalib/nethound@latest, and so on. It stays quiet in scripts, CI, with --json, and with
NETHOUND_NO_UPDATE_CHECK=1. nethound upgrade never runs anything it downloads: the new file must match
the release's SHA256SUMS on GitHub and be the one published on npm (for a binary, the SHA256SUMS npm
has), so a tampered release would have to be tampered with on both. Then it replaces itself in one rename.
The binaries are release files too (nethound-linux-x64, nethound-linux-arm64, nethound-darwin-x64,
nethound-darwin-arm64, nethound-windows-x64.exe), listed in SHA256SUMS and attested like the rest;
see SECURITY.md.
On Linux and macOS, without root, nethound can't see other users' processes, so their sockets show no
process; sudo nethound shows them all. Windows names every socket's process, but shows other users'
command lines only in an administrator terminal. nethound only reads: see
what it does with your machine.
Commands
| Command | What it shows |
|---|---|
| nethound / nethound list | every listening port (TCP and UDP): the addresses it is bound to, who can reach it (this machine only, every interface, or the interfaces holding its address), how many clients are connected, and its owner: a process, or the docker container behind a published port |
| nethound who PORT | what listens on PORT: its command line, or its container with the address and network inside; where it is reachable from, and whether the firewall lets other machines in (with the rule that decides); on Linux, what listens on PORT inside containers and other network namespaces (and what takes a published port in its container); every client connected now, with its process when it runs here; and connections from this machine to PORT elsewhere. Exits 1 when there are none |
| nethound conns | every connection, grouped by process: inbound (←) to its ports and outbound (→), with the process, container or route at the other end, and the VPN it goes through. --all adds sockets opening or closing |
| nethound interfaces | every interface: kind (ethernet, wifi, bridge, veth, tun, wireguard…, VPNs marked with their adapter on Windows), state, addresses, routes, bridge ports, docker containers on it, traffic |
| nethound route HOST | where packets to HOST go: this machine, a container, an attached network, or a gateway; with the interface, the route and the source address, and whether they go through a VPN that is up. In WSL, where Windows sends them next: through Windows' VPN or not; a name only Windows resolves (a VPN's), Windows resolves |
| nethound watch | live: every port with its process, the git checkout and branch it runs in (or its directory), how long it has been up and its clients, refreshed every couple of seconds; ports that open or close are marked. Select one to see its command line and what started it; o opens it in the browser when it answers HTTP, k stops it, K stops it with what started it (bun run dev, npm, a watcher), / filters by branch, port or name. Click a port to select it, its URL to open it, scroll with the wheel, click the keys on the last line |
| nethound open [PORT] | opens what listens on PORT in the browser (in WSL, Windows' browser) once it answers HTTP or HTTPS; alone, lists every port a browser can open, page or API, with what holds it. Databases and caches are never asked; --no-probe asks nothing and opens http://localhost:PORT as is. Exits 1 when nothing answers |
| nethound kill PORT | stops what listens on PORT, after saying which process it is, in which checkout, started by what, and asking. --tree also stops what started it, --force kills at once, -y doesn't ask; a container is docker stopped |
| nethound doctor | what is wrong with how this machine listens and routes, each with what is behind it and its fix: databases, caches, debuggers and the docker API reachable from other machines (dev servers too, as notes); ports two programs share, with which one localhost reaches over IPv4 and over IPv6; servers on :: that refuse IPv4, or on ::1 only; in WSL, a Windows program on a port WSL serves, which Windows' localhost then reaches instead. Ports docker publishes to a container where nothing listens, or only the container's own 127.0.0.1. The firewall's answer comes with each: what it blocks is only a note. Then the routes: no default route, two with the same metric, a VPN taking all traffic or only some networks (in WSL, Windows' VPN, which WSL's traffic goes through; whether WSL asks its DNS servers; an MTU bigger than its), DNS servers with no route (or a private one only the default route reaches, often a disconnected VPN's). Exits 1 on errors |
| nethound map | the whole picture, interface by interface: what is open everywhere, what is bound to each interface, the containers behind each bridge (with the ports each listens on inside, on Linux), what listens in other network namespaces, the connections leaving through each one, which local process talks to which, and the DNS servers; in WSL, the VPN Windows runs |
| nethound completion bash\|zsh\|fish\|powershell | a shell completion script (commands, options, the ports listening now) |
| nethound upgrade | update an install.sh or install.ps1 install; for others, prints the update command |
nethound COMMAND --help (or nethound help COMMAND) shows one command's usage, options and examples.
if, ifaces, connections, graph and ls are accepted too.
Options: --json, --tcp, --udp, -a, --all, --no-docker (don't ask docker), --show-secrets (command
lines are shown with the values of --token=, --password and the like masked by default).
Shell completion
install.sh and Homebrew set up bash and fish completion for you. Otherwise:
eval "$(nethound completion bash)" # in ~/.bashrc
eval "$(nethound completion zsh)" # in ~/.zshrc, after compinit
nethound completion fish > ~/.config/fish/completions/nethound.fishIn PowerShell, add this line to your profile (notepad $PROFILE):
nethound completion powershell | Out-String | Invoke-ExpressionCompletion is dynamic: on each Tab the script asks nethound for candidates, so nethound kill <Tab>
and nethound who <Tab> offer the ports listening right now, each with what holds it:
$ nethound kill <Tab>
3000 -- node 4127 app-feature (feature-x)
5173 -- node 4200 app (main)
5432 -- container pgHow it works
Each system is read its own way, into the same picture, so every command works the same everywhere.
- Linux: sockets from
/proc/net/tcp,tcp6,udpandudp6; IPv4 clients of a socket bound to::(::ffff:1.2.3.4) are shown as IPv4. Each socket's process is found through thesocket:[inode]links in/proc/PID/fd. Without root, other users' processes can't be read: their sockets show the user only, andsudo nethoundshows them all. Interfaces from/sys/class/net(kind, state, bridge and its ports, traffic) and their addresses fromgetifaddrs; routes from the kernel's main table,/proc/net/routeand/proc/net/ipv6_route. - macOS: every user's sockets from
netstat -an(tcp46for a socket taking both families), and the process behind each fromlsof: without root, only your own, so other users' show no process andsudo nethoundshows them all. Interfaces fromifconfig -a(bridge members included), their traffic fromnetstat -ibn, which one is Wi-Fi fromnetworksetup; routes fromnetstat -rn, without the ARP entries and interface-scoped copies; processes fromps. - Windows: one PowerShell call. Sockets and their processes from
Get-NetTCPConnectionandGet-NetUDPEndpoint(netstattranslates its states into the system's language; these don't), interfaces, addresses, traffic and DNS servers from .NET'sNetworkInterface, routes fromGet-NetRoute(their metric plus the interface's, as Windows compares them), command lines fromWin32_Process. It takes a couple of seconds, PowerShell's start included. routepicks the longest matching prefix, then the lowest metric, as the system does (policy routing,ip ruleon Linux, is not replayed).- Containers:
docker inspectof the running containers gives their networks (and the host bridge each uses,docker0orbr-<network id>) and their published ports. A published port belongs to its container, whetherdocker-proxyholds a socket for it or the firewall forwards it with none. - Containers on macOS and Windows: Docker Desktop runs them in a VM, so their networks are not on this machine; a published port shows its container all the same.
- Who reaches what: a socket on
127.0.0.1,::1or another address of the loopback is reachable from this machine only; on0.0.0.0or::, from every interface; on one address, from the interface holding it. A socket on::takes IPv4 too by default on Linux (net.ipv6.bindv6onlyis 0) and on macOS (tcp46), and IPv6 only on Windows unless its program asks for both: those sayevery interface, IPv6 only. - The firewall (
whoanddoctor): whether a connection from another machine gets in, and the rule or default that decides. Read as the system keeps it:- Linux as root:
iptables-saveandip6tables-save(ufw's and docker's rules, whichever backend holds them) andnft -j list ruleset(firewalld's,/etc/nftables.conf), walked as the kernel does for a new connection: the input chains for a process, the forward chains for a port docker publishes. A rule that depends on the client (its address, a rate limit) makes itlets some machines in. Without root, ufw's settings (/etc/ufw/ufw.conf: on, and its default) and firewalld's zones (/etc/firewalld,/usr/lib/firewalld); ufw's own rules take root to read. Docker publishes ports before ufw's and firewalld's rules, so those don't stop them. - Windows: the firewall's profiles, each network's profile (
Get-NetConnectionProfile) and its inbound rules (by program, port and source), from the same PowerShell call. Store apps' rules are left out. Another firewall registered with Windows Security is named, not read. - macOS: the application firewall, from
socketfilterfw(on, block all, signed software, each app). pf's rules take root and are not read.
- Linux as root:
- WSL: Windows forwards its own
localhostto ports bound to127.0.0.1or every interface in the WSL VM, so those say+ Windows. - Connections between two processes of this machine are matched end to end, so
curl 400 → :5000 node 300names both sides. - Network namespaces (Linux): a container's own sockets, which the host's don't show, from
/proc/PID/netof its first process (docker inspectgives it): anyone can read it, sowhonames the ports a container listens on inside, anddoctorsees a published port with nothing behind it. Other namespaces (ip netns, rootless podman, sandboxes) are found by their processes'/proc/PID/ns/net: your own, or all as root. Which process holds a socket inside a container takes root, as on the host. Docker Desktop keeps containers in its VM: not read there. - DNS servers:
/etc/resolv.conf; behind systemd-resolved's127.0.0.53, the servers it asks too, from/run/systemd/resolve/resolv.conf. doctorin WSL also asks Windows which ports it listens on, with Windows' ownnetstat.exe,tasklist.exeandnetsh.exe interface portproxy(half a second; skipped when WSL can't run Windows programs).wslrelay, Docker Desktop and port proxies forward to WSL: they don't count.
Status
Linux (WSL included), macOS and Windows.
The pack
nethound has a sibling, built the same way: one small file for Node or Bun, or a standalone binary, nothing to configure.
| Tool | What it finds | Try it |
|---|---|---|
| nethound | which process or container listens on each port, who can reach it, and how everything on your machine connects | npx @michelsalib/nethound |
| envhound | which startup file (or registry key) sets each environment variable, and your PATH | npx @michelsalib/envhound |
Development
bun install
bun run dev -- map # run from source
bun test # unit tests on the fixtures of each system, plus the built CLI on node on this machine
bun run typecheck
bun run lint # Biome: formatting and lint
bun run build # dist/nethound.js, plain JS for node >= 20test/fixtures/root is a small Linux machine as /proc, /sys and /etc show it, written by
scripts/fixture.py (its docstring describes it); test/fixtures/macos holds what its commands print
on a Mac, and test/fixtures/windows what nethound's PowerShell script prints on Windows. Source uses only node: APIs, so the build runs on
both Node and Bun.
See CONTRIBUTING.md for pull requests and releases.
