#!/usr/bin/env bash
# Real demos for P024 "HTTP vs HTTPS: What Actually Changes on Your Network" (Networking for Beginners, episode 6).
# The lab network from the earlier episodes, in Docker on this machine only, with reserved test addresses
# (RFC 1918 192.168.1.0/24 in the café, RFC 5737 203.0.113.0/24 and 198.51.100.0/24 "on the internet"):
#
#   laptop 192.168.1.20 ── cafe-router (NAT, the café's DNS) 192.168.1.1 | 203.0.113.7
#   fake-shop 192.168.1.66 ┘  ── isp-router 203.0.113.1 | 198.51.100.1 (20 ms each way)
#                                 ── shop-server 198.51.100.10 (web :80 and :443) and dns 198.51.100.53
#
# Nothing leaves this machine: the ISP router has no default route. The certificate authority is our own lab CA,
# added to the laptop's trusted list (standing in for a public one such as Let's Encrypt).
# The story: Ana logs in to the shop over the café's Wi-Fi. The café's router records everything that passes.
# Over HTTP it reads her password; over HTTPS it sees only addresses, the site's name and encrypted bytes.
# Then an impostor on the café network, the certificate check that stops it, the time HTTPS costs, and how sites
# force HTTPS (a redirect plus HSTS).
# 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-p024
BOXES="laptop cafe-router fake-shop 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 cafe isp dc; do docker network rm $P-$n >/dev/null 2>&1 || true; done
}
cleanup
trap cleanup EXIT
W=/tmp/ce-p024
rm -rf $W && mkdir -p $W/img $W/pki

