Chapter 53 — Firewalls in the real world: host firewalls, cloud security groups, NACLs
In one sentence
Firewalls are policy checkpoints. Debugging them means finding which checkpoint made which decision, and operating them means expressing policy so that it can be reviewed, tested, and rolled back.
The problem
A packet from a client to a service in the cloud can pass through five or more
independent policy points: the client's corporate firewall, the cloud
provider's network ACL, the instance's security group, the host's nftables,
and a Kubernetes NetworkPolicy. Each has different rules, an owner, and logs
(or none). "The firewall is blocking it" isn't a diagnosis until you can say
which one.
The three layers you will meet
| Layer | Examples | Typical behavior |
|---|---|---|
| Host firewall | nftables, iptables, Windows Defender Firewall, macOS PF |
Runs on the machine itself; stateful via conntrack |
| Cloud security group | AWS SG, GCP firewall rules, Azure NSG | Stateful, attached to a VM/NIC/workload; allow-rules only |
| Network ACL / appliance | AWS NACL, corporate firewall, router ACL | Subnet or perimeter level; often stateless; ordered allow and deny |
Do not say "the firewall" until you know which one.
How it actually works: stateful vs stateless
A stateful firewall uses connection tracking (Chapter 52). Once it has
allowed the first packet of a flow, it recognises every later packet in both
directions as part of an ESTABLISHED connection. You write one rule ("allow
TCP 443 inbound"), and replies are allowed automatically.
A stateless filter judges every packet on its own. To allow inbound HTTPS you need two rules: inbound to port 443, and outbound from 443 to the client's ephemeral port range (Chapter 19). Forgetting the return rule is the classic NACL outage: the SYN gets in, the SYN-ACK is dropped on the way out, and the client sees a timeout.
STATEFUL (security group) STATELESS (network ACL)
inbound: tcp 443 from 0.0.0.0/0 inbound: 100 allow tcp 443 from 0.0.0.0/0
outbound: (replies allowed outbound: 100 allow tcp 1024-65535 to 0.0.0.0/0 <- the return path!
automatically) * deny all
A real host firewall, line by line
This is a complete default-deny nftables policy for a web server. It was syntax-checked and tested in a lab:
# /etc/nftables.d/server.nft -- a default-deny host firewall for a web server
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept # replies to traffic we allowed
ct state invalid drop # packets that fit no known flow
iif "lo" accept # local processes talking to each other
meta l4proto { icmp, ipv6-icmp } accept # ping, PMTU discovery (Chapter 15!)
tcp dport 22 ip saddr 10.0.0.0/8 accept # SSH only from the internal network
tcp dport { 80, 443 } accept # the actual service
tcp dport 8080 reject with tcp reset # fail FAST for a retired port
counter comment "default drop" # count what the policy drops
}
}
sudo nft -c -f server.nft # check syntax WITHOUT applying: always do this first
sudo nft -f server.nft # apply atomically: the whole file or nothing
sudo nft list ruleset # what's actually loaded
Things that make this policy production-grade:
policy dropplusct state established,related acceptfirst. Default deny, with replies allowed cheaply before any other rule runs.- ICMP is allowed. Blocking it breaks Path MTU Discovery (Chapter 15) and makes failures much harder to debug.
- Source restrictions on admin ports. SSH is open only to the internal range, not the internet.
- A deliberate
rejectfor a retired port, so old clients fail in 1 ms instead of hanging. - A counter on the default drop, so you can see whether the policy is dropping anything at all.
Drop vs reject: measured
The same lab, connecting to the server with nc -z -w 3 from 10.9.0.1:
port 443 2 ms succeeded <- allowed, listening
port 8080 1 ms failed: Connection refused <- "reject with tcp reset"
port 9000 3012 ms timed out <- listening, but policy DROP
port 22 2 ms failed: Connection refused <- ALLOWED (10.0.0.0/8), nothing listening
- Reject sends an error (TCP RST or ICMP unreachable), so clients fail fast.
- Drop says nothing, so clients retry SYNs until their own timeout.
- Port 22 is the trap: the firewall allowed it, and the refusal came from the kernel because no SSH daemon was running. "Refused" proves you got through every firewall on the path.
This is why "connection refused" and "connection timed out" are different fingerprints, not two wordings for the same thing.
Real troubleshooting workflow
# 1. Is anything listening, and on which address?
ss -tulpn | grep ':443'
# 2. Can localhost reach it? (bypasses every network firewall)
curl -vk https://127.0.0.1:443/
# 3. Can another host reach the port?
nc -vz server.example.com 443
# 4. Does the packet arrive at the server?
sudo tcpdump -ni any 'tcp port 443'
# 5. Which rule matched? Watch counters change while you retry
sudo nft list ruleset | grep -n counter
sudo nft monitor trace # after adding: meta nftrace set 1 to a rule in a prerouting chain
Interpretation:
| Observation | Meaning |
|---|---|
| Packet never arrives (step 4 silent) | route, upstream firewall, cloud SG/NACL, wrong IP |
| SYN arrives, no SYN-ACK leaves | host firewall, service not listening on that IP, local policy |
| SYN-ACK leaves, client never gets it | stateless ACL missing the return rule, asymmetric routing |
| Handshake completes, app fails later | not a firewall problem; move up the stack |
Build it in Go (30 min) — firewall policy as executable tests
Firewall rules drift: someone adds a "temporary" rule and forgets it, or a
migration loses a path. The fix is to write the intent down as tests and run
them after every change, from every network segment that matters. This
program reads allow/deny expectations and checks them in parallel:
// policytest: firewall policy as executable tests (Chapter 53).
//
// Write down what SHOULD be reachable and what should NOT, then let the
// program prove it -- from every network segment that matters, before and
// after every firewall change. Exit code 1 on any mismatch, so it fits in CI.
//
// cat > policy.txt <<'EOF'
// # expect target why
// allow example.com:443 public website
// deny example.com:22 SSH must not be exposed
// allow 1.1.1.1:53 DNS resolver
// EOF
// go run ./policytest policy.txt
package main
import (
"bufio"
"errors"
"fmt"
"net"
"os"
"strings"
"sync"
"syscall"
"time"
)
type rule struct {
line int
expect string // "allow" or "deny"
target string
why string
}
type result struct {
rule
outcome string // connected | refused | timeout | error
ok bool
}
func main() {
if len(os.Args) != 2 {
fmt.Println("usage: policytest policy.txt")
os.Exit(2)
}
rules, err := load(os.Args[1])
if err != nil {
fmt.Println(err)
os.Exit(2)
}
results := make([]result, len(rules))
var wg sync.WaitGroup
for i, r := range rules {
wg.Add(1)
go func() { // probes run in parallel: a "deny" waits for a timeout
defer wg.Done()
out := probe(r.target, 3*time.Second)
// "deny" is satisfied by refused OR timeout: either way, no connection.
ok := (r.expect == "allow") == (out == "connected")
results[i] = result{r, out, ok}
}()
}
wg.Wait()
failed := 0
for _, r := range results {
mark := "PASS"
if !r.ok {
mark, failed = "FAIL", failed+1
}
fmt.Printf("%s line %-3d expect %-5s %-24s got %-9s %s\n", mark, r.line, r.expect, r.target, r.outcome, r.why)
}
fmt.Printf("\n%d rules, %d failed\n", len(results), failed)
if failed > 0 {
os.Exit(1)
}
}
func probe(target string, timeout time.Duration) string {
c, err := net.DialTimeout("tcp", target, timeout)
var ne net.Error
switch {
case err == nil:
c.Close()
return "connected"
case errors.Is(err, syscall.ECONNREFUSED):
return "refused" // a REJECT rule, or nothing listening
case errors.As(err, &ne) && ne.Timeout():
return "timeout" // a DROP rule, or no route
default:
return "error"
}
}
func load(path string) ([]rule, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var rules []rule
sc := bufio.NewScanner(f)
for n := 1; sc.Scan(); n++ {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 || (fields[0] != "allow" && fields[0] != "deny") {
return nil, fmt.Errorf("line %d: want 'allow|deny host:port [reason]'", n)
}
rules = append(rules, rule{n, fields[0], fields[1], strings.Join(fields[2:], " ")})
}
return rules, sc.Err()
}
$ cat policy.txt
# expect target why
allow example.com:443 public website
deny example.com:22 SSH must not be exposed
allow 1.1.1.1:53 DNS resolver
allow 127.0.0.1:1 this one should fail
$ go run ./policytest policy.txt
PASS line 2 expect allow example.com:443 got connected public website
PASS line 3 expect deny example.com:22 got timeout SSH must not be exposed
PASS line 4 expect allow 1.1.1.1:53 got connected DNS resolver
FAIL line 5 expect allow 127.0.0.1:1 got refused this one should fail
4 rules, 1 failed
exit status 1
What to notice: "deny" passes on either refused or timeout, because both mean no connection was made. The output still records which one happened, and that tells you whether something listens behind the rule. The probes run concurrently because each "deny" waits for its full timeout; done one after another, 50 rules would take minutes.
Exercises:
- Run the same policy file from three places: your laptop, a VM in the same VPC, and a VM in a different subnet. The results should differ. That difference is your segmentation, verified.
- Add a
-junitflag that writes JUnit XML, and run it in CI after every Terraform change to security groups. - Extend the format to
allow-from <cidr>assertions by running the tool inside network namespaces (Chapter 52) with different source addresses.
Safety rule
Before changing firewall policy, write down:
- source IP/prefix;
- destination IP;
- protocol/port;
- direction;
- expected rollback command;
- how you'll keep your own access (on a remote host, schedule an
automatic rollback first:
sudo nft -f server.nft; sleep 60 && sudo nft -f previous.nft &, then cancel it once you've confirmed you can still connect).
Most firewall incidents are caused by "temporary" broad rules that nobody removes, or by narrow rules that forgot one path.
Common confusions
- "I allowed it in the security group, so it's allowed." Every other layer (NACL, host firewall, NetworkPolicy) must allow it too. Policy is the intersection of all of them.
- "The firewall must be dropping replies." With stateful firewalls, replies are allowed automatically. If replies vanish, look for a stateless ACL or asymmetric routing (Chapter 13).
- "REJECT is less secure than DROP." Hiding a closed port gains you very little: scanners notice drops too. Use reject inside your own networks, where fast failure helps your engineers. Drop at the internet edge if you want to slow scanners down.
Check yourself
- Why does a stateless NACL need an outbound rule for inbound HTTPS to work?
- A client gets "connection refused" from your server. Which firewalls can you rule out?
- What do
policy dropandct state established,related accepteach do, and why does their order matter? - Why should firewall intent be written as tests rather than only as rules?
Further reading
- Docs: the nftables wiki (wiki.nftables.org): "Quick reference" and "Ruleset debug/tracing".
- Docs: AWS VPC documentation, "Compare security groups and network ACLs".
- Across the series: the Linux guide's hardening chapter covers host-level least privilege, and the HTTPS guide's security chapter covers application-layer controls (WAF, rate limiting) that sit above these firewalls. The Go plan builds a WAF in Week 10.