#!/usr/bin/env bash
# Real demos for P012 "TCP vs UDP: When Each One Wins" (Networking for Beginners, episode 2).
# The lab from episode 1, trimmed: a laptop at a café, the café's Wi-Fi router, an internet provider's
# router and one server (web page, DNS, and the far end of a pretend video call), all in Docker on this
# machine, with reserved test addresses only (RFC 1918 192.168.1.0/24, RFC 5737 203.0.113.0/24 and 198.51.100.0/24):
#
#   laptop 192.168.1.20 ── cafe-router (NAT) 192.168.1.1 | 203.0.113.7
#                            ── isp-router 203.0.113.1 | 198.51.100.1 ── server 198.51.100.10
#
# Nothing leaves this machine (the ISP router has no default route). The café router plays a bad network
# with tc netem: first 50 ms each way (distance), then also 2 packets in 100 lost (weak Wi-Fi).
# Commands are typed into real interactive shells (one pty per machine); commands + real output go to
# demo_output.json, and the call's per-frame delays to call_delays.json. Re-run = new numbers: the
# script reads them all from those files.
set -euo pipefail
cd "$(dirname "$0")"
P=ce-p012
BOXES="laptop cafe-router isp-router server"
cleanup() {
  for b in $BOXES; do docker rm -f $P-$b >/dev/null 2>&1 || true; done
  for n in cafe isp dc; do docker network rm $P-$n >/dev/null 2>&1 || true; done
}
cleanup
trap cleanup EXIT
W=/tmp/ce-p012
rm -rf $W && mkdir -p $W/img

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 \
      iproute2 iputils-ping dnsutils curl tcpdump iptables dnsmasq python3 procps ethtool >/dev/null \
 && rm -rf /var/lib/apt/lists/* \
 && userdel -r ubuntu 2>/dev/null; useradd -m -s /bin/bash ana
DOCKER
docker build -q -t $P-net $W/img >/dev/null

docker network create --subnet 192.168.1.0/24 --gateway 192.168.1.254 $P-cafe >/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 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 laptop cafe 192.168.1.20
box cafe-router cafe 192.168.1.1 --sysctl net.ipv4.ip_forward=1
docker network connect --ip 203.0.113.7 $P-isp $P-cafe-router
box 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 server dc 198.51.100.10

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}'; }
LAN=$(ifname cafe-router 192.168.1.1); WAN=$(ifname cafe-router 203.0.113.7)
ex cafe-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"
ex isp-router "ip route del default"
ex laptop "ip route replace default via 192.168.1.1 && echo 'nameserver 198.51.100.10' > /etc/resolv.conf"
ex server "ip route replace default via 198.51.100.1"
ex server "dnsmasq --no-resolv --no-hosts --address=/server.example/198.51.100.10"
ex server "mkdir -p /srv/web && printf '<h1>Hello from the server</h1>\n' > /srv/web/index.html"
docker exec -d $P-server sh -c "cd /srv/web && python3 -m http.server 80 > /var/log/web.log 2>&1"
# Ana's photo: 2 MB, fixed seed, so every run sends the same bytes.
docker cp lab/call.py $P-server:/root/; docker cp lab/sendfile.py $P-server:/root/
docker cp lab/call.py $P-laptop:/home/ana/; docker cp lab/sendfile.py $P-laptop:/home/ana/
ex laptop "python3 -c 'import random; r=random.Random(12); open(\"/home/ana/photo.jpg\",\"wb\").write(r.randbytes(2_000_000))' \
  && chown ana:ana /home/ana/*"
# No 64 KB packet merging on Docker's virtual cards: every capture and every loss counts real packets.
for b in $BOXES; 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, user):
        self.host, self.user, self.where, self.pending = box, 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=240):
        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, a listener) 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", "ana")
router = Shell("cafe-router", "root")
server = Shell("server", "root")

# ---------------------------------------------------------------- a good network first
laptop.run("ping_fast", "ping -c 3 server.example")

# ---------------------------------------------------------------- round 1: distance (50 ms each way)
router.run("delay_lan", "tc qdisc add dev lan root netem delay 50ms")
router.run("delay_wan", "tc qdisc add dev wan root netem delay 50ms")
laptop.run("ping_far", "ping -c 3 server.example")
router.start("hs_capture", "tcpdump -n -i lan -q tcp port 80")
laptop.run("curl_times", "curl -s -o /dev/null -w 'name found: %{time_namelookup} s\\nconnected:  %{time_connect} s\\npage:       %{time_total} s\\n' server.example")
router.finish(interrupt=True)
router.start("dns_udp_capture", "tcpdump -n -i lan -q port 53")
laptop.run("dig_udp", "time dig +short server.example")
router.finish(interrupt=True)
router.start("dns_tcp_capture", "tcpdump -n -i lan -q port 53")
laptop.run("dig_tcp", "time dig +tcp +short server.example")
router.finish(interrupt=True)

# ---------------------------------------------------------------- now also weak Wi-Fi: 2 packets in 100 lost
router.run("loss_lan", "tc qdisc change dev lan root netem delay 50ms loss 2%")
router.run("loss_wan", "tc qdisc change dev wan root netem delay 50ms loss 2%")

# ---------------------------------------------------------------- round 2: a photo, every byte
laptop.run("sha_laptop", "sha256sum photo.jpg")
server.start("recv_tcp", "python3 sendfile.py receive tcp")
laptop.run("send_tcp", "python3 sendfile.py send tcp photo.jpg server.example")
server.finish()
server.run("sha_tcp", "sha256sum photo.jpg")
server.start("recv_udp", "python3 sendfile.py receive udp")
laptop.run("send_udp", "python3 sendfile.py send udp photo.jpg server.example")
server.finish()
server.run("sha_udp", "sha256sum photo.jpg")

# ---------------------------------------------------------------- round 3: a live call, every frame on time
laptop.run("retrans_before", "nstat -az TcpRetransSegs")
server.start("call_tcp", "python3 call.py listen tcp")
laptop.run("send_call_tcp", "python3 call.py send tcp server.example")
server.finish()
laptop.run("retrans_after", "nstat -az TcpRetransSegs")
server.start("call_udp", "python3 call.py listen udp")
laptop.run("send_call_udp", "python3 call.py send udp server.example")
server.finish()

router.run("net_off", "tc qdisc del dev lan root; tc qdisc del dev wan root")

delays = {p: json.loads(sh(f"docker exec {P}-server cat /tmp/call-{p}.json")) for p in ("tcp", "udp")}
json.dump(delays, open("call_delays.json", "w"))
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; dig -v 2>&1; python3 --version'; date -u +%Y-%m-%dT%H:%MZ").strip()}
json.dump(steps, open("demo_output.json", "w"), indent=1)
print("saved demo_output.json and call_delays.json")
PY
