DHCP Relay and NAT
DHCP Relay and NAT
Edges do more than forward packets. They hand out addresses (or relay DHCP) and often translate private space toward a provider. This chapter keeps both topics vendor-agnostic and lab-first: Linux services + FRR routing in Containerlab (mid-2026 free path).
Learning goals
By the end of this chapter you can:
- Explain DHCP discover/offer/request/ack across a relay
- Configure a simple DHCP server and a relay path in a lab
- Contrast source NAT (masquerade), static NAT, and “why not NAT everything forever”
- Implement Linux
nftables/iptablesMASQUERADE on an edge
- Predict breakage when relays, helpers, or return translations fail
Concepts — DHCP
Why not static everything?
Labs love static host IPs. Real access LANs use DHCP for:
| Benefit | Trade-off |
|---|---|
| Central policy (lease, DNS, gateway) | Dependency on server/relay |
| Fast readdressing | Lease timers and “sticky” clients |
| Audit of who got what | Need logging and hygiene |
Four-way dance (v4)
Client Relay (optional) Server
|-- Discover ------(unicast to server)-->|
|<-- Offer -------------------------------|
|-- Request ----------------------------->|
|<-- Ack ---------------------------------|
On a flat LAN, broadcast Discover reaches the server. Across subnets, a relay agent (ip helper / DHCP relay) listens on the client VLAN and unicasts to the server, inserting giaddr (gateway IP of the client interface) so the server knows which pool to use.
Critical fields
| Field / idea | Why it matters |
|---|---|
| Client MAC / Client-ID | Lease binding |
| giaddr | Selects pool / subnet |
| Options: router, DNS, domain | Host becomes usable |
| Lease time | How long address is “owned” |
Relay failure modes
- Wrong giaddr → wrong pool or no offer
- ACL blocking UDP 67/68
- Server unreachable from relay
- Multiple relays without design → duplicate offers chaos
Concepts — NAT family
| Kind | Meaning |
|---|---|
| SNAT / MASQUERADE | Rewrite source as packets leave (common “PAT”) |
| DNAT | Rewrite destination (port forward / publish service) |
| 1:1 static NAT | Stable mapping for a host |
| NAT64 / CLAT | v6/v4 transition tools (awareness only here) |
NAT is state + rewrite. It is not a security model by itself. Stateful firewalls may sit with NAT; keep roles clear.
When NAT is appropriate in this book
- Lab edge simulating consumer/ISP CPE
- Hiding RFC1918 behind a single lab “public”
- Temporary access during dual-stack migration
Prefer real global or ULA design for multi-site fabrics later—NAT at every hop becomes undebugable.
Addressing plan (combined edge lab)
h-client (DHCP) dhcp-srv
| |
lan ---- edge-rtr ---- pe (public-ish)
|
NAT outside
| Net | Use |
|---|---|
| 10.20.30.0/24 | Client LAN (DHCP pool) |
| 10.20.30.1 | Edge LAN IP + VRRP VIP optional |
| 172.31.0.0/30 | Edge–server link for DHCP server placement |
| 198.51.100.0/30 | Edge–PE “public” lab space (TEST-NET) |
| Pool | 10.20.30.100–200, GW 10.20.30.1, DNS 10.20.30.1 or public lab DNS |
Topology YAML
name: dhcp-nat-edge
topology:
nodes:
edge:
kind: linux
image: alpine:3.20
# or FRR image if you co-locate routing; Alpine for pure Linux NAT/relay
exec:
- apk add --no-cache iproute2 iptables iputils dhcrelay dnsmasq
pe:
kind: linux
image: alpine:3.20
exec:
- apk add --no-cache iproute2 iputils
- ip addr add 198.51.100.2/30 dev eth1
- ip link set eth1 up
- ip addr add 203.0.113.1/32 dev lo
- ip link set lo up
dhcpsrv:
kind: linux
image: alpine:3.20
exec:
- apk add --no-cache iproute2 dnsmasq iputils
- ip addr add 172.31.0.2/30 dev eth1
- ip link set eth1 up
client:
kind: linux
image: alpine:3.20
exec:
- apk add --no-cache iproute2 dhclient iputils
lan:
kind: linux
image: alpine:3.20
exec:
- apk add --no-cache iproute2
- ip link add br0 type bridge && ip link set br0 up
links:
- endpoints: ["client:eth1", "lan:eth1"]
- endpoints: ["edge:eth1", "lan:eth2"]
- endpoints: ["edge:eth2", "dhcpsrv:eth1"]
- endpoints: ["edge:eth3", "pe:eth1"]Bridge enslavement (run after deploy or in richer exec scripts):
# on lan node — enslaves data ports into br0
for i in eth1 eth2; do ip link set $i master br0; ip link set $i up; doneEdge addressing and forward
# edge LAN toward clients
docker exec clab-dhcp-nat-edge-edge ip addr add 10.20.30.1/24 dev eth1
docker exec clab-dhcp-nat-edge-edge ip link set eth1 up
# toward DHCP server
docker exec clab-dhcp-nat-edge-edge ip addr add 172.31.0.1/30 dev eth2
docker exec clab-dhcp-nat-edge-edge ip link set eth2 up
# toward PE
docker exec clab-dhcp-nat-edge-edge ip addr add 198.51.100.1/30 dev eth3
docker exec clab-dhcp-nat-edge-edge ip link set eth3 up
docker exec clab-dhcp-nat-edge-edge sysctl -w net.ipv4.ip_forward=1
docker exec clab-dhcp-nat-edge-edge ip route add default via 198.51.100.2Bake these into startup scripts for real lab-as-code.
DHCP server (dnsmasq) on dhcpsrv
docker exec clab-dhcp-nat-edge-dhcpsrv sh -c 'cat >/etc/dnsmasq.conf <<EOF
interface=eth1
dhcp-range=10.20.30.100,10.20.30.200,12h
dhcp-option=option:router,10.20.30.1
dhcp-option=option:dns-server,10.20.30.1
log-dhcp
EOF'
docker exec clab-dhcp-nat-edge-dhcpsrv dnsmasqServer is not on 10.20.30.0/24—the relay’s giaddr must make the pool selection work. For dnsmasq/simple labs, operators often put the server on the client subnet first (no relay). Teach both:
- Same subnet: client broadcast reaches server—no relay.
- Off-subnet: relay required; server must understand the client subnet (shared-network / giaddr-aware config).
Minimal same-subnet lab (sanity first)
Place dnsmasq on edge’s LAN IP or a server on the bridge:
dhcp-range=10.20.30.100,10.20.30.200,12h
dhcp-option=option:router,10.20.30.1
Relay path (concept + command)
# edge: relay client-facing interface toward server
docker exec clab-dhcp-nat-edge-edge dhcrelay -i eth1 172.31.0.2Predict (DHCP)
- Client sends Discover; server logs Offer for 10.20.30.x
- Client installs default via 10.20.30.1
- Lease appears in server logs
Observe
docker exec clab-dhcp-nat-edge-client ip link set eth1 up
docker exec clab-dhcp-nat-edge-client dhclient eth1 -v
docker exec clab-dhcp-nat-edge-client ip addr show eth1
docker exec clab-dhcp-nat-edge-client ip route
docker exec clab-dhcp-nat-edge-dhcpsrv cat /var/lib/misc/dnsmasq.leases 2>/dev/null || trueFix
| Symptom | Checks |
|---|---|
| No offer | bridge, interface up, firewall, wrong interface in dnsmasq |
| Offer but wrong GW | dhcp-option router |
| Relay silent | UDP 67/68, giaddr, server route |
NAT — MASQUERADE on edge
Clients use private 10.20.30.0/24; PE only knows 198.51.100.0/30.
docker exec clab-dhcp-nat-edge-edge iptables -t nat -A POSTROUTING \
-o eth3 -j MASQUERADE
# nftables equivalent pattern:
# nft add table ip nat
# nft add chain ip nat postrouting { type nat hook postrouting priority 100 \; }
# nft add rule ip nat postrouting oifname "eth3" masqueradePE needs a way to answer (connected route to 198.51.100.1 is enough for return of MASQUERADE traffic).
Predict
client → 203.0.113.1works
- On pe, sources appear as 198.51.100.1, not 10.20.30.x
- Without MASQUERADE, pe has no return path to 10.20.30.0/24
Observe
docker exec clab-dhcp-nat-edge-client ping -c 3 203.0.113.1
docker exec clab-dhcp-nat-edge-pe sh -c 'apk add --no-cache tcpdump; tcpdump -ni eth1 -c 5 icmp'
docker exec clab-dhcp-nat-edge-edge iptables -t nat -L -n -vFix
ip_forward=1
- Correct
-oegress interface
- Default route on client and edge
- Conntrack full / flush during experiments:
conntrack -Fif tool present
Drill — break DHCP then NAT
- Stop dnsmasq → new client gets nothing; journal “dependency.”
- Remove MASQUERADE → pings to PE fail; add static route on PE for 10.20.30.0/24 via edge as a teaching alternate (routing vs NAT).
- DNAT publish (optional): map 198.51.100.1:8080 → client:80 and curl from pe.
docker exec clab-dhcp-nat-edge-edge iptables -t nat -A PREROUTING \
-i eth3 -p tcp --dport 8080 -j DNAT --to-destination 10.20.30.100:80Only works if a client actually holds that lease/IP and listens.
FRR on the edge (optional co-location)
Many designs run FRR for BGP/OSPF and Linux for NAT/DHCP. Pattern:
- FRR owns routing (
vtysh)
- Linux netfilter owns NAT
- DHCP server may be centralized; FRR/host runs relay
Do not assume FRR “is” the NAT engine—check which process owns rewrite.
Verification checklist
| Check | Command / intent |
|---|---|
| Lease | client has /24 + default |
| Options | DNS/GW match design |
| Relay | server log shows giaddr path |
| NAT | capture shows public source |
| Forward | sysctl ip_forward |
| Rules | iptables/nft list nat |
Common mistakes
| Mistake | Symptom |
|---|---|
| DHCP server wrong subnet logic | No lease or wrong mask |
| Client static leftover IP | “DHCP broken” false positive |
| NAT without hairpin plan | Local services odd |
| Forgetting return routing when NAT removed | One-way pings |
| Blocking bootp/dhcp in filter | Silent failure |
Summary
- DHCP centralizes host addressing; relay extends it across L3
- giaddr and pool design matter as much as the daemon
- NAT rewrites and tracks state—use deliberately, verify with captures
- Linux edge + Containerlab is enough to practice both without licenses
- Prefer lab scripts that start dnsmasq, relay, and NAT rules idempotently
Next: name, time, and log hygiene—the quiet services that make edge failures diagnosable.