ACL Thinking
ACL Thinking
Access control lists are not a pile of lines—they are a decision policy evaluated in order. This chapter builds vendor-agnostic ACL decision patterns and implements them with Linux nftables/iptables and FRR filters in Containerlab (mid-2026 free tools).
Learning goals
By the end of this chapter you can:
- Write allow/deny policies with explicit default stance
- Place filters at the right plane (data plane vs route policy vs management)
- Implement Linux netfilter rules that match a written policy
- Apply FRR prefix-lists/route-maps without confusing them with packet ACLs
- Predict asymmetric ACL failures and fix with evidence
Concepts
Three different “filters” people mix up
| Kind | Acts on | Example |
|---|---|---|
| Packet ACL / firewall | Data-plane packets | nftables drop tcp/23 |
| Route policy | Routes in RIB/advertisements | prefix-list on BGP |
| Control-plane protection | Traffic to the CPU/RE | CoPP / policers (next chapter) |
Using a BGP prefix-list will not stop a host scan. Dropping packets will not stop a bad route install (unless you couple systems).
Policy statement template
Before typing rules, write:
Stance: default deny (or default allow—pick deliberately)
Allow:
- established/related (if stateful)
- DNS to resolver R from clients
- HTTPS to internet from clients
- BGP from peer P on interface I
Deny:
- everything else (log sample)
First-match vs best-match
Most packet ACLs are first match wins. Order is part of the policy. Route filters also often first-match in lists. Never “add a deny at the top” without reading the whole list.
Stateful vs stateless
| Stateless | Stateful | |
|---|---|---|
| Idea | Each packet alone | Track flows/conntrack |
| Return traffic | Must allow reverse explicitly | Often allow established |
| Lab tools | Simple iptables rules | iptables/nft conntrack |
Direction and attachment
inbound on client LAN ≠ inbound on uplink
Always specify interface + direction + zone. Diagram the edge:
[clients] --in--> | edge | --out--> [uplink]
| |
+-- mgmt
Written policy for the lab
Edge role: small site router (Linux).
- Default drop forwarded traffic not explicitly allowed.
- Allow clients (
10.60.0.0/24) to ping and HTTP to lab server10.60.1.10.
- Allow clients DNS to
10.60.0.53.
- Allow SSH only from mgmt host
10.60.0.9to edge.
- Deny client access to edge SSH from other addresses.
- Log dropped new TCP (rate-limited mentally—don’t flood disks).
Topology YAML
name: acl-think
topology:
nodes:
edge:
kind: linux
image: alpine:3.20
exec:
- apk add --no-cache iproute2 iptables iputils curl
client:
kind: linux
image: alpine:3.20
exec:
- apk add --no-cache iproute2 iputils curl
mgmt:
kind: linux
image: alpine:3.20
exec:
- apk add --no-cache iproute2 iputils openssh-client
srv:
kind: linux
image: alpine:3.20
exec:
- apk add --no-cache iproute2 iputils busybox-extras
# tiny http: nc -l -p 80 ...
links:
- endpoints: ["client:eth1", "edge:eth1"]
- endpoints: ["mgmt:eth1", "edge:eth1"] # same LAN via bridge preferred
- endpoints: ["edge:eth2", "srv:eth1"]Prefer a bridge node so client + mgmt share 10.60.0.0/24:
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: ["mgmt:eth1", "lan:eth2"]
- endpoints: ["edge:eth1", "lan:eth3"]
- endpoints: ["edge:eth2", "srv:eth1"]Addressing
| Node | IP |
|---|---|
| edge eth1 | 10.60.0.1/24 |
| edge eth2 | 10.60.1.1/24 |
| client | 10.60.0.10/24 via .1 |
| mgmt | 10.60.0.9/24 via .1 |
| srv | 10.60.1.10/24 via .1 |
| DNS (optional) | 10.60.0.53 on edge lo or dnsmasq |
docker exec clab-acl-think-edge sh -c '
sysctl -w net.ipv4.ip_forward=1
ip addr add 10.60.0.1/24 dev eth1; ip link set eth1 up
ip addr add 10.60.1.1/24 dev eth2; ip link set eth2 up
'
docker exec clab-acl-think-client sh -c '
ip addr add 10.60.0.10/24 dev eth1; ip link set eth1 up
ip route add default via 10.60.0.1
'
docker exec clab-acl-think-mgmt sh -c '
ip addr add 10.60.0.9/24 dev eth1; ip link set eth1 up
ip route add default via 10.60.0.1
'
docker exec clab-acl-think-srv sh -c '
ip addr add 10.60.1.10/24 dev eth1; ip link set eth1 up
ip route add default via 10.60.1.1
'Implement with iptables (readable teaching form)
Flush carefully in lab only:
docker exec clab-acl-think-edge sh -c '
iptables -F
iptables -X
iptables -t nat -F
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT
# loopback + established
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# SSH to edge only from mgmt
iptables -A INPUT -p tcp -s 10.60.0.9 --dport 22 -j ACCEPT
# allow ping to edge from LAN (optional)
iptables -A INPUT -p icmp -s 10.60.0.0/24 -j ACCEPT
# forward: client/mgmt to server http + ping
iptables -A FORWARD -s 10.60.0.0/24 -d 10.60.1.10 -p tcp --dport 80 -j ACCEPT
iptables -A FORWARD -s 10.60.0.0/24 -d 10.60.1.10 -p icmp -j ACCEPT
# explicit deny counters
iptables -A FORWARD -j LOG --log-prefix "FWD-DROP: " --log-level 4
iptables -A INPUT -j LOG --log-prefix "IN-DROP: " --log-level 4
'If conntrack module unavailable in minimal Alpine, approximate with broad return allows only for learning, then move to an image with conntrack.
nftables sketch (same policy)
docker exec clab-acl-think-edge sh -c 'apk add --no-cache nftables; nft -f - <<EOF
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iif lo accept
ct state established,related accept
tcp dport 22 ip saddr 10.60.0.9 accept
ip protocol icmp ip saddr 10.60.0.0/24 accept
}
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
ip saddr 10.60.0.0/24 ip daddr 10.60.1.10 tcp dport 80 accept
ip saddr 10.60.0.0/24 ip daddr 10.60.1.10 ip protocol icmp accept
}
chain output {
type filter hook output priority 0; policy accept;
}
}
EOF'Predict → observe → fix drills
Drill 1 — Allowed path
docker exec clab-acl-think-srv sh -c 'echo OK | nc -l -p 80 &'
docker exec clab-acl-think-client ping -c 2 10.60.1.10
docker exec clab-acl-think-client wget -qO- http://10.60.1.10 || \
docker exec clab-acl-think-client nc -w2 10.60.1.10 80 </dev/nullPredict: success. If fail: forwarding, routes, rule order, server listen.
Drill 2 — Denied path
docker exec clab-acl-think-client sh -c 'nc -w2 10.60.1.10 22 || true'
docker exec clab-acl-think-edge iptables -L FORWARD -n -vPredict: fail; counters/logs increment.
Drill 3 — SSH source restriction
# from mgmt should work if dropbear/sshd runs on edge
# from client should fail
docker exec clab-acl-think-client sh -c 'nc -w2 10.60.0.1 22 || echo blocked'Drill 4 — Asymmetric paths
Allow only client→server tcp/80 but forget established return:
Predict: SYN works, data stalls if truly stateless wrong.
Observe: conntrack ESTABLISHED rule presence.
Fix: stateful allow or explicit reverse rule.
Drill 5 — Wrong plane
Install FRR on edge and only add a prefix-list denying 10.60.1.0/24. Client ping to server still works if connected routes exist.
Predict: route policy ≠ packet filter.
Observe: ip route still forwards; iptables counters unchanged for that myth.
Fix: teach the table of three filter kinds again.
FRR: route policy is still “ACL thinking”
ip prefix-list BOGON seq 5 deny 0.0.0.0/8 le 32
ip prefix-list BOGON seq 10 deny 10.0.0.0/8 le 32
ip prefix-list BOGON seq 100 permit any
!
route-map IMPORT deny 10
match ip address prefix-list BOGON
route-map IMPORT permit 20
Same discipline: written intent → ordered rules → negative tests.
Object-group idea (vendor-agnostic)
Group hosts and services even if the engine lacks object-groups:
CLIENTS = 10.60.0.0/24
SERVERS = 10.60.1.10/32
WEB = tcp/80
Expand into concrete rules; keep the group doc next to the ruleset in git.
Change control for ACLs
- Write policy diff in markdown.
- Add rules in lab.
- Positive + negative tests.
- Only then promote.
- Never “temporary allow any” without a ticket/expiry note.
Verification checklist
| Check | Intent |
|---|---|
| Policy doc matches counters | Rules do what you think |
| Default action explicit | No surprise open |
| Negative tests | Deny paths proven |
| Management path | You cannot lock yourself out without console plan |
| Logs sampled | Drops are visible |
Common mistakes
| Mistake | Symptom |
|---|---|
| Allow after broad deny | Never hits |
| Filter only one direction | One-way connectivity |
| Open SSH to world in lab configs committed | Habit risk |
| Logging every drop at full rate | Disk / CPU melt |
| Confusing RIB filter with packet filter | False confidence |
Summary
- ACLs are ordered policies with an explicit default stance
- Separate packet, route, and control-plane filtering in your head
- Linux netfilter in Containerlab is enough to practice real data-plane policy
- Always pair positive allows with negative tests
- Write the policy in prose before the rule engine dialect
Next: control-plane protection—stop the CPU from drowning while the data plane still forwards.