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:

CodeStateCodeState
01ESTABLISHED06TIME_WAIT
02SYN_SENT07CLOSE
03SYN_RECV08CLOSE_WAIT
04FIN_WAIT109LAST_ACK
05FIN_WAIT20ALISTEN

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 ss is not available: filter state == "LISTEN" and join with the owner. It answers the question “which process is on port 5432” without lsof.
  • Confirming a connection is really established from inside a container. /proc/net/tcp is 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.