#!/usr/bin/env bash
# Real demos for P030 "The 5 Levels of Network Firewalls" (Networking for Beginners, episode 7).
# A small lab in Docker on this machine only, with reserved documentation addresses (RFC 5737):
#
#   "the internet" 203.0.113.0/24: stranger .50 (any computer), ana-home .30 (Ana's home), updates .80 (a download site)
#        └── shop-router 203.0.113.1 | 198.51.100.1  (the shop network's edge)
#   the shop 198.51.100.0/24: web .10 (website :80, SSH :22), db .20 (PostgreSQL :5432, SSH :22), proxy .5 (level 5)
#
# Nothing leaves this machine: no box has a route to the real internet (the Docker gateways at .254 are never used).
# The story: Ana runs a small online shop. A scan shows her database open to anyone. Level by level she adds:
#   1 a packet filter on the database server (address + port rules, default drop),
#   2 connection tracking (stateful), because the filter broke the server's own downloads,
#   3 a network firewall on the router that guards every server at once,
#   4 (cloud security groups: explained with diagrams, no demo),
#   5 an HTTP-aware reverse proxy that blocks the admin pages and limits request rates, which port rules can't see.
# Every scan and connection targets only these lab boxes. Commands are typed into real interactive shells
# (one pty per window); commands + real output go to demo_output.json.
set -euo pipefail
cd "$(dirname "$0")"
P=ce-p030
BOXES="stranger ana-home updates shop-router web db proxy"
cleanup() {
  for b in $BOXES; do docker rm -f $P-$b >/dev/null 2>&1 || true; done
  for n in inet shop; do docker network rm $P-$n >/dev/null 2>&1 || true; done
}
cleanup
trap cleanup EXIT
W=/tmp/ce-p030
rm -rf $W && mkdir -p $W/img $W/files

cat > $W/img/Dockerfile <<'DOCKER'
FROM ubuntu:24.04
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
      sudo iproute2 iputils-ping curl nftables conntrack netcat-openbsd nmap python3 procps \
      openssh-server postgresql nginx >/dev/null \
 && rm -rf /var/lib/apt/lists/* \
 && userdel -r ubuntu 2>/dev/null; useradd -m -s /bin/bash ana \
 && echo "ana ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/ana && chmod 440 /etc/sudoers.d/ana \
 && mkdir -p /run/sshd
DOCKER
docker build -q -t $P-net $W/img >/dev/null

# ------------------------------------------------------------ files the boxes use (shown on screen with cat)
# The shop's website: a home page, a product list and an admin page that should never be public.
cat > $W/files/shop.py <<'PY'
import http.server
PAGES = {"/": "Ana's Kettle Shop\n", "/products": "Copper kettle  $49\nSteel kettle   $29\n",
         "/admin": "Shop admin: 3 new orders, refunds, customer list\n"}
class H(http.server.BaseHTTPRequestHandler):
    server_version, sys_version = "shop/1.0", ""
    def log_message(self, *a): pass
    def do_GET(self):
        body = PAGES.get(self.path.split("?")[0])
        b = (body or "Not found\n").encode()
        self.send_response(200 if body else 404)
        self.send_header("Content-Type", "text/plain"); self.send_header("Content-Length", str(len(b)))
        self.end_headers(); self.wfile.write(b)
http.server.ThreadingHTTPServer(("0.0.0.0", 80), H).serve_forever()
PY
# A simple download site on the internet (for the database server's updates).
cat > $W/files/updates.py <<'PY'
import http.server
class H(http.server.BaseHTTPRequestHandler):
    server_version, sys_version = "updates/1.0", ""
    def log_message(self, *a): pass
    def do_GET(self):
        b = b"security-update-2026-10.tar: ready\n"
        self.send_response(200); self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
http.server.ThreadingHTTPServer(("0.0.0.0", 80), H).serve_forever()
PY
# Level 1: the database server's own firewall. Rules are checked top to bottom; anything unmatched is dropped.
cat > $W/files/db-firewall.nft <<'NFT'
table inet fw {
  chain input {
    type filter hook input priority 0; policy drop;
    iif lo accept
    tcp dport 22 accept
    ip saddr 198.51.100.10 tcp dport 5432 accept
  }
}
NFT
# Level 3: the router's firewall for the whole shop network (traffic passing through = the forward hook).
cat > $W/files/edge.nft <<'NFT'
table inet edge {
  chain forward {
    type filter hook forward priority 0; policy drop;
    ct state established,related accept
    ip saddr 198.51.100.0/24 accept
    ip daddr 198.51.100.10 tcp dport 80 accept
    ip saddr 203.0.113.30 tcp dport 22 accept
    counter drop
  }
}
NFT
# Level 5: only the proxy may take web traffic from the internet now.
cat > $W/files/edge-proxy.nft <<'NFT'
flush chain inet edge forward
table inet edge {
  chain forward {
    ct state established,related accept
    ip saddr 198.51.100.0/24 accept
    ip daddr 198.51.100.5 tcp dport 80 accept
    ip saddr 203.0.113.30 tcp dport 22 accept
    counter drop
  }
}
NFT
# Level 5: the proxy's rules. It reads each HTTP request: the path, and how fast one address is asking.
cat > $W/files/shop-proxy.conf <<'NGX'
limit_req_zone $binary_remote_addr zone=perip:1m rate=5r/s;
server {
  listen 80;
  location /admin { deny all; }
  location / {
    limit_req zone=perip burst=5 nodelay;
    limit_req_status 429;
    proxy_pass http://198.51.100.10;
  }
}
NGX

docker network create --subnet 203.0.113.0/24 --gateway 203.0.113.254 $P-inet >/dev/null
docker network create --subnet 198.51.100.0/24 --gateway 198.51.100.254 $P-shop >/dev/null

box() {  # box NAME NETWORK IP [more docker args]
  local name=$1 net=$2 ip=$3; shift 3
  docker run -d --name $P-$name --hostname $name --cap-add NET_ADMIN --cap-add NET_RAW \
    --sysctl net.ipv6.conf.all.disable_ipv6=1 "$@" --network $P-$net --ip $ip $P-net sleep infinity >/dev/null
}
box stranger inet 203.0.113.50
box ana-home inet 203.0.113.30
box updates inet 203.0.113.80
box shop-router inet 203.0.113.1 --sysctl net.ipv4.ip_forward=1
docker network connect --ip 198.51.100.1 $P-shop $P-shop-router
box web shop 198.51.100.10
box db shop 198.51.100.20
box proxy shop 198.51.100.5

ex() { docker exec $P-$1 sh -c "$2"; }
for b in stranger ana-home updates; do ex $b "ip route add 198.51.100.0/24 via 203.0.113.1"; done
for b in web db proxy; do ex $b "ip route replace default via 198.51.100.1"; done
ex shop-router "ip route del default"
HOSTS="198.51.100.10 shop.example web
198.51.100.20 db
198.51.100.5 proxy
203.0.113.80 updates.example
203.0.113.30 ana-home"
for b in $BOXES; do docker exec $P-$b sh -c "printf '%s\n' \"$HOSTS\" >> /etc/hosts"; done

# The servers: SSH on web and db, the website on web, PostgreSQL on db (listening on every address, the risky default
# many tutorials set), the download site on updates.
for b in web db; do ex $b "/usr/sbin/sshd"; done
docker cp $W/files/shop.py $P-web:/srv/shop.py
docker exec -d $P-web sh -c "exec python3 /srv/shop.py"
docker cp $W/files/updates.py $P-updates:/srv/updates.py
docker exec -d $P-updates sh -c "exec python3 /srv/updates.py"
ex db "V=\$(ls /etc/postgresql) && sed -i \"s/^#listen_addresses.*/listen_addresses = '*'/\" /etc/postgresql/\$V/main/postgresql.conf \
  && echo 'host all all 0.0.0.0/0 scram-sha-256' >> /etc/postgresql/\$V/main/pg_hba.conf \
  && pg_ctlcluster \$V main start"
docker cp $W/files/db-firewall.nft $P-db:/home/ana/firewall.nft
docker cp $W/files/edge.nft $P-shop-router:/home/ana/edge.nft
docker cp $W/files/edge-proxy.nft $P-shop-router:/home/ana/edge-proxy.nft
docker cp $W/files/shop-proxy.conf $P-proxy:/home/ana/shop-proxy.conf
for b in db shop-router proxy; do ex $b "chown ana:ana /home/ana/*"; done
ex proxy "rm -f /etc/nginx/sites-enabled/default"
sleep 2

python3 - "$P" <<'PYDRIVER'
import fcntl, json, os, pty, re, select, struct, subprocess, sys, termios, time
P = sys.argv[1]
steps, faults = {}, []
READY = re.compile(rb"__READY__:(.*?):__ ")

def sh(cmd, check=True):
    r = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    if check and r.returncode:
        raise SystemExit(f"FAILED: {cmd}\n{r.stdout}\n{r.stderr}")
    return r.stdout

def fault(box, what, cmd):
    """Change something from outside the terminals, and record exactly what was done."""
    sh(f"docker exec {P}-{box} sh -c {json.dumps(cmd)}")
    faults.append({"box": box, "what": what, "cmd": cmd})
    print(f"## {box}: {what}  ({cmd})", flush=True)
    time.sleep(1)

def clean(text):
    text = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b[()][A-Z0-9]", "", text)
    lines = [l.rstrip("\r").split("\r")[-1] for l in text.split("\n")]
    return "\n".join(l.rstrip() for l in lines).strip("\n")

class Shell:
    """One interactive bash in a real terminal (80 columns) on one machine, like a terminal window."""
    def __init__(self, box, user="ana"):
        self.host, self.user, self.where = box, user, "~"
        home = f"/home/{user}"
        pid, fd = pty.fork()
        if pid == 0:
            fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 80, 0, 0))
            os.execvp("docker", ["docker", "exec", "-it", "-u", user, "-w", home, "-e", f"HOME={home}",
                                 "-e", "TERM=xterm", "-e", "LANG=C.UTF-8", f"{P}-{box}", "bash", "-i"])
        self.fd = fd
        os.write(fd, b"stty -echo; PS1='__REA''DY__:\\w:__ '\n")
        self.read()

    def read(self, timeout=120):
        buf, end = b"", time.time() + timeout
        while time.time() < end:
            if select.select([self.fd], [], [], 0.3)[0]:
                buf += os.read(self.fd, 65536)
                m = None
                for m in READY.finditer(buf):
                    pass
                if m:
                    self.where = m.group(1).decode()
                    return clean(buf[:m.start()].decode("utf-8", "replace"))
        raise SystemExit(f"{self.host}: timed out; last output: {buf[-400:]!r}")

    def run(self, key, cmd):
        shown = f"{self.user}@{self.host}:{self.where}$ " + cmd
        t0 = time.time()
        os.write(self.fd, cmd.encode() + b"\n")
        o = self.read()
        assert key not in steps, key
        steps[key] = {"cmd": shown, "out": o, "host": self.host, "after_fault": len(faults),
                      "seconds": round(time.time() - t0, 1)}
        print(f"== {key}\n{shown}\n{o}\n", flush=True)

stranger = Shell("stranger")      # any computer on the internet
home = Shell("ana-home")          # Ana's computer at home
web = Shell("web")                # the shop's web server
db = Shell("db")                  # the shop's database server
router = Shell("shop-router")     # the router at the edge of the shop's network
proxy = Shell("proxy")            # level 5: the reverse proxy in front of the website
NC = "nc -zv -w 3"

