#!/usr/bin/env bash
# Real demos for P016 "Why the Internet Ran Out of IP Addresses" (Networking for Beginners, episode 3).
# Two homes on one street share one internet provider, all in Docker on this machine, with reserved test
# addresses only (RFC 1918 192.168.x, RFC 6598 shared space 100.64.0.0/24, RFC 5737 203.0.113.0/24,
# RFC 3849 IPv6 documentation space 2001:db8::/32):
#
#   ana-laptop 192.168.1.20 + 2001:db8:a::20 ── ana-router (NAT; IPv6 just forwarded) 100.64.0.2 + 2001:db8:100::2 ─┐
#   ben-laptop 192.168.0.20 (IPv4 only)      ── ben-router (NAT, an older router: IPv4 only) 100.64.0.3 ─────────────┤
#        isp-router (carrier-grade NAT: the street shares 203.0.113.7) 100.64.0.1 + 2001:db8:100::1 ─┘
#        ── server 203.0.113.10 + 2001:db8:200::10
#
# Nothing leaves this machine (the ISP router has no default route). Commands are typed into real interactive
# shells (one pty per machine); commands + real output go to demo_output.json. IANA's list of the 256 IPv4
# blocks is downloaded fresh each run (lab/ipv4-address-space.csv keeps the copy that was used).
set -euo pipefail
cd "$(dirname "$0")"
P=ce-p016
BOXES="ana-laptop ana-router ben-laptop ben-router isp-router server"
cleanup() {
  for b in $BOXES; do docker rm -f $P-$b >/dev/null 2>&1 || true; done
  for n in home-ana home-ben isp net; do docker network rm $P-$n >/dev/null 2>&1 || true; done
}
cleanup
trap cleanup EXIT
CSV=lab/ipv4-address-space.csv
curl -fsS -m 60 -o $CSV.new https://www.iana.org/assignments/ipv4-address-space/ipv4-address-space.csv \
  && mv $CSV.new $CSV || { rm -f $CSV.new; echo "IANA download failed, using the saved copy"; test -s $CSV; }

