Bash and Linux

List Listening Ports

easyNetworking and connectivity Must-do

Problem statement

Read saved ss -tlnp output and list every listening TCP port with the program behind it, and whether it can be reached from other machines or only from the server itself. "What is listening on this box?" is the first question when a service is unreachable, when a port is "already in use", and in every security review.

ss.txt (from sudo ss -tlnp)

TEXT
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=812,fd=6))
LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=900,fd=5))
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=650,fd=3))
LISTEN 0 511 [::]:80 [::]:* users:(("nginx",pid=812,fd=7))
LISTEN 0 4096 127.0.0.1:6379 0.0.0.0:* users:(("redis-server",pid=950,fd=6))
LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=650,fd=4))
LISTEN 0 4096 *:9100 *:* users:(("node_exporter",pid=1300,fd=3))

Print one line per port and program, sorted by port: the port, the program name, and all interfaces or local only. IPv4 and IPv6 listeners for the same port and program should appear once.

Expected output:

TEXT
== listening ports ==
22 sshd all interfaces
80 nginx all interfaces
5432 postgres local only
6379 redis-server local only
9100 node_exporter all interfaces

Hints

Hint 1: The local address is field 4, like 0.0.0.0:80 or [::]:22. The port is everything after the last :, which sub(/.*:/, "", port) leaves behind.

Approach

Optimal: ss -tlnp with awk

Covers: what listening means, ss -tlnp flags, bind addresses 0.0.0.0 / 127.0.0.1 / [::], splitting IPv6 addresses, awk match() and substr(), sort -n -u, netstat.