cat > $W/img/Dockerfile <<'DOCKER'
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 \
      binutils openssl ca-certificates >/dev/null \
 && rm -rf /var/lib/apt/lists/* \
 && userdel -r ubuntu 2>/dev/null; for u in ana sam eve; do useradd -m -s /bin/bash $u \
      && echo "$u ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$u && chmod 440 /etc/sudoers.d/$u; done
DOCKER
docker build -q -t $P-net $W/img >/dev/null

# The shop's web program: a home page and a login form, on port 80 (HTTP) and 443 (HTTPS).
# With --secure, port 80 only redirects to HTTPS and HTTPS answers add HSTS.
cat > $W/shop.py <<'PY'
import http.server, ssl, sys, threading, urllib.parse
SECURE = "--secure" in sys.argv
NAME = sys.argv[1]
LOG = sys.argv[2] if len(sys.argv) > 2 and not sys.argv[2].startswith("--") else None

class H(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    disable_nagle_algorithm = True   # send each answer at once, as real web servers do
    server_version = "shop/1.0"
    sys_version = ""
    def log_message(self, *a): pass
    def send(self, code, body, extra=()):
        b = body.encode()
        self.send_response(code)
        for k, v in extra: self.send_header(k, v)
        if self.server.tls and SECURE: self.send_header("Strict-Transport-Security", "max-age=31536000")
        self.send_header("Content-Type", "text/plain")
        self.send_header("Content-Length", str(len(b)))
        self.end_headers()
        if self.command != "HEAD": self.wfile.write(b)
    def plain_redirect(self):
        if SECURE and not self.server.tls:
            self.send(301, "Moved to HTTPS\n", [("Location", f"https://{NAME}{self.path}")]); return True
    def do_HEAD(self): self.do_GET()
    def do_GET(self):
        if not self.plain_redirect(): self.send(200, f"Welcome to {NAME}\n")
    def do_POST(self):
        form = self.rfile.read(int(self.headers.get("Content-Length", 0))).decode()
        if LOG:
            with open(LOG, "a") as f: f.write(form + "\n")
        if self.plain_redirect(): return
        user = urllib.parse.parse_qs(form).get("user", ["?"])[0]
        self.send(200, f"Welcome back, {user}\n", [("Set-Cookie", "session=7f3a9c2e41d8")])

def serve(port, tls):
    s = http.server.ThreadingHTTPServer(("0.0.0.0", port), H)
    s.tls = tls
    if tls:
        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
        ctx.load_cert_chain("/srv/tls/cert.pem", "/srv/tls/key.pem")
        s.socket = ctx.wrap_socket(s.socket, server_side=True)
    s.serve_forever()

threading.Thread(target=serve, args=(80, False), daemon=True).start()
serve(443, True)
PY

# PKI, made with openssl inside the lab image: a lab root CA, the shop's certificate signed by it,
# and the impostor's own self-signed certificate for the same name.
docker run --rm -v $W/pki:/pki -w /pki $P-net sh -c '
  openssl req -x509 -newkey rsa:2048 -nodes -days 365 -keyout ca.key -out ca.crt -subj "/CN=Lab Root CA" 2>/dev/null
  openssl req -newkey rsa:2048 -nodes -keyout shop.key -out shop.csr -subj "/CN=shop.example" 2>/dev/null
  printf "subjectAltName=DNS:shop.example\n" > san.ext
  openssl x509 -req -in shop.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 90 -extfile san.ext -out shop.crt 2>/dev/null
  openssl req -x509 -newkey rsa:2048 -nodes -days 90 -keyout fake.key -out fake.crt -subj "/CN=shop.example" \
    -addext "subjectAltName=DNS:shop.example" 2>/dev/null
  chmod 644 *'

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 fake-shop cafe 192.168.1.66
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 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 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 wifi && ip link set wifi 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"
IA=$(ifname isp-router 203.0.113.1); IB=$(ifname isp-router 198.51.100.1)
# 20 ms each way on the provider's router, so Ana's round trip to the shop is ~40 ms, like a real internet path.
ex isp-router "tc qdisc add dev $IA root netem delay 20ms && tc qdisc add dev $IB root netem delay 20ms"
ex laptop "ip route replace default via 192.168.1.1 && echo 'nameserver 192.168.1.1' > /etc/resolv.conf"
ex fake-shop "ip route replace default via 192.168.1.1"
for b in shop-server dns; do ex $b "ip route replace default via 198.51.100.1"; done
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"
# The café's router is also the café's DNS: it passes questions on to the real DNS server, and caches nothing.
ex cafe-router "dnsmasq --no-resolv --no-hosts --cache-size=0 --server=198.51.100.53"
# The laptop trusts the lab's CA, like a real laptop trusts the public CAs that ship with it.
docker cp $W/pki/ca.crt $P-laptop:/usr/local/share/ca-certificates/lab-root-ca.crt
ex laptop "update-ca-certificates >/dev/null 2>&1"
for b in shop-server fake-shop; do ex $b "mkdir -p /srv/tls"; docker cp $W/shop.py $P-$b:/srv/shop.py; done
docker cp $W/pki/shop.crt $P-shop-server:/srv/tls/cert.pem; docker cp $W/pki/shop.key $P-shop-server:/srv/tls/key.pem
docker cp $W/pki/fake.crt $P-fake-shop:/srv/tls/cert.pem; docker cp $W/pki/fake.key $P-fake-shop:/srv/tls/key.pem
docker exec -d $P-shop-server sh -c "exec python3 /srv/shop.py shop.example"
ex fake-shop "touch /home/eve/stolen.txt && chown eve /home/eve/stolen.txt"
docker exec -d $P-fake-shop sh -c "exec python3 /srv/shop.py shop.example /home/eve/stolen.txt"
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.5)

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 = 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 prompt(self):
        return f"{self.user}@{self.host}:{self.where}$ "

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

ana = Shell("laptop", "ana")              # Ana's laptop, in the café
cafe = Shell("cafe-router", "sam")        # the café's router (Sam runs the café's network)
eve = Shell("fake-shop", "eve")           # an impostor on the same Wi-Fi
FORM = "user=ana&password=Blue-Kettle-42"

# ---------------------------------------------------------------- HTTP: what the café's router sees
cafe.start("cap_http", "sudo tcpdump -i wifi -w http.pcap port 80")
ana.run("login_http", f"curl -d '{FORM}' http://shop.example/login")
cafe.finish()
cafe.run("http_req", "tshark -r http.pcap -Y http.request -T fields -E header=y -e ip.src -e http.host "
                     "-e http.request.method -e http.request.uri | column -t")
cafe.run("http_strings", "grep -aoE 'password=[A-Za-z0-9-]+|session=[0-9a-f]+|Welcome back, [a-z]+' http.pcap")

# ---------------------------------------------------------------- HTTPS: the same login, encrypted
cafe.start("cap_https", "sudo tcpdump -i wifi -w https.pcap port 443 or port 53")
ana.run("login_https", f"curl -d '{FORM}' https://shop.example/login")
cafe.finish()
cafe.run("https_list", "tshark -r https.pcap")
cafe.run("https_grep", "grep -acE 'password|session|Welcome' https.pcap")
cafe.run("https_http", "tshark -r https.pcap -Y http | wc -l")
cafe.run("https_dns", "tshark -r https.pcap -Y dns.a -T fields -E header=y -e dns.qry.name -e dns.a | column -t")
cafe.run("https_sni", "tshark -r https.pcap -Y 'tls.handshake.type == 1' -T fields -E header=y "
                      "-e ip.dst -e tcp.dstport -e tls.handshake.extensions_server_name | column -t")
cafe.run("https_sizes", "tshark -r https.pcap -Y 'tls.app_data' -T fields -E header=y "
                        "-e ip.src -e frame.len | column -t")

# ---------------------------------------------------------------- the certificate
ana.run("cert", "curl -sv https://shop.example/ -o /dev/null 2>&1 | grep -E 'SSL connection|subject:|issuer:|expire date|verify ok'")
ana.run("trust", "grep -c 'BEGIN CERTIFICATE' /etc/ssl/certs/ca-certificates.crt")

# ---------------------------------------------------------------- an impostor on the café network
fault("cafe-router", "the café's DNS now sends shop.example to the impostor at 192.168.1.66",
      "pkill dnsmasq; sleep 0.5; dnsmasq --no-resolv --no-hosts --cache-size=0 "
      "--server=198.51.100.53 --address=/shop.example/192.168.1.66")
ana.run("fake_dig", "dig +short shop.example")
ana.run("fake_http", f"curl -d '{FORM}' http://shop.example/login")
eve.run("stolen_http", "cat stolen.txt")
ana.run("fake_https", f"curl -d '{FORM}' https://shop.example/login")
eve.run("stolen_https", "cat stolen.txt")
fault("cafe-router", "the café's DNS is honest again",
      "pkill dnsmasq; sleep 0.5; dnsmasq --no-resolv --no-hosts --cache-size=0 "
      "--server=198.51.100.53")
ana.run("real_dig", "dig +short shop.example")

# ---------------------------------------------------------------- what HTTPS costs: one more round trip
W = "-w 'dns %{time_namelookup}  connect %{time_connect}  tls %{time_appconnect}  total %{time_total}\\n'"
ana.run("ping_shop", "ping -c 3 -q shop.example")
for i in (1, 2, 3):
    ana.run(f"time_http{i}", f"curl -so /dev/null {W} http://shop.example/")
    ana.run(f"time_https{i}", f"curl -so /dev/null {W} https://shop.example/")
ana.run("aes_speed", "openssl speed -seconds 1 -bytes 16384 -evp aes-256-gcm 2>/dev/null | tail -2")

# ---------------------------------------------------------------- forcing HTTPS: redirect + HSTS
sh(f"docker exec {P}-shop-server pkill -f '[s]hop.py'")
sh(f"docker exec -d {P}-shop-server sh -c 'exec python3 /srv/shop.py shop.example --secure'")
faults.append({"box": "shop-server", "what": "the shop now redirects HTTP to HTTPS and sends HSTS",
               "cmd": "python3 /srv/shop.py shop.example --secure"})
time.sleep(2)
ana.run("redirect", "curl -sI http://shop.example/ | grep -E 'HTTP/|Location'")
ana.run("hsts", "curl -sI https://shop.example/ | grep -E 'HTTP/|Strict'")
ana.run("follow", "curl -sL http://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; curl --version | head -1; openssl 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
