Networking · lesson 1 of 8
Beginner
4Intermediate
4Also part of Networking
11- + Linux Networking: Routes, Interfaces, and Bind Addresses from Linux
- + Bash Networking from Bash
- + Docker Networking from Docker
- + FastAPI HTTP Methods and Status Codes from FastAPI
- + FastAPI Headers and Cookies from FastAPI
- + Cloud Networking Basics: What's the Same Everywhere from Multi-Cloud
- + VPC and Networking Basics from AWS
- + GCP VPC Networking Basics from Google Cloud
- + Azure Virtual Network Basics from Azure
- + The TLS Handshake, Run Locally with openssl s_server from Cryptography
- + Load Balancing: Round Robin vs Least Connections from System Design
IP Addresses and CIDR: Which Hosts Are on My Subnet?
Read CIDR with Python's ipaddress: network, broadcast and host counts, the /25 membership bug a string check hides, and a Docker network that enforces it.
By SkillByExample · Updated
Every networking question starts with an address and a question: is that host next to me, or behind a
router? A subnet answers it, and CIDR notation like 10.0.0.0/25 is how a subnet is written down. This
lesson reads CIDR with Python’s standard ipaddress module, shows the membership mistake that
looks right, and then lets Docker enforce the same rule on a real network. Tested with Docker Engine
29.8.2, Python 3.12.15 (python:3.12-slim) and Alpine 3.22.6.
Where this fits, as one table rather than a diagram:
| Layer | What it addresses | Lesson |
|---|---|---|
| Network (IP) | hosts, by IP address and subnet | this one |
| Transport (TCP/UDP) | processes on a host, by port | Ports and sockets |
| Application | names and requests | DNS, HTTP |
Read a CIDR block
# subnet.py
import ipaddress
net = ipaddress.ip_network("10.0.0.0/25")
print("network: ", net.network_address)
print("netmask: ", net.netmask)
print("broadcast:", net.broadcast_address)
print("addresses:", net.num_addresses)
hosts = list(net.hosts())
print("usable: ", len(hosts), "from", hosts[0], "to", hosts[-1])
$ python subnet.py
network: 10.0.0.0
netmask: 255.255.255.128
broadcast: 10.0.0.127
addresses: 128
usable: 126 from 10.0.0.1 to 10.0.0.126
/25 means the first 25 bits name the network and the last 7 number the hosts, so the block holds
2⁷ = 128 addresses. The first (.0) is the network address and the last (.127) is the broadcast
address. Neither can be given to a machine, which leaves 126. The netmask 255.255.255.128 is the same
prefix written as 25 one-bits followed by 7 zero-bits.
The bug: “it starts with 10.0.0., so it’s local”
A service at 10.0.0.130 is unreachable from a host on 10.0.0.0/25, and the address looks like it
belongs there. Here’s the check people write by eye, next to the real one:
# same_subnet.py
import ipaddress
server = "10.0.0.130"
subnet = "10.0.0.0/25"
# The bug: "it starts with 10.0.0., so it's on my subnet"
prefix = subnet.split("/")[0].rsplit(".", 1)[0] + "."
print("string check:", server.startswith(prefix))
# The real check compares the address against the mask
net = ipaddress.ip_network(subnet)
print("ipaddress check:", ipaddress.ip_address(server) in net)
print("first 25 bits of .130:", format(int(ipaddress.ip_address(server)), "032b")[:25])
print("first 25 bits of net: ", format(int(net.network_address), "032b")[:25])
$ python same_subnet.py
string check: True
ipaddress check: False
first 25 bits of .130: 0000101000000000000000001
first 25 bits of net: 0000101000000000000000000
The string check assumes subnet boundaries fall on the dots. A /25 boundary falls inside the last
octet: 130 is 10000010 in binary, and its leading 1 is bit 25, the last network bit. That one bit puts
.130 in the next block, 10.0.0.128/25. A host on 10.0.0.0/25 sends traffic for .130 to its
default gateway instead of straight to it, and if no router connects the two halves, the traffic never
arrives. Compare addresses with ipaddress, never with string prefixes.
Which network does a host belong to?
Given a host and its prefix, you often want its network. The obvious call refuses:
# host_bits.py
import ipaddress
print(ipaddress.ip_interface("10.0.0.130/25").network)
print(ipaddress.ip_network("10.0.0.130/25"))
$ python host_bits.py
10.0.0.128/25
Traceback (most recent call last):
File "/work/host_bits.py", line 5, in <module>
print(ipaddress.ip_network("10.0.0.130/25"))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.12/ipaddress.py", line 74, in ip_network
return IPv4Network(address, strict)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.12/ipaddress.py", line 1544, in __init__
raise ValueError('%s has host bits set' % self)
ValueError: 10.0.0.130/25 has host bits set
ip_interface models “this host, with this prefix”, which is exactly what ip addr shows on a Linux
box. Its .network answers the question: 10.0.0.130 lives in 10.0.0.128/25. ip_network models a
network, and a network address must have every host bit set to zero. The strictness is useful: a
firewall rule or route written as 10.0.0.130/25 almost always means someone misread the boundary.
Split a block into subnets
Cloud networks are carved this way: one large block for the VPC, smaller ones per subnet.
# split.py
import ipaddress
vpc = ipaddress.ip_network("10.0.0.0/24")
for sub in vpc.subnets(new_prefix=26):
print(f"{str(sub):<15} {sub.num_addresses - 2:>3} usable hosts")
$ python split.py
10.0.0.0/26 62 usable hosts
10.0.0.64/26 62 usable hosts
10.0.0.128/26 62 usable hosts
10.0.0.192/26 62 usable hosts
Each extra prefix bit halves the block, so going from /24 to /26 gives four quarters. Every subnet
loses two addresses to its own network and broadcast address, so four /26 blocks hold 248 usable hosts,
not 254. Cloud providers reserve a few more per subnet on top of that, which the
AWS VPC lesson shows.
Private, loopback, link-local, global
# classify.py
import ipaddress
for text in ["10.20.30.40", "172.16.5.1", "172.32.0.1", "192.168.1.10",
"127.0.0.1", "169.254.169.254", "8.8.8.8"]:
ip = ipaddress.ip_address(text)
kind = ("loopback" if ip.is_loopback else
"link-local" if ip.is_link_local else
"private" if ip.is_private else
"global" if ip.is_global else "other")
print(f"{text:<16} {kind}")
$ python classify.py
10.20.30.40 private
172.16.5.1 private
172.32.0.1 global
192.168.1.10 private
127.0.0.1 loopback
169.254.169.254 link-local
8.8.8.8 global
The three private ranges are 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16. The middle one catches
people out: /12 ends at 172.31.255.255, so 172.32.0.1 is a public address. 169.254.169.254 is
link-local, the address cloud instances use for their metadata service. Loopback and link-local are
checked first because Python also counts them as is_private. Nothing here sends a packet. These are
properties of the number alone.
Docker enforces the same arithmetic
A user-defined Docker network has a subnet, and Docker checks every address against it. Create one with
the /25 from above. --internal keeps it off your real network, which matters because 10.0.0.0
is a range home LANs and VPNs often use:
docker network create --internal --subnet 10.0.0.0/25 subnet-lab
$ docker run --rm --network subnet-lab alpine:3.22 ip -4 -o addr show eth0
2: eth0 inet 10.0.0.2/25 brd 10.0.0.127 scope global eth0\ valid_lft forever preferred_lft forever
$ docker run --rm --network subnet-lab alpine:3.22 ip route
10.0.0.0/25 dev eth0 scope link src 10.0.0.2
$ docker run --rm --network subnet-lab --ip 10.0.0.130 alpine:3.22 true
docker: Error response from daemon: invalid config for network subnet-lab: invalid endpoint settings:
no configured subnet contains IP address 10.0.0.130
Run 'docker run --help' for more information
$ docker run --rm --network subnet-lab --ip 10.0.0.100 alpine:3.22 ip -4 -o addr show eth0
2: eth0 inet 10.0.0.100/25 brd 10.0.0.127 scope global eth0\ valid_lft forever preferred_lft forever
ip addr prints the container’s address as an interface, 10.0.0.2/25, with the broadcast address
ipaddress computed. Docker gave the first usable host, .1, to the network’s gateway, which is why the
container got .2. The route table shows the decision from the bug section as the kernel makes it:
addresses in 10.0.0.0/25 go straight out of eth0. There’s no default via line, because an
internal network has no gateway route off the subnet, so every other address is unreachable. Drop
--internal and a default via 10.0.0.1 line appears for everything else. Docker
rejects .130 for the reason ipaddress printed False, and accepts .100. Remove the network with
docker network rm subnet-lab. Routes and interfaces are covered in more depth in
Linux networking basics.
Practice
- Size the blocks. Print the address count and the number of usable hosts for
/24,/26,/28,/30,/31and/32. Two of them break the “minus two” rule. Which ones?
Solution
# sizes.py
import ipaddress
for prefix in (24, 26, 28, 30, 31, 32):
net = ipaddress.ip_network(f"10.0.0.0/{prefix}")
print(f"/{prefix:<3} {net.num_addresses:>4} addresses {len(list(net.hosts())):>4} hosts")
$ python sizes.py
/24 256 addresses 254 hosts
/26 64 addresses 62 hosts
/28 16 addresses 14 hosts
/30 4 addresses 2 hosts
/31 2 addresses 2 hosts
/32 1 addresses 1 hosts
/31 and /32. A /31 is a point-to-point link (RFC 3021) with no room for a network or broadcast
address, so both addresses are hosts. A /32 is one host, which is how a single address is written in a
firewall rule or route.
- Local or via the gateway? An office uses
192.168.10.0/26. For192.168.10.5,.63,.64and192.168.11.5, print whether a host in the office reaches it directly or through the gateway.
Solution
# who_is_local.py
import ipaddress
office = ipaddress.ip_network("192.168.10.0/26")
for text in ["192.168.10.5", "192.168.10.63", "192.168.10.64", "192.168.11.5"]:
where = "local" if ipaddress.ip_address(text) in office else "via gateway"
print(f"{text:<15} {where}")
$ python who_is_local.py
192.168.10.5 local
192.168.10.63 local
192.168.10.64 via gateway
192.168.11.5 via gateway
A /26 holds 64 addresses, so the block ends at .63. .63 is technically the broadcast address, but
it’s still “on the subnet”. .64 starts the next block.
- Tidy a rule list. A firewall has rules for
10.0.0.0/25,10.0.0.128/25,10.0.1.0/25and10.0.2.0/24. Useipaddress.collapse_addressesto find the shortest equivalent list.
Solution
# collapse.py
import ipaddress
nets = [ipaddress.ip_network(n) for n in
["10.0.0.0/25", "10.0.0.128/25", "10.0.1.0/25", "10.0.2.0/24"]]
for n in ipaddress.collapse_addresses(nets):
print(n)
$ python collapse.py
10.0.0.0/24
10.0.1.0/25
10.0.2.0/24
The two halves of 10.0.0.0/24 merge into one. 10.0.1.0/25 can’t merge with anything, because its
other half, 10.0.1.128/25, isn’t in the list, and 10.0.1.0/24 would allow addresses the original rules
didn’t.
- Ask Docker for the edges. Create a network with
--subnet 10.0.0.0/28and try--ipvalues of10.0.0.14,10.0.0.15and10.0.0.16. Predict which ones work before you run them.
Solution
$ docker network create --subnet 10.0.0.0/28 tiny-lab
$ docker run --rm --network tiny-lab --ip 10.0.0.14 alpine:3.22 ip -4 -o addr show eth0
2: eth0 inet 10.0.0.14/28 brd 10.0.0.15 scope global eth0\ valid_lft forever preferred_lft forever
$ docker run --rm --network tiny-lab --ip 10.0.0.15 alpine:3.22 ip -4 -o addr show eth0
docker: Error response from daemon: failed to set up container networking: Address already in use
Run 'docker run --help' for more information
$ docker run --rm --network tiny-lab --ip 10.0.0.16 alpine:3.22 ip -4 -o addr show eth0
docker: Error response from daemon: invalid config for network tiny-lab: invalid endpoint settings:
no configured subnet contains IP address 10.0.0.16
Run 'docker run --help' for more information
.14 is the last usable host. .15 is inside the subnet but is its broadcast address, which Docker
reserves, so it’s reported as already in use. .16 is outside the /28 altogether, so the request
fails the same way .130 did. Clean up with docker network rm tiny-lab.