Part 5 — Ports and the transport layer
IP got the packet to the right machine. Now: which program on that machine, and did everything arrive?
Chapter 19 — Ports and sockets: which program gets the data?
In one sentence
A port is a number that says which program on a machine a packet is for, and a connection is identified by the combination of both machines' addresses and ports.
The problem
Your laptop has one IP address and is running fifty programs — a browser with twelve tabs, an email client, a chat app, a music streamer. A packet arrives. Which program gets it?
The idea, in plain language
Back to the office building analogy from Chapter 2:
IP ADDRESS = the building's street address "12 High Street"
PORT NUMBER = the room/department number "Room 443"
The postman gets the letter to the building using the street address. The receptionist gets it to the right room using the room number.
The numbers
A port is a 16-bit number: 0 to 65535.
| Range | Name | Used for |
|---|---|---|
| 0 – 1023 | Well-known | Standard services. Binding to these usually needs admin rights. |
| 1024 – 49151 | Registered | Applications that registered a number (MySQL 3306, Postgres 5432) |
| 49152 – 65535 | Ephemeral | Temporary, random ports your machine picks when it starts a connection |
The well-known ports worth knowing by heart (the rest are in Appendix D):
22 SSH 53 DNS 80 HTTP 443 HTTPS
25 SMTP (mail) 123 NTP (time) 3306 MySQL 5432 PostgreSQL
67/68 DHCP 3389 RDP 6379 Redis 27017 MongoDB
The four-part identity of a connection
This is the key concept. A connection is identified by four things together:
(your IP, your port, their IP, their port)
Example -- two browser tabs to the same website:
tab 1: 192.168.1.50 : 51000 <--> 93.184.216.34 : 443
tab 2: 192.168.1.50 : 51001 <--> 93.184.216.34 : 443
^^^^^
only this differs -- and that's enough
This is called the 4-tuple (or 5-tuple, including the protocol). It's why:
- One server on port 443 can handle millions of simultaneous connections — they all differ in the client's address and port.
- Your machine can have many connections to the same server at once.
- The receiving machine can always tell them apart.
The server listens on a fixed, well-known port (443). The client uses a random ephemeral port so its replies can be sorted out.
Listening vs connected
ss -tuln # Linux: listening TCP+UDP sockets
netstat -an | grep LISTEN # macOS
State Local Address:Port Peer Address:Port
LISTEN 0.0.0.0:22 0.0.0.0:* <- SSH, waiting for anyone
LISTEN 127.0.0.1:5432 0.0.0.0:* <- Postgres, LOCAL ONLY
ESTAB 192.168.1.50:51000 93.184.216.34:443 <- an actual connection
Note the difference between 0.0.0.0:22 and 127.0.0.1:5432:
0.0.0.0means "listen on all my addresses" — reachable from the network.127.0.0.1means "listen on loopback only" — reachable only from this machine.
This distinction causes an enormous number of "why can't I connect to my server?" problems. Your database is running, the port is open, but it's bound to
127.0.0.1so nothing outside the machine can reach it. Check this first, always.
Practice (25 min)
A. See what's listening on your machine:
# Linux
ss -tulnp
# macOS
sudo lsof -iTCP -sTCP:LISTEN -n -P
netstat -an | grep LISTEN
For each one, ask: do I know what that is, and should it be reachable from the network? This is a genuinely useful security habit.
B. See your active connections:
ss -tn state established # Linux
netstat -an | grep ESTABLISHED # macOS
Open a few browser tabs and run it again. Watch the count grow.
C. Be a server and a client yourself — this makes ports concrete:
# Terminal 1: listen on port 9999
nc -l 9999
# Terminal 2: connect to it and type something
nc 127.0.0.1 9999
# whatever you type appears in terminal 1. You just built a chat app.
# Terminal 3: watch the connection exist
ss -tn '( sport = :9999 or dport = :9999 )'
D. Prove the binding-address rule:
# Bind to loopback only
python3 -m http.server 8000 --bind 127.0.0.1
# From this machine: curl http://127.0.0.1:8000 -> works
# From another device: curl http://<your-ip>:8000 -> FAILS
# Now bind to everything
python3 -m http.server 8000 --bind 0.0.0.0
# From another device: works.
E. Check whether a remote port is open:
nc -zv example.com 443 # "succeeded" or "refused"/timeout
nc -zv example.com 444 # almost certainly nothing there
Build it in Go (25 min) — the binding rules, tried for real
Three rules from this chapter cause real outages: one listener per
address:port, a loopback bind only accepts loopback traffic, and the
less well-known SO_REUSEPORT, which lets several sockets share one port on
purpose.
//go:build linux || darwin
// bindlab: Chapter 19's binding rules, tried for real.
//
// go run ./bindlab conflict # two listeners on one port -> EADDRINUSE
// go run ./bindlab reuseport # SO_REUSEPORT: four listeners share one port
// go run ./bindlab loopback # 127.0.0.1 vs 0.0.0.0, and what each accepts
package main
import (
"context"
"errors"
"fmt"
"net"
"os"
"sync"
"syscall"
"time"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("usage: bindlab conflict|reuseport|loopback")
os.Exit(2)
}
switch os.Args[1] {
case "conflict":
conflict()
case "reuseport":
reusePort()
case "loopback":
loopback()
}
}
func conflict() {
a, err := net.Listen("tcp", "127.0.0.1:7300")
if err != nil {
fmt.Println(err)
return
}
defer a.Close()
_, err = net.Listen("tcp", "127.0.0.1:7300")
fmt.Println("second listen:", err)
fmt.Println("EADDRINUSE? ", errors.Is(err, syscall.EADDRINUSE))
}
// reusePort opens four sockets on the SAME port with SO_REUSEPORT set before
// bind(). The kernel then spreads incoming connections across them -- the
// trick nginx, Envoy, and HAProxy use to scale accept() across CPU cores.
func reusePort() {
lc := net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
var opErr error
err := c.Control(func(fd uintptr) {
opErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEPORT, 1)
})
if err != nil {
return err
}
return opErr
},
}
counts := make([]int, 4)
var mu sync.Mutex
for i := range counts {
ln, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:7301")
if err != nil {
fmt.Println("listener", i, err)
return
}
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
mu.Lock()
counts[i]++
mu.Unlock()
c.Close()
}
}()
}
for i := 0; i < 400; i++ {
if c, err := net.Dial("tcp", "127.0.0.1:7301"); err == nil {
c.Close()
}
}
time.Sleep(200 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
fmt.Println("connections accepted per listener:", counts)
fmt.Println("(Linux hashes each connection to one socket -> roughly even;")
fmt.Println(" macOS/BSD semantics differ -> often all on one listener)")
}
func loopback() {
lo, _ := net.Listen("tcp", "127.0.0.1:7302")
all, _ := net.Listen("tcp", "0.0.0.0:7303")
defer lo.Close()
defer all.Close()
// Find a non-loopback address of this machine to test "from the network".
c, err := net.Dial("udp", "1.1.1.1:53")
if err != nil {
fmt.Println("no network address to test with:", err)
return
}
lan := c.LocalAddr().(*net.UDPAddr).IP.String()
c.Close()
for _, t := range []string{"127.0.0.1:7302", lan + ":7302", "127.0.0.1:7303", lan + ":7303"} {
conn, err := net.DialTimeout("tcp", t, time.Second)
if err != nil {
fmt.Printf("%-22s FAIL %v\n", t, err)
continue
}
conn.Close()
fmt.Printf("%-22s ok\n", t)
}
fmt.Println("\nA socket bound to 127.0.0.1 only accepts traffic addressed to 127.0.0.1.")
fmt.Println("No firewall rule can change that: fix the bind address, not the firewall.")
}
$ go run ./bindlab conflict
second listen: listen tcp 127.0.0.1:7300: bind: address already in use
EADDRINUSE? true
$ go run ./bindlab loopback
127.0.0.1:7302 ok
192.168.1.2:7302 FAIL dial tcp 192.168.1.2:7302: connect: connection refused
127.0.0.1:7303 ok
192.168.1.2:7303 ok
$ go run ./bindlab reuseport # on Linux
connections accepted per listener: [103 105 104 88]
$ go run ./bindlab reuseport # on macOS
connections accepted per listener: [0 0 0 400]
What to notice:
- "Refused" from your own LAN address. The 127.0.0.1 listener exists, but
the connection was addressed to 192.168.1.2:7302, where nothing listens.
This is the most common "works on my machine" bug in container and VM
setups: the app binds
127.0.0.1and everything outside the host gets refused. Fix the bind, not the firewall. ListenConfig.Controlruns your code on the raw file descriptor aftersocket()but beforebind(). That's the only moment socket options likeSO_REUSEPORTcan be set. This hook is how Go programs reach any socket option thenetpackage doesn't wrap.- The same code behaves differently on Linux and macOS. Linux load-balances
new connections across
SO_REUSEPORTsockets by hashing the 4-tuple. BSD-derived systems historically deliver to one socket. Always test kernel behaviour on the kernel you deploy to.
A real incident this explains: during testing for this guide, a backend
bound to 127.0.0.1:8081 was killed. Instead of getting "connection
refused", the clients started receiving 404s. Another program (Docker
Desktop) was listening on the wildcard *:8081. The kernel gives each
connection to the most specific matching listener. While the backend was
alive, it was more specific. Once it died, the wildcard listener got the
traffic. If you see "wrong service answered", check ss -ltnp /
lsof -nP -iTCP -sTCP:LISTEN for overlapping binds.
Exercises:
- Start
python3 -m http.server 8000 --bind 0.0.0.0, then try tonet.Listen("tcp", "127.0.0.1:8000")in Go. Does it conflict? (On Linux it does: a wildcard bind covers every address.) - Run
reuseportunderstrace -f -e trace=setsockopt,bind,listenand find the foursetsockopt(... SO_REUSEPORT ...)calls. - Read how Linux's Chapter 37 on network views shows
these listeners with
ss -ltnp, then find which process owns each.
Common confusions
- "Is a port a physical thing?" No. It's purely a number in a packet header. Nothing plugs into it.
- "Port 80 vs port 8080?" Both are just numbers. 80 is the convention for HTTP; 8080 is a common alternative that doesn't need admin rights.
- "Open port vs listening port?" "Listening" = a program is waiting on it. "Open" usually means a firewall permits traffic to it. You need both.
- "Connection refused vs timeout?" Refused = the machine is there and actively said no (nothing listening). Timeout = nothing came back at all (firewall dropping silently, or host unreachable). Very different diagnoses.
Check yourself
- Why does a connection need four numbers to identify it, not two?
- What's the difference between binding to
0.0.0.0and127.0.0.1? - If a server listens on 443 and a million clients connect, how does it tell them apart?
- What's the difference between "connection refused" and "connection timed out"?
Further reading
- Video: "Ports and Sockets Explained" — Practical Networking.
- Article: "What is a computer port?" — Cloudflare Learning Center.
- Reference: the IANA port registry (search "IANA service name port number registry") — the official list.
- Blog: Julia Evans' zine "How to be a Wizard Programmer" / her networking
tools posts cover
ss,netstat, andlsofwell.