A listening port is an open door. A server program asks the kernel to listen on a port, and waits for connections. Two things matter: the port number, and the address it listens on, which decides who can reach it.

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart TB subgraph ALL["nginx on 0.0.0.0:80"] direction LR A1["other machines
can connect"]:::green ~~~ A3["this machine
can connect"]:::green end subgraph LOCAL["postgres on 127.0.0.1:5432"] direction LR L1["other machines
blocked"]:::red ~~~ L3["this machine
can connect"]:::green end ALL ~~~ LOCAL classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px style ALL fill:transparent,stroke:#2563eb,stroke-width:2px style LOCAL fill:transparent,stroke:#059669,stroke-width:2px
Bind address Who can connect
0.0.0.0 anyone who can reach any IPv4 address of this machine
[::] or * the same, for IPv6 (and often IPv4 too)
127.0.0.1, [::1] only programs on this machine
a specific IP, like 10.0.1.5 only through that network card

A database on 0.0.0.0:5432 is open to the network unless a firewall blocks it; on 127.0.0.1:5432 it is safe by design.

The ss flags. ss (socket statistics) replaced the older netstat:

Flag Means
-t TCP sockets (-u for UDP)
-l listening sockets only
-n numbers, not names (80, not http), which is faster and clearer
-p show the program; needs sudo to see other users' programs

Splitting the port off, IPv6 included. IPv6 addresses contain colons themselves, like [::]:22 or [2001:db8::1]:443. So "split at the first :" breaks them. The port is always after the last colon. In awk, sub(/.*:/, "", port) deletes everything up to the last colon, because .* grabs as much as it can, and leaves just the port. The address is the rest.

Finding the program name. The last field looks like users:(("nginx",pid=812,fd=6)). match($0, /\(\("[^"]+"/) finds (("nginx", and substr cuts out the name between the quotes. RSTART and RLENGTH are set by match() to where the match starts and how long it is.

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart TB F["[::]:22"]:::gray --> P(["sub(/.*:/, "")
keep after the last colon"]):::purple P --> PORT["port 22"]:::green F --> AD["address [::]"]:::blue classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px

Walking through the code. The # Setup: lines only save the sample output, so skip past them.

  1. NR > 1 skips the header. For each line, awk takes the port and address from field 4 and the program name from the end.
  2. Addresses 0.0.0.0, [::] and * become all interfaces; anything starting with 127. or [::1] becomes local only.
  3. sort -n -u sorts by port and removes the duplicate lines from IPv4 and IPv6 listeners of the same program.

One more program is open to the network besides SSH and the web server: node_exporter on 9100. If that is not meant to be public, a firewall rule or --web.listen-address=127.0.0.1:9100 fixes it.

Edge cases. Without sudo, the Process column is empty for programs owned by other users. A port can be listening and still unreachable because of a firewall or a cloud security group, so test from another machine too.

# Setup: save sample ss -tlnp output in a fresh temporary folder (on a server: sudo ss -tlnp)
cd "$(mktemp -d)"
cat > ss.txt << 'OUT'
State  Recv-Q Send-Q  Local Address:Port  Peer Address:Port Process
LISTEN 0      511           0.0.0.0:80         0.0.0.0:*     users:(("nginx",pid=812,fd=6))
LISTEN 0      4096        127.0.0.1:5432       0.0.0.0:*     users:(("postgres",pid=900,fd=5))
LISTEN 0      128           0.0.0.0:22         0.0.0.0:*     users:(("sshd",pid=650,fd=3))
LISTEN 0      511              [::]:80            [::]:*     users:(("nginx",pid=812,fd=7))
LISTEN 0      4096        127.0.0.1:6379       0.0.0.0:*     users:(("redis-server",pid=950,fd=6))
LISTEN 0      128              [::]:22            [::]:*     users:(("sshd",pid=650,fd=4))
LISTEN 0      4096                *:9100             *:*     users:(("node_exporter",pid=1300,fd=3))
OUT

echo "== listening ports =="
awk 'NR > 1 {
  port = $4; sub(/.*:/, "", port)                  # after the LAST colon
  addr = substr($4, 1, length($4) - length(port) - 1)
  name = "?"
  if (match($0, /\(\("[^"]+"/)) name = substr($0, RSTART + 3, RLENGTH - 4)
  if (addr == "0.0.0.0" || addr == "[::]" || addr == "*") where = "all interfaces"
  else if (addr ~ /^127\./ || addr == "[::1]")             where = "local only"
  else                                                      where = "only " addr
  printf "%-5s %-14s %s\n", port, name, where
}' ss.txt | sort -n -u
RecapThe whole problem in a few lines, for the night before
  • Spot it: "what is listening", "port already in use", "is this exposed"
  • Idea: sudo ss -tlnp; the port is after the last :, and 0.0.0.0 / [::] mean open to the network
  • Cost: instant
  • Trap: splitting IPv6 addresses at the first colon, or forgetting sudo so the program column is empty

Interview follow-ups

  • Flag any database port that is open to all interfaces.

    Keep a list of database ports, like 3306 5432 6379 27017, and pass it in with awk -v db=" 3306 5432 6379 27017 ". In the main block, test index(db, " " port " ") && where == "all interfaces" and print a warning. The spaces around each number stop 543 from matching 5432. Run it from cron with live ss -tlnp output, and you have a small exposure check for every server.

Frequently asked questions

sudo ss -tlnp 'sport = :8080' shows only the listener on that port, with the program and PID. sudo lsof -i :8080 and sudo fuser 8080/tcp give the same answer in other forms. Then decide: stop that program, or choose another port for yours. An old copy of your own service that did not shut down is the most common answer.

netstat -tlnp shows nearly the same table and you will see it in older guides. It comes from the net-tools package, which many new systems no longer install, while ss is part of iproute2 and is always there. ss is also much faster on busy servers with many connections. Learn ss, and recognise netstat when you meet it.

Check the path step by step. Is it bound to 0.0.0.0 and not 127.0.0.1? Is a host firewall blocking it (sudo iptables -L -n, sudo nft list ruleset or sudo ufw status)? In the cloud, does the security group or network ACL allow the port from your IP? Finally test from the server itself with curl localhost:80, then from another machine with nc -zv host 80, which tells you whether the problem is the app or the network.