ACL Thinking

Updated

September 4, 2026

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).

  1. Default drop forwarded traffic not explicitly allowed.
  2. Allow clients (10.60.0.0/24) to ping and HTTP to lab server 10.60.1.10.
  3. Allow clients DNS to 10.60.0.53.
  4. Allow SSH only from mgmt host 10.60.0.9 to edge.
  5. Deny client access to edge SSH from other addresses.
  6. 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/null

Predict: 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 -v

Predict: 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

  1. Write policy diff in markdown.
  2. Add rules in lab.
  3. Positive + negative tests.
  4. Only then promote.
  5. 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.