#!/usr/bin/env bash
# Real demos for P003 "Networking Will Finally Make Sense After This".
# A small lab network in Docker, on this machine only, using reserved test addresses
# (RFC 1918 192.168.1.0/24 at home, RFC 5737 203.0.113.0/24 and 198.51.100.0/24 "on the internet"):
#
#   laptop 192.168.1.20 ── home-router (NAT) 192.168.1.1 | 203.0.113.7
#                            ── isp-router 203.0.113.1 | 198.51.100.1
#                                 ── shop-server 198.51.100.10 (web :80, ssh :22) and dns 198.51.100.53
#
# Nothing leaves this machine: the ISP router has no default route, so the lab can only reach itself,
# and no real address of ours appears.
# Commands are typed into real interactive shells (one pty per machine), as the viewer sees them;
# commands + real output go to demo_output.json. Terminal scenes show only that.
# Every number in video.json must match a value here. Re-run = new numbers = update the script.
set -euo pipefail
cd "$(dirname "$0")"
P=ce-p003
BOXES="laptop home-router isp-router shop-server dns"
cleanup() {
  for b in $BOXES; do docker rm -f $P-$b >/dev/null 2>&1 || true; done
  for n in home isp dc; do docker network rm $P-$n >/dev/null 2>&1 || true; done
}
cleanup
trap cleanup EXIT
W=/tmp/ce-p003
rm -rf $W && mkdir -p $W/img

cat > $W/img/Dockerfile <<'EOF'
FROM ubuntu:24.04
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
      sudo iproute2 iputils-ping traceroute dnsutils curl tcpdump iptables netcat-openbsd \
      openssh-server dnsmasq socat python3 procps ethtool >/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
EOF
docker build -q -t $P-net $W/img >/dev/null

# Gateways at .254 so the routers can own the .1 addresses, as in a real home network.
docker network create --subnet 192.168.1.0/24 --gateway 192.168.1.254 $P-home >/dev/null
docker network create --subnet 203.0.113.0/24 --gateway 203.0.113.254 $P-isp >/dev/null
docker network create --subnet 198.51.100.0/24 --gateway 198.51.100.254 $P-dc >/dev/null

box() {  # box NAME HOSTNAME NETWORK IP [more docker args]
  local name=$1 host=$2 net=$3 ip=$4; shift 4
  docker run -d --name $P-$name --hostname $host --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 laptop laptop home 192.168.1.20
box home-router home-router home 192.168.1.1 --sysctl net.ipv4.ip_forward=1
docker network connect --ip 203.0.113.7 $P-isp $P-home-router
box isp-router isp-router isp 203.0.113.1 --sysctl net.ipv4.ip_forward=1
docker network connect --ip 198.51.100.1 $P-dc $P-isp-router
box shop-server shop-server dc 198.51.100.10
box dns dns dc 198.51.100.53

ex() { docker exec $P-$1 sh -c "$2"; }
ifname() { docker exec $P-$1 ip -4 -o addr | awk -v ip="$2" 'index($4, ip"/")==1 {print $2}'; }

# The home router: interfaces named lan and wan, like a real router; NAT out of wan.
LAN=$(ifname home-router 192.168.1.1); WAN=$(ifname home-router 203.0.113.7)
ex home-router "ip link set $LAN down && ip link set $LAN name lan && ip link set lan up \
  && ip link set $WAN down && ip link set $WAN name wan && ip link set wan up \
  && ip route replace default via 203.0.113.1 dev wan \
  && iptables -t nat -A POSTROUTING -o wan -j MASQUERADE"
# The ISP router knows the internet side only: no route to any home's 192.168 network, and no
# default route (Docker gave it one to the host), so nothing in the lab can reach the real internet.
ex isp-router "ip route del default"
ex laptop "ip route replace default via 192.168.1.1 && echo 'nameserver 198.51.100.53' > /etc/resolv.conf"
for b in shop-server dns; do ex $b "ip route replace default via 198.51.100.1"; done
ex dns "dnsmasq --no-resolv --no-hosts --address=/shop.example/198.51.100.10 --log-facility=/var/log/dnsmasq.log"

# The web shop: a page and a 2 MB photo (fixed seed: same bytes every run); access log shows client addresses.
ex shop-server "mkdir -p /srv/shop && printf '<h1>Welcome to the shop</h1>\n' > /srv/shop/index.html \
  && python3 -c 'import random; r=random.Random(3); open(\"/srv/shop/photo.jpg\",\"wb\").write(r.randbytes(2_000_000))' \
  && mkdir -p /run/sshd && /usr/sbin/sshd -o AddressFamily=inet"
docker exec -d $P-shop-server sh -c "cd /srv/shop && python3 -u -m http.server 80 > /var/log/web.log 2>&1"
# UDP receiver for the loss demo: writes each datagram it receives as one line.
docker exec -d $P-shop-server sh -c "socat -u UDP-RECV:9000 OPEN:/root/received.txt,creat,append"
# Docker's virtual cards merge packets into 64 KB chunks (offload); a real cable carries at most 1500 bytes
# per packet, so turn the merging off and every capture counts real packets.
for b in laptop home-router isp-router shop-server; do
  docker exec $P-$b sh -c 'for i in $(ls /sys/class/net | grep -v lo); do ethtool -K $i tso off gso off gro off >/dev/null 2>&1 || true; done'
done
sleep 2

python3 - "$P" <<'PY'
import fcntl, json, os, pty, re, select, struct, subprocess, sys, termios, time
P = sys.argv[1]
steps = {}
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 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, host, user):
        self.host, self.user, self.where, self.pending = host, user, "~", None
        home = "/root" if user == "root" else 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", 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=180):
        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 prompt(self):
        return f"{self.user}@{self.host}:{self.where}{'#' if self.user == 'root' else '$'} "

    def run(self, key, cmd):
        shown = self.prompt() + cmd
        os.write(self.fd, cmd.encode() + b"\n")
        self.save(key, shown, self.read())

    def start(self, key, cmd):
        """Start a command that keeps running (a capture) while we use another window."""
        self.pending = (key, self.prompt() + cmd)
        os.write(self.fd, cmd.encode() + b"\n")
        time.sleep(1.5)

    def finish(self, interrupt=False):
        if interrupt:
            time.sleep(2)   # let the capture catch up before Ctrl-C, so it counts every packet
            os.write(self.fd, b"\x03")
        key, shown = self.pending
        self.pending = None
        self.save(key, shown, self.read())

    def save(self, key, shown, out):
        out = "\n".join(l for l in out.split("\n") if l.strip() != "^C")
        steps[key] = {"cmd": shown, "out": out, "host": self.host}
        print(f"== {key}\n{shown}\n{out}\n", flush=True)

