A container image I was debugging had no netstat, no ss, and no package manager worth using. It did have /proc, which is where all of those tools get their data anyway.
/proc/net/tcp is a plain text table, one line per socket:
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 28411 1
1: 0B00007F:9C40 0100007F:1F90 01 00000000:00000000 00:00000000 00000000 0 0 30117 1
The columns that matter most of the time are local_address, rem_address, st, and inode. The addresses are hex, and not in the order you expect: each 32-bit group is stored in host byte order, so 0100007F is 7F.00.00.01, or 127.0.0.1. The port is plain hex: 1F90 is 8080.
The state field is a two-character hex code:
| Code | State | Code | State |
|---|---|---|---|
| 01 | ESTABLISHED | 06 | TIME_WAIT |
| 02 | SYN_SENT | 07 | CLOSE |
| 03 | SYN_RECV | 08 | CLOSE_WAIT |
| 04 | FIN_WAIT1 | 09 | LAST_ACK |
| 05 | FIN_WAIT2 | 0A | LISTEN |
A short Python reader, no dependencies:
import socket
STATES = {
"01": "ESTABLISHED", "02": "SYN_SENT", "03": "SYN_RECV",
"04": "FIN_WAIT1", "05": "FIN_WAIT2", "06": "TIME_WAIT",
"07": "CLOSE", "08": "CLOSE_WAIT", "09": "LAST_ACK", "0A": "LISTEN",
}
def decode(addr):
ip_hex, port_hex = addr.split(":")
ip = socket.inet_ntoa(bytes.fromhex(ip_hex)[::-1])
return ip, int(port_hex, 16)
def read(path="/proc/net/tcp"):
with open(path) as f:
next(f)
for line in f:
parts = line.split()
lip, lport = decode(parts[1])
rip, rport = decode(parts[2])
yield {
"local": f"{lip}:{lport}",
"remote": f"{rip}:{rport}" if rport else "*",
"state": STATES.get(parts[3], parts[3]),
"inode": parts[9],
}
/proc/net/tcp6 has the same layout with four 32-bit groups per address, each byte-swapped the same way. That is why a naive bytes.fromhex on the v6 field produces nonsense.
Mapping a socket back to a process is the part people miss. The kernel does not name the process in /proc/net/tcp; it gives you the socket inode. The process is whichever PID has a file descriptor pointing at socket:[<inode>]:
import glob, os
def owner(inode):
target = f"socket:[{inode}]"
for fd in glob.glob("/proc/[0-9]*/fd/*"):
try:
if os.readlink(fd) == target:
pid = fd.split("/")[2]
with open(f"/proc/{pid}/comm") as f:
return pid, f.read().strip()
except OSError:
continue
return None, None
This is O(processes × descriptors) and takes a couple of seconds on a busy machine. Fine for a one-off. If you need this continuously, ss -tanp does the same walk in C, and ss is worth installing when you can.
Two things I use this for:
- Finding what is listening when
ssis not available: filterstate == "LISTEN"and join with the owner. It answers the question “which process is on port 5432” withoutlsof. - Confirming a connection is really established from inside a container.
/proc/net/tcpis per network namespace, so what you see is the container’s view, not the host’s. That distinction has saved me from chasing a firewall rule that was not involved.
The whole script is about forty lines and has no dependencies beyond the standard library. It is not a replacement for ss, but it works in the places where ss is not installed, and reading it once made the output of ss much less mysterious.