# ---------------------------------------------------------------- the problem: everything is open
stranger.run("scan_before", "nmap -Pn -p 22,80,5432 web db | grep -E 'report|/tcp'")
stranger.run("db_open", f"{NC} db 5432")

# ---------------------------------------------------------------- level 1: a packet filter on the database server
db.run("rules_l1", "cat firewall.nft")
db.run("apply_l1", "sudo nft -f firewall.nft")
stranger.run("db_blocked", f"{NC} db 5432")
web.run("db_from_web", f"{NC} db 5432")
stranger.run("scan_l1", "nmap -Pn -p 22,5432 db | grep /tcp")
db.run("update_broken", "curl -m 5 http://updates.example/")

# ---------------------------------------------------------------- level 2: remember connections (stateful)
db.run("apply_l2", "sudo nft insert rule inet fw input ct state established,related accept")
db.run("update_ok", "curl -m 5 http://updates.example/")
db.run("ruleset_l2", "sudo nft list chain inet fw input")
web.run("db_from_web2", f"{NC} db 5432")
db.run("conntrack", "sudo conntrack -L -p tcp --dport 80 2>/dev/null | awk '{print $4, $5, $6, $7, $8}'")
stranger.run("db_blocked2", f"{NC} db 5432")

# ---------------------------------------------------------------- level 3: one firewall for the whole network
stranger.run("web_ssh_open", f"{NC} web 22")
router.run("rules_l3", "cat edge.nft")
router.run("apply_l3", "sudo nft -f edge.nft")
stranger.run("l3_site", "curl -s http://shop.example/")
stranger.run("l3_web_ssh", f"{NC} web 22")
stranger.run("l3_db_ssh", f"{NC} db 22")
home.run("l3_home_ssh", f"{NC} web 22")
db.run("l3_update", "curl -m 5 http://updates.example/")
router.run("l3_counter", "sudo nft list chain inet edge forward | grep counter")

# ---------------------------------------------------------------- level 5: rules that read the request itself
stranger.run("admin_open", "curl -s http://shop.example/admin")
proxy.run("proxy_rules", "cat shop-proxy.conf")
proxy.run("proxy_on", "sudo cp shop-proxy.conf /etc/nginx/conf.d/ && sudo nginx")
router.run("rules_l5", "cat edge-proxy.nft | grep 'tcp dport 80'")
router.run("apply_l5", "sudo nft -f edge-proxy.nft")
for b in ("stranger", "ana-home"):
    fault(b, "shop.example now points to the proxy (198.51.100.5), as the shop's DNS record would",
          "sed 's/^198.51.100.10 shop.example web$/198.51.100.10 web/; s/^198.51.100.5 proxy$/198.51.100.5 shop.example proxy/' "
          "/etc/hosts > /tmp/hosts && cat /tmp/hosts > /etc/hosts")
stranger.run("dig_proxy", "getent hosts shop.example")
stranger.run("l5_home", "curl -s http://shop.example/products")
stranger.run("l5_admin", "curl -s -o /dev/null -w '%{http_code}\\n' http://shop.example/admin")
stranger.run("l5_direct", "curl -m 5 http://198.51.100.10/admin")
stranger.run("l5_burst", "for i in $(seq 20); do curl -s -o /dev/null -w '%{http_code}\\n' "
                         "http://shop.example/; done | sort | uniq -c")

steps["version"] = {"cmd": "versions", "out": sh(
    f"docker exec {P}-db sh -c '. /etc/os-release; echo $PRETTY_NAME; uname -r; nft --version; "
    f"nmap --version | head -1; nginx -v 2>&1; psql --version'; date -u +%Y-%m-%dT%H:%MZ").strip()}
steps["faults"] = {"cmd": "faults", "out": json.dumps(faults)}
json.dump(steps, open("demo_output.json", "w"), indent=1, ensure_ascii=False)
print("saved demo_output.json")
PYDRIVER