laptop = Shell("laptop", "laptop", "ana")
router = Shell("home-router", "home-router", "root")
server = Shell("shop-server", "shop-server", "root")

# ---------------------------------------------------------------- hook: one request, end to end
laptop.run("hook", "curl shop.example")

# ---------------------------------------------------------------- 1. your address
laptop.run("ip_addr", "ip -4 -brief address")
laptop.run("hostname_i", "hostname -I")

# ---------------------------------------------------------------- 2. the local network: MAC addresses and ARP
sh(f"docker exec {P}-laptop ip neigh flush all; docker exec {P}-home-router ip neigh flush all")   # forget, as after a while idle
laptop.run("neigh_before", "ip neighbour")
router.start("arp", "tcpdump -n -i lan -c 2 arp")
laptop.run("ping_router", "ping -c 3 192.168.1.1")
router.finish()
laptop.run("neigh_after", "ip neighbour")
laptop.run("link", "ip link show eth0")

# ---------------------------------------------------------------- 3. the router: default gateway and hops
laptop.run("route", "ip route")
laptop.run("traceroute", "traceroute -n 198.51.100.10")

# ---------------------------------------------------------------- 4. DNS (UDP): name to address
router.start("dns_capture", "tcpdump -n -i lan -c 2 udp port 53")
laptop.run("dig", "dig +short shop.example")
router.finish()

# ---------------------------------------------------------------- 5. NAT: private inside, public outside
router.start("nat_capture", "tcpdump -n -i any -c 2 'tcp[tcpflags] == tcp-syn'")
laptop.run("curl_nat", "curl -s shop.example")
router.finish()
server.run("web_log", "tail -n 2 /var/log/web.log")
router.run("nat_rule", "iptables -t nat -S POSTROUTING")
steps["isp_routes"] = {"cmd": "root@isp-router:~# ip route", "out": sh(f"docker exec {P}-isp-router ip route").strip(),
                       "host": "isp-router"}

# ---------------------------------------------------------------- 6. ports: one address, many programs
server.run("ss", "ss -tlnp")
laptop.run("port_80", "curl -s shop.example:80")
laptop.run("port_22", "nc -w 2 shop.example 22 < /dev/null")
laptop.run("port_8080", "curl shop.example:8080")

# ---------------------------------------------------------------- 7. TCP: the handshake, then data
router.start("handshake", "tcpdump -n -i lan -c 3 'tcp port 80'")
laptop.run("curl_hs", "curl -s shop.example > /dev/null")
router.finish()
router.start("packets", "tcpdump -n -i lan -U -q 'tcp port 80 and src host 198.51.100.10' -w /tmp/photo.pcap")
laptop.run("download", "time curl -s -o photo.jpg shop.example/photo.jpg")
router.finish(interrupt=True)
laptop.run("photo_size", "ls -l photo.jpg")

# ---------------------------------------------------------------- 8. losing packets: TCP resends, UDP doesn't
router.run("loss_lan", "tc qdisc add dev lan root netem loss 10%")
router.run("loss_wan", "tc qdisc add dev wan root netem loss 10%")
server.run("retrans_before", "nstat -az TcpRetransSegs")
laptop.run("rm_photo", "rm photo.jpg")
laptop.run("download_loss", "time curl -s -o photo.jpg shop.example/photo.jpg")
server.run("retrans_after", "nstat -az TcpRetransSegs")
laptop.run("sha_laptop", "sha256sum photo.jpg")
server.run("sha_server", "sha256sum /srv/shop/photo.jpg")
laptop.run("udp_send", 'for i in $(seq 1 100); do echo "message $i" > /dev/udp/198.51.100.10/9000; done')
time.sleep(1)
server.run("udp_count", "wc -l received.txt")
router.run("loss_off", "tc qdisc del dev lan root; tc qdisc del dev wan root")

steps["version"] = {"cmd": "versions", "out": sh(
    f"docker exec {P}-laptop sh -c '. /etc/os-release; echo $PRETTY_NAME; uname -r; "
    f"tcpdump --version | head -1; curl --version | head -1; ip -V'; date -u +%Y-%m-%dT%H:%MZ").strip()}
json.dump(steps, open("demo_output.json", "w"), indent=1)
print("saved demo_output.json")
PY