W=/tmp/ce-p016
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 curl iptables python3 procps ethtool >/dev/null \
 && rm -rf /var/lib/apt/lists/* \
 && userdel -r ubuntu 2>/dev/null; useradd -m -s /bin/bash ana && useradd -m -s /bin/bash ben
DOCKER
docker build -q ${DOCKER_BUILD_ARGS:-} -t $P-net $W/img >/dev/null

# Docker's own gateway on each network sits at the top of the range, so the routers own .1 / ::1.
docker network create --subnet 192.168.1.0/24 --gateway 192.168.1.254 \
  --ipv6 --subnet 2001:db8:a::/64 --gateway 2001:db8:a::fe $P-home-ana >/dev/null
docker network create --subnet 192.168.0.0/24 --gateway 192.168.0.254 $P-home-ben >/dev/null
docker network create --subnet 100.64.0.0/24 --gateway 100.64.0.254 \
  --ipv6 --subnet 2001:db8:100::/64 --gateway 2001:db8:100::fe $P-isp >/dev/null
docker network create --subnet 203.0.113.0/24 --gateway 203.0.113.254 \
  --ipv6 --subnet 2001:db8:200::/64 --gateway 2001:db8:200::fe $P-net >/dev/null

box() {  # box NAME NETWORK IPV4 IPV6|- [more docker args]
  local name=$1 net=$2 ip=$3 ip6=$4; shift 4
  local six=(--sysctl net.ipv6.conf.all.disable_ipv6=1)
  # keep_addr_on_down: renaming an interface takes it down, which would otherwise drop its IPv6 addresses.
  [ "$ip6" != - ] && six=(--sysctl net.ipv6.conf.all.disable_ipv6=0 --sysctl net.ipv6.conf.all.keep_addr_on_down=1 --ip6 "$ip6")
  docker run -d --name $P-$name --hostname $name --cap-add NET_ADMIN --cap-add NET_RAW "${six[@]}" "$@" \
    --network $P-$net --ip $ip $P-net sleep infinity >/dev/null
}
FWD=(--sysctl net.ipv4.ip_forward=1)
box ana-laptop home-ana 192.168.1.20 2001:db8:a::20
box ana-router home-ana 192.168.1.1 2001:db8:a::1 "${FWD[@]}" --sysctl net.ipv6.conf.all.forwarding=1
docker network connect --ip 100.64.0.2 --ip6 2001:db8:100::2 $P-isp $P-ana-router
box ben-laptop home-ben 192.168.0.20 -
box ben-router home-ben 192.168.0.1 - "${FWD[@]}"
docker network connect --ip 100.64.0.3 $P-isp $P-ben-router
box isp-router isp 100.64.0.1 2001:db8:100::1 "${FWD[@]}" --sysctl net.ipv6.conf.all.forwarding=1
docker network connect --ip 203.0.113.7 --ip6 2001:db8:200::7 $P-net $P-isp-router
box server net 203.0.113.10 2001:db8:200::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}'; }
rename() { ex $1 "ip link set $2 down && ip link set $2 name $3 && ip link set $3 up"; }
# Interfaces named for what they face: lan (the home), wan (the internet provider).
for r in ana ben; do
  lan=$(ifname $r-router 192.168.$([ $r = ana ] && echo 1 || echo 0).1); wan=$(ifname $r-router 100.64.0.$([ $r = ana ] && echo 2 || echo 3))
  rename $r-router $lan lan; rename $r-router $wan wan
  ex $r-router "ip route replace default via 100.64.0.1 dev wan && iptables -t nat -A POSTROUTING -o wan -j MASQUERADE"
done
ex ana-router "ip -6 route replace default via 2001:db8:100::1 dev wan"
cust=$(ifname isp-router 100.64.0.1); inet=$(ifname isp-router 203.0.113.7)
rename isp-router $cust customers; rename isp-router $inet internet
ex isp-router "ip route del default 2>/dev/null; ip -6 route del default 2>/dev/null; true"
# Carrier-grade NAT: every customer leaves with the provider's one public address.
ex isp-router "iptables -t nat -A POSTROUTING -o internet -j SNAT --to-source 203.0.113.7"
# IPv6 is not translated: the provider just routes Ana's home range to her router.
ex isp-router "ip -6 route replace 2001:db8:a::/64 via 2001:db8:100::2 dev customers"
ex ana-laptop "ip route replace default via 192.168.1.1 && ip -6 route replace default via 2001:db8:a::1"
ex ben-laptop "ip route replace default via 192.168.0.1"
ex server "ip route replace default via 203.0.113.7 && ip -6 route replace default via 2001:db8:200::7"
for b in ana-laptop ben-laptop; do
  ex $b "printf '203.0.113.10 server.example\n2001:db8:200::10 server.example\n' >> /etc/hosts"
done
docker cp lab/web.py $P-server:/root/; docker cp lab/web.py $P-ana-laptop:/home/ana/
docker cp lab/bits.py $P-ana-laptop:/home/ana/; docker cp lab/blocks.py $P-ana-laptop:/home/ana/
docker cp $CSV $P-ana-laptop:/home/ana/ipv4-address-space.csv
ex ana-laptop "chown ana:ana /home/ana/*"
sleep 1

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):
    return subprocess.run(cmd, shell=True, capture_output=True, text=True, check=True).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=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 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 web server) 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):
        time.sleep(1)
        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)

ana = Shell("ana-laptop", "ana")
ben = Shell("ben-laptop", "ben")
ana_router = Shell("ana-router", "root")
server = Shell("server", "root")

# ---------------------------------------------------------------- 1. what an address is
ana.run("bits4", "python3 bits.py 192.168.1.20")
ana.run("count4", "python3 -c 'print(2**32)'")
# ---------------------------------------------------------------- 2. where the 256 blocks went
ana.run("blocks", "python3 blocks.py ipv4-address-space.csv")
ana.run("legacy", "grep -E '^0(17|19)/8' ipv4-address-space.csv | cut -d, -f1-3")
# ---------------------------------------------------------------- 3. sharing: two homes, one public address
ana.run("ana_addr4", "ip -4 -brief address show eth0")
ben.run("ben_addr4", "ip -4 -brief address show eth0")
ana_router.run("router_wan4", "ip -4 -brief address show wan")
server.start("web4", "python3 web.py 80 Hello from the server")
ana.run("ana_visit4", "curl -4 -s -m 10 server.example")
ben.run("ben_visit4", "curl -4 -s -m 10 server.example")
server.finish()
# Ana shares photos from her laptop; can anyone outside reach it?
ana.start("ana_share", "python3 web.py 8000 \"Photos from Ana's laptop\"")
server.run("reach4", "curl -4 -sS -m 5 http://203.0.113.7:8000")
# ---------------------------------------------------------------- 4. IPv6
ana.finish()
ana.run("bits6", "python3 bits.py 2001:db8:a::20")
ana.run("count6", "python3 -c 'print(2**128)'")
ana.run("ana_addr6", "hostname -I")
server.start("web6", "python3 web.py 80 Hello from the server")
ana.run("ana_visit6", "curl -6 -s -m 10 server.example")
ana.run("ana_visit4b", "curl -4 -s -m 10 server.example")
ben.run("ben_visit6", "curl -6 -sS -m 5 server.example")
server.finish()
ana.start("ana_share6", "python3 web.py 8000 \"Photos from Ana's laptop\"")
server.run("reach6", "curl -6 -s -m 5 'http://[2001:db8:a::20]:8000'")
ana.finish()
ben.run("ben_addr6", "hostname -I")

steps["version"] = {"cmd": "versions", "out": sh(
    f"docker exec {P}-ana-laptop sh -c '. /etc/os-release; echo $PRETTY_NAME; uname -r; "
    f"curl --version | head -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")
PY
