#!/usr/bin/env bash
# Real demos for P020 "Every Network Troubleshooting Command You Need" (Networking for Beginners, episode 4).
# 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 can't open the shop's website. We break one thing at a time from outside the terminals
# (a disconnected network card, a lost default route, a provider dropping traffic, a stopped DNS server,
# a web server listening only on itself, a wrong page address), and each command finds its fault.
# Commands are typed into real interactive shells (one pty per machine); commands + real output go to
# demo_output.json, and every fault and fix is recorded under "faults" so the script can say exactly what broke.
set -euo pipefail
cd "$(dirname "$0")"
P=ce-p020
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-p020
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 \
      iproute2 iputils-ping traceroute dnsutils curl iptables netcat-openbsd dnsmasq python3 procps >/dev/null \
 && rm -rf /var/lib/apt/lists/* \
 && userdel -r ubuntu 2>/dev/null; useradd -m -s /bin/bash 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 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
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 \
  && printf '<h1>Autumn sale</h1>\n' > /srv/shop/sales.html"
web() {  # web BIND: (re)start the shop's web server, listening on BIND port 80
  ex shop-server "pkill -f '[h]ttp.server' || true; sleep 0.5"
  docker exec -d $P-shop-server sh -c "cd /srv/shop && exec python3 -m http.server 80 --bind $1 >> /var/log/web.log 2>&1"
  sleep 1.5
}
web 0.0.0.0
sleep 1

python3 - "$P" <<'PY'
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):
    """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 web(bind, what):
    sh(f"docker exec {P}-shop-server pkill -f '[h]ttp.server'", check=False)
    time.sleep(0.5)
    sh(f"docker exec -d {P}-shop-server sh -c 'cd /srv/shop && exec python3 -m http.server 80 --bind {bind} >> /var/log/web.log 2>&1'")
    faults.append({"box": "shop-server", "what": what, "cmd": f"python3 -m http.server 80 --bind {bind}"})
    print(f"## shop-server: {what}", 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 = box, user, "~"
        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):
        assert key not in steps, key
        shown = self.prompt() + cmd
        t0 = time.time()
        os.write(self.fd, cmd.encode() + b"\n")
        out = self.read()
        steps[key] = {"cmd": shown, "out": out, "host": self.host, "seconds": round(time.time() - t0, 1),
                      "after_fault": len(faults)}
        print(f"== {key}\n{shown}\n{out}\n", flush=True)

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

# ---------------------------------------------------------------- healthy: the page loads
ana.run("ok_curl", "curl shop.example")

# ---------------------------------------------------------------- 1. ip address: is my network card up, with an address?
ana.run("addr_ok", "ip -brief address")
fault("laptop", "network card disconnected", "ip link set eth0 down")
ana.run("down_curl", "curl -sS -m 5 shop.example")
ana.run("addr_down", "ip -brief address")
fault("laptop", "network card reconnected (address and default route back)",
      "ip link set eth0 up && ip route replace default via 192.168.1.1")

# ---------------------------------------------------------------- 2. ip route: do I know the way out?
ana.run("route_ok", "ip route")
fault("laptop", "default route lost", "ip route del default")
ana.run("noroute_curl", "curl -sS -m 5 shop.example")
ana.run("route_bad", "ip route")
ana.run("noroute_ping", "ping -c 2 198.51.100.10")
fault("laptop", "default route back", "ip route replace default via 192.168.1.1")

# ---------------------------------------------------------------- 3. ping: can I reach it?
ana.run("ping_router", "ping -c 3 192.168.1.1")
ana.run("ping_server", "ping -c 3 198.51.100.10")
# ---------------------------------------------------------------- 4. traceroute: where does the path stop?
ana.run("trace_ok", "traceroute -n 198.51.100.10")
fault("isp-router", "provider drops everything going to the shop server",
      "iptables -I FORWARD -d 198.51.100.10 -j DROP")
ana.run("drop_curl", "curl -sS -m 5 shop.example")
ana.run("drop_ping", "ping -c 3 198.51.100.10")
ana.run("trace_bad", "traceroute -n -m 5 198.51.100.10")
fault("isp-router", "provider fixed", "iptables -D FORWARD -d 198.51.100.10 -j DROP")

# ---------------------------------------------------------------- 5. dig: does the name turn into an address?
ana.run("dig_ok", "dig +short shop.example")
fault("dns", "DNS server stopped", "pkill dnsmasq")
ana.run("dns_curl", "curl -sS -m 5 shop.example")
ana.run("dns_ping", "ping -c 2 198.51.100.10")
ana.run("dig_bad", "dig shop.example")
fault("dns", "DNS server started again", "dnsmasq --no-resolv --no-hosts --address=/shop.example/198.51.100.10")
ana.run("dig_back", "dig shop.example")

# ---------------------------------------------------------------- 6. nc: is the door (port) open?
ana.run("nc_ok", "nc -zv shop.example 80")
web("127.0.0.1", "web server restarted, listening only on the server itself (127.0.0.1)")
ana.run("bind_curl", "curl -sS -m 5 shop.example")
ana.run("bind_ping", "ping -c 2 shop.example")
ana.run("nc_bad", "nc -zv shop.example 80")

# ---------------------------------------------------------------- 7. ss: who is listening, on the server?
server.run("ss_bad", "ss -tlnp")
server.run("server_curl", "curl -s localhost")
web("0.0.0.0", "web server restarted, listening on every address (0.0.0.0)")
server.run("ss_ok", "ss -tlnp")
ana.run("nc_back", "nc -zv shop.example 80")

# ---------------------------------------------------------------- 8. curl: what does the website itself say?
ana.run("curl_404", "curl -I shop.example/sale")
ana.run("curl_200", "curl -I shop.example/sales.html")
ana.run("curl_v", "curl -v shop.example/sales.html")

steps["version"] = {"cmd": "versions", "out": sh(
    f"docker exec {P}-laptop sh -c '. /etc/os-release; echo $PRETTY_NAME; uname -r; "
    f"curl --version | head -1; ip -V; dig -v 2>&1; traceroute --version 2>&1 | head -1'; 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)
print("saved demo_output.json")
PY
