#!/usr/bin/env bash
# Real demos for P023 "The OSI Model Will Finally Make Sense After This" (Networking for Beginners, episode 5).
# The lab network from episode 1, in Docker on this machine only, with 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) and dns 198.51.100.53
#
# Nothing leaves this machine: the ISP router has no default route.
# The story: Ana opens the shop's page once, and we catch that one request twice: as it leaves her laptop and as it
# arrives at the shop server. Wireshark's command-line tool (tshark) then takes the same packet apart layer by layer:
# which parts changed on the way (hardware addresses at every hop, the sender address at the home router, the hop
# counter), and which arrived untouched (ports and the web request). Then three faults, each found at one layer.
# 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-p023
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-p023
rm -rf $W && mkdir -p $W/img

cat > $W/img/Dockerfile <<'EOF'
FROM ubuntu:24.04
RUN echo 'wireshark-common wireshark-common/install-setuid boolean false' | debconf-set-selections \
 && apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
      sudo iproute2 iputils-ping dnsutils curl iptables tcpdump tshark ethtool dnsmasq python3 procps bsdextrautils >/dev/null \
 && rm -rf /var/lib/apt/lists/* \
 && userdel -r ubuntu 2>/dev/null; for u in ana sam; do useradd -m -s /bin/bash $u \
      && echo "$u ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$u && chmod 440 /etc/sudoers.d/$u; done
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 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 home 192.168.1.20
box 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 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 dc 198.51.100.10
box 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}'; }

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"
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
# Docker's virtual cards merge packets into 64 KB chunks: turn that off so every packet is a real-sized one.
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
ex dns "dnsmasq --no-resolv --no-hosts --address=/shop.example/198.51.100.10"
ex shop-server "mkdir -p /srv/shop && printf '<h1>Welcome to the shop</h1>\n' > /srv/shop/index.html"
docker exec -d $P-shop-server sh -c "cd /srv/shop && exec python3 -m http.server 80 >> /var/log/web.log 2>&1"
sleep 2

python3 - "$P" <<'PY'
import fcntl, json, os, pty, re, select, struct, subprocess, sys, termios, time
P = sys.argv[1]
steps, faults, facts = {}, [], {}
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):
    """Break (or fix) 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):
        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", "-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=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 save(self, key, shown, out):
        assert key not in steps, key
        steps[key] = {"cmd": shown, "out": out, "host": self.host, "after_fault": len(faults)}
        print(f"== {key}\n{shown}\n{out}\n", flush=True)

    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(2)

    def finish(self):
        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 mac(box, ip):
    """Hardware address of the card that owns this IP (for diagram labels and asserts; not shown typed)."""
    for l in sh(f"docker exec {P}-{box} ip -o -4 addr").splitlines():
        if f" {ip}/" in l:
            dev = l.split()[1]
            return sh(f"docker exec {P}-{box} cat /sys/class/net/{dev}/address").strip()
    raise SystemExit(f"no card with {ip} on {box}")

facts["mac"] = {"laptop": mac("laptop", "192.168.1.20"), "home_lan": mac("home-router", "192.168.1.1"),
                "home_wan": mac("home-router", "203.0.113.7"), "isp_wan": mac("isp-router", "203.0.113.1"),
                "isp_dc": mac("isp-router", "198.51.100.1"), "shop": mac("shop-server", "198.51.100.10")}

ana = Shell("laptop", "ana")         # window 1 on Ana's laptop
ana2 = Shell("laptop", "ana")        # window 2 on Ana's laptop (the capture)
server = Shell("shop-server", "sam")  # the shop's admin, on the shop server

# ---------------------------------------------------------------- layer 1 and 2: the card, its link, its address
ana.run("link", "ip -brief link")
ana.run("ethtool", "sudo ethtool eth0 | grep -E 'Speed|Link detected'")

# ---------------------------------------------------------------- one request, caught at both ends
server.start("cap_server", "sudo tcpdump -i eth0 -w shop.pcap tcp port 80")
ana2.start("cap_ana", "sudo tcpdump -i eth0 -w ana.pcap tcp port 80")
ana.run("curl", "curl shop.example")
ana2.finish()
server.finish()
ana2.run("chown", "sudo chown ana ana.pcap")
server.run("chown_s", "sudo chown sam shop.pcap")
del steps["chown"], steps["chown_s"]  # housekeeping, not shown

ana.run("neigh", "ip neigh")

# The whole conversation, one line per packet
ana.run("list", "tshark -r ana.pcap")
# The request packet, one line per layer
ana.run("layers", "tshark -r ana.pcap -Y http.request -V | grep -v '^ '")
# Each layer's addresses, on Ana's side and on the shop's side
T = " | column -t"     # line the columns up under their names
F = "tshark -r {f} -Y http.request -T fields -E header=y"
ana.run("l2_ana", F.format(f="ana.pcap") + " -e eth.src -e eth.dst" + T)
ana.run("l3_ana", F.format(f="ana.pcap") + " -e ip.src -e ip.dst -e ip.ttl" + T)
ana.run("l4_ana", F.format(f="ana.pcap") + " -e tcp.srcport -e tcp.dstport" + T)
ana.run("l7_ana", F.format(f="ana.pcap") + " -e http.request.method -e http.host" + T)
ana.run("sizes", F.format(f="ana.pcap") + " -e frame.len -e ip.hdr_len -e tcp.hdr_len -e tcp.len" + T)
server.run("l2_shop", F.format(f="shop.pcap") + " -e eth.src -e eth.dst" + T)
server.run("l3_shop", F.format(f="shop.pcap") + " -e ip.src -e ip.dst -e ip.ttl" + T)
server.run("l4_shop", F.format(f="shop.pcap") + " -e tcp.srcport -e tcp.dstport" + T)
server.run("l7_shop", F.format(f="shop.pcap") + " -e http.request.method -e http.host" + T)

# ---------------------------------------------------------------- three faults, three layers
fault("laptop", "network card disconnected (layer 1)", "ip link set eth0 down")
ana.run("f1_link", "ip -brief link show eth0")
fault("laptop", "network card reconnected", "ip link set eth0 up && ip route replace default via 192.168.1.1")
fault("laptop", "wrong hardware address saved for the home router (layer 2)",
      "ip neigh replace 192.168.1.1 lladdr 02:00:00:00:00:01 nud permanent dev eth0")
ana.run("f2_neigh", "ip neigh")
ana.run("f2_ping", "ping -c 2 -W 1 192.168.1.1")
fault("laptop", "wrong entry removed (the laptop asks again)", "ip neigh del 192.168.1.1 dev eth0")
fault("isp-router", "provider router stops forwarding (layer 3)", "iptables -P FORWARD DROP")
ana.run("f3_ping_router", "ping -c 2 -W 1 192.168.1.1")
ana.run("f3_ping", "ping -c 2 -W 1 198.51.100.10")
fault("isp-router", "provider router forwarding again", "iptables -P FORWARD ACCEPT")
fault("shop-server", "web server stopped (layer 4: nothing on port 80)", "pkill -f '[h]ttp.server'")
ana.run("f4_ping", "ping -c 2 198.51.100.10")
ana.run("f4_curl", "curl -sS shop.example")
sh(f"docker exec -d {P}-shop-server sh -c 'cd /srv/shop && exec python3 -m http.server 80 >> /var/log/web.log 2>&1'")
faults.append({"box": "shop-server", "what": "web server started again", "cmd": "python3 -m http.server 80"})
time.sleep(1.5)
ana.run("f7_curl", "curl -I shop.example/sale")
ana.run("ok_again", "curl shop.example")

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