@celilo/e2e 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +5 -5
  2. package/bin/e2e-status +1 -1
  3. package/bin/e2e-up +2 -2
  4. package/config/dns/tangohost.com.zone +1 -1
  5. package/config/dns/templates/example.net.zone +3 -3
  6. package/config/dns/templates/iamtheinternet.org.zone +2 -2
  7. package/config/routing/fw-ext-routes.sh +8 -8
  8. package/config/routing/fw-isp-routes.sh +2 -2
  9. package/config/routing/fw-main-routes.sh +5 -5
  10. package/config/routing/management-routes.sh +1 -1
  11. package/config/routing/observer-setup.sh +1 -1
  12. package/config/routing/public-sim-entrypoint.sh +1 -1
  13. package/config/routing/resolver-internal-routes.sh +2 -2
  14. package/config/routing/resolver-routes.sh +1 -1
  15. package/config/routing/target-routes.sh +1 -1
  16. package/config/routing/target-setup.sh +1 -1
  17. package/config/socks/startup.sh +2 -2
  18. package/docker/Dockerfile.firewall +12 -1
  19. package/package.json +5 -4
  20. package/simulators/greenwave/server.ts +35 -0
  21. package/simulators/greenwave/state.ts +64 -11
  22. package/simulators/ip-echo/server.ts +1 -1
  23. package/src/address-plan.test.ts +4 -4
  24. package/src/cli/build.ts +30 -0
  25. package/src/cli/command-registry.ts +19 -0
  26. package/src/cli/completion.ts +16 -1
  27. package/src/cli/index.ts +85 -4
  28. package/src/container-manager.ts +191 -14
  29. package/src/docker-compose-generator.ts +55 -26
  30. package/src/doctor.test.ts +279 -0
  31. package/src/doctor.ts +421 -0
  32. package/src/extract-failure.ts +41 -0
  33. package/src/index.ts +12 -0
  34. package/src/last-run.test.ts +62 -0
  35. package/src/last-run.ts +54 -0
  36. package/src/observer.test.ts +2 -2
  37. package/src/observer.ts +1 -1
  38. package/src/router-swap.ts +121 -0
  39. package/src/run-lock.test.ts +73 -2
  40. package/src/run-lock.ts +110 -14
  41. package/src/runner.ts +99 -5
  42. package/src/socks-proxy.ts +2 -2
  43. package/src/types.ts +48 -8
  44. package/src/vantage.test.ts +1 -1
  45. package/src/zone-classifier.test.ts +11 -16
  46. package/src/zone-classifier.ts +16 -31
package/README.md CHANGED
@@ -20,7 +20,7 @@ The network consists of 7 Docker bridge networks simulating a realistic home lab
20
20
  | **dmz** | 10.226.10.0/24 | Public-facing services |
21
21
  | **app** | 10.226.20.0/24 | Internal applications |
22
22
  | **secure** | 10.226.30.0/24 | Sensitive services |
23
- | **isp-external** | 100.100.0.0/24 | ISP network (between home and internet) |
23
+ | **isp-external** | 203.0.113.0/24 | ISP network (between home and internet) |
24
24
  | **internet-external** | 100.64.0.0/24 | Simulated internet (DNS, ACME, etc.) |
25
25
  | **real-internet** | 172.30.0.0/24 | Bridge to actual internet (for apt, pip, etc.) |
26
26
 
@@ -43,9 +43,9 @@ Don't hardcode an octet: use `zoneIp(zone, host)` / `ZONE_SUBNETS` / `ZONE_GATEW
43
43
  |---------|------|----------|-----|
44
44
  | **management** | Celilo CLI, Ansible, SSH | internal | 10.226.1.100 |
45
45
  | **fw-main** | iptables firewall (managed by Celilo) | internal, dmz, app, secure | 10.226.1.254, 10.226.10.1, 10.226.20.1, 10.226.30.1 |
46
- | **fw-isp** | Greenwave router simulator | internal, isp-external | 10.226.1.1, 100.100.0.100 |
47
- | **fw-ext** | Edge router + transparent HTTPS proxy (Squid) | isp-external, internet-external, real-internet | 100.100.0.101, 100.64.0.1, 172.30.0.5 |
48
- | **comcast-resolver** | Unbound recursive DNS resolver | isp-external, real-internet | 100.100.0.1, 172.30.0.4 |
46
+ | **fw-isp** | Greenwave router simulator | internal, isp-external | 10.226.1.1, 203.0.113.100 |
47
+ | **fw-ext** | Edge router + transparent HTTPS proxy (Squid) | isp-external, internet-external, real-internet | 203.0.113.101, 100.64.0.1, 172.30.0.5 |
48
+ | **comcast-resolver** | Unbound recursive DNS resolver | isp-external, real-internet | 203.0.113.1, 172.30.0.4 |
49
49
  | **root-dns** | Knot authoritative DNS (root zone) | internet-external | 100.64.0.53 |
50
50
  | **tld-dns** | Knot authoritative DNS (.com, .org TLDs) | internet-external | 100.64.0.54 |
51
51
  | **namecheap-dns** | Knot DNS + DDNS API simulator | internet-external | 100.64.0.55 |
@@ -181,7 +181,7 @@ c system init --accept-defaults \
181
181
  network.internal.subnet=10.226.1.0/24 \
182
182
  primary_domain=iamtheinternet.org \
183
183
  admin.email=admin@iamtheinternet.org \
184
- dns.primary=100.100.0.1 \
184
+ dns.primary=203.0.113.1 \
185
185
  dns.fallback=1.0.0.1,8.8.8.8
186
186
 
187
187
  # Import all modules
package/bin/e2e-status CHANGED
@@ -22,7 +22,7 @@ docker compose -p "$PROJECT_NAME" exec -T comcast-resolver dig @127.0.0.1 www.ia
22
22
 
23
23
  echo ""
24
24
  echo "=== Connectivity (from management) ==="
25
- for target in "fw-main:10.226.1.254" "fw-isp:10.226.1.1" "comcast-resolver:100.100.0.1" "root-dns:100.64.0.53" "namecheap-dns:100.64.0.55"; do
25
+ for target in "fw-main:10.226.1.254" "fw-isp:10.226.1.1" "comcast-resolver:203.0.113.1" "root-dns:100.64.0.53" "namecheap-dns:100.64.0.55"; do
26
26
  name="${target%%:*}"
27
27
  ip="${target##*:}"
28
28
  echo -n " $name ($ip): "
package/bin/e2e-up CHANGED
@@ -135,14 +135,14 @@ echo " internal: 10.226.1.0/24"
135
135
  echo " dmz: 10.226.10.0/24"
136
136
  echo " app: 10.226.20.0/24"
137
137
  echo " secure: 10.226.30.0/24"
138
- echo " isp-external: 100.100.0.0/24"
138
+ echo " isp-external: 203.0.113.0/24"
139
139
  echo " internet-external: 100.64.0.0/24"
140
140
  echo ""
141
141
  echo "Key machines:"
142
142
  echo " management: 10.226.1.100 (conductor CLI)"
143
143
  echo " fw-main: 10.226.1.254 (iptables firewall)"
144
144
  echo " fw-isp: 10.226.1.1 (greenwave router sim)"
145
- echo " comcast-resolver: 100.100.0.1 (DNS resolver)"
145
+ echo " comcast-resolver: 203.0.113.1 (DNS resolver)"
146
146
  echo " namecheap-dns: 100.64.0.55 (DDNS simulator)"
147
147
  echo " letsencrypt: 100.64.0.100 (Pebble ACME)"
148
148
  echo " apt-cache: 100.64.0.2 (apt proxy → real internet)"
@@ -7,7 +7,7 @@ $TTL 300
7
7
 
8
8
  ; The OFF-FLEET host's own public DNS — it owns this zone, celilo does not.
9
9
  ; Both names point at the host's own public address, NOT the customer
10
- ; firewall (100.100.0.100): this host lives out on the simulated internet.
10
+ ; firewall (203.0.113.100): this host lives out on the simulated internet.
11
11
  @ IN NS ns1.tangohost.com.
12
12
  ns1 IN A 100.64.0.55
13
13
  @ IN A 100.64.0.63
@@ -8,9 +8,9 @@ $TTL 300
8
8
  ; Public DNS — RFC 1918 addresses NEVER appear here. The whole point of
9
9
  ; the e2e simulation is to model real internet connectivity, where
10
10
  ; private IPs are unreachable from outside. All public-facing names
11
- ; resolve to the firewall's external IP (100.100.0.100), which DNATs
11
+ ; resolve to the firewall's external IP (203.0.113.100), which DNATs
12
12
  ; inbound to the right internal host.
13
13
  @ IN NS ns1.example.net.
14
14
  ns1 IN A 100.64.0.55
15
- @ IN A 100.100.0.100
16
- www IN A 100.100.0.100
15
+ @ IN A 203.0.113.100
16
+ www IN A 203.0.113.100
@@ -8,9 +8,9 @@ $TTL 300
8
8
  ; Public DNS — RFC 1918 addresses NEVER appear here. The whole point of
9
9
  ; the e2e simulation is to model real internet connectivity, where
10
10
  ; private IPs are unreachable from outside. All public-facing names
11
- ; resolve to the firewall's external IP (100.100.0.100), which DNATs
11
+ ; resolve to the firewall's external IP (203.0.113.100), which DNATs
12
12
  ; inbound to the right internal host.
13
13
  @ IN NS ns1.iamtheinternet.org.
14
14
  ns1 IN A 100.64.0.55
15
15
  @ IN A 0.0.0.0
16
- www IN A 100.100.0.100
16
+ www IN A 203.0.113.100
@@ -6,10 +6,10 @@ ip route del default 2>/dev/null || true
6
6
  # Default route via real-internet bridge (for Squid outbound to real internet)
7
7
  ip route add default via 172.30.0.1
8
8
  # Internal subnets via fw-isp
9
- ip route add 10.226.1.0/24 via 100.100.0.100 # Internal via fw-isp
10
- ip route add 10.226.10.0/24 via 100.100.0.100 # dmz via fw-isp
11
- ip route add 10.226.20.0/24 via 100.100.0.100 # app via fw-isp
12
- ip route add 10.226.30.0/24 via 100.100.0.100 # secure via fw-isp
9
+ ip route add 10.226.1.0/24 via 203.0.113.100 # Internal via fw-isp
10
+ ip route add 10.226.10.0/24 via 203.0.113.100 # dmz via fw-isp
11
+ ip route add 10.226.20.0/24 via 203.0.113.100 # app via fw-isp
12
+ ip route add 10.226.30.0/24 via 203.0.113.100 # secure via fw-isp
13
13
 
14
14
  # IP forwarding is set via sysctls in docker-compose
15
15
 
@@ -17,7 +17,7 @@ ip route add 10.226.30.0/24 via 100.100.0.100 # secure via fw-isp
17
17
  #
18
18
  # The customer's public /24 is ROUTED, never re-NAT'd. An ISP does not NAT a
19
19
  # subscriber that already holds a public address — the whole point of
20
- # 100.100.0.100 is that it IS the customer's address on the internet. fw-ext
20
+ # 203.0.113.100 is that it IS the customer's address on the internet. fw-ext
21
21
  # used to MASQUERADE it anyway, so every packet leaving the fleet reached the
22
22
  # public simulators bearing fw-ext's own 100.64.0.1.
23
23
  #
@@ -28,7 +28,7 @@ ip route add 10.226.30.0/24 via 100.100.0.100 # secure via fw-isp
28
28
  # and any inbound reach. It also silently clobbered seeded apex records.
29
29
  # Anything that reasons about "the address the internet sees us as" was
30
30
  # measuring the simulator's own artefact.
31
- iptables -t nat -A POSTROUTING -s 100.100.0.0/24 -j RETURN
31
+ iptables -t nat -A POSTROUTING -s 203.0.113.0/24 -j RETURN
32
32
  iptables -t nat -A POSTROUTING -j MASQUERADE
33
33
  iptables -P FORWARD ACCEPT
34
34
 
@@ -66,12 +66,12 @@ done
66
66
 
67
67
  # Transparent proxy: REDIRECT HTTP/HTTPS from isp-external to local Squid
68
68
  # Exception: traffic to simulated services (100.64.0.0/24) passes through directly
69
- ISP_IFACE=$(ip -o addr show | grep '100.100.0.101' | awk '{print $2}')
69
+ ISP_IFACE=$(ip -o addr show | grep '203.0.113.101' | awk '{print $2}')
70
70
  iptables -t nat -A PREROUTING -i "$ISP_IFACE" -p tcp --dport 80 ! -d 100.64.0.0/24 -j REDIRECT --to-port 3128
71
71
  iptables -t nat -A PREROUTING -i "$ISP_IFACE" -p tcp --dport 443 ! -d 100.64.0.0/24 -j REDIRECT --to-port 3129
72
72
 
73
73
  # DNS
74
- echo "nameserver 100.100.0.1" > /etc/resolv.conf
74
+ echo "nameserver 203.0.113.1" > /etc/resolv.conf
75
75
 
76
76
  # Fetch Pebble's runtime ACME root CA (Pebble generates a fresh root at each startup)
77
77
  # Retry until Pebble is up and responding
@@ -10,7 +10,7 @@ chmod 600 /root/.ssh/authorized_keys
10
10
 
11
11
  # Routing
12
12
  ip route del default 2>/dev/null || true
13
- ip route add default via 100.100.0.101 # Internet via fw-ext
13
+ ip route add default via 203.0.113.101 # Internet via fw-ext
14
14
  ip route add 10.226.10.0/24 via 10.226.1.254 # dmz via fw-main
15
15
  ip route add 10.226.20.0/24 via 10.226.1.254 # app via fw-main
16
16
  ip route add 10.226.30.0/24 via 10.226.1.254 # secure via fw-main
@@ -22,7 +22,7 @@ iptables -t nat -A POSTROUTING -j MASQUERADE
22
22
  iptables -P FORWARD ACCEPT
23
23
 
24
24
  # DNS
25
- echo "nameserver 100.100.0.1" > /etc/resolv.conf
25
+ echo "nameserver 203.0.113.1" > /etc/resolv.conf
26
26
 
27
27
  # Start dnsmasq DHCP server on internal network
28
28
  dnsmasq --conf-dir=/etc/dnsmasq.d --keep-in-foreground --log-facility=- &
@@ -14,17 +14,17 @@ iptables -P FORWARD ACCEPT
14
14
  # Routing + egress NAT — depends on topology (detected by the presence of an
15
15
  # isp-external interface).
16
16
  ip route del default 2>/dev/null || true
17
- if ip -o addr show | grep -q '100.100.0'; then
17
+ if ip -o addr show | grep -q '203.0.113'; then
18
18
  # Direct-internet: fw-main has its own external (WAN) interface.
19
- ip route add default via 100.100.0.101
19
+ ip route add default via 203.0.113.101
20
20
  # Egress NAT SCOPED to the external/WAN interface (the one with the public
21
- # 100.100.0.x address) — NOT a blanket MASQUERADE (ISS-0156). Scoping to the WAN
21
+ # 203.0.113.x address) — NOT a blanket MASQUERADE (ISS-0156). Scoping to the WAN
22
22
  # means inter-zone traffic (protected↔protected, protected↔internal) keeps its
23
23
  # source, so the dmz-resident resolver sees each client's real zone for
24
24
  # source-based DNS views. This boot-time rule is what target machines need for
25
25
  # egress/DNS BEFORE any module deploys; celilo's iptables module applies the
26
26
  # same WAN-scoped MASQUERADE on deploy (idempotent — the production code path).
27
- EXT_IFACE=$(ip -o addr show | awk '/100\.100\.0\./{print $2; exit}')
27
+ EXT_IFACE=$(ip -o addr show | awk '/203\.0\.113\./{print $2; exit}')
28
28
  iptables -t nat -A POSTROUTING -o "$EXT_IFACE" -j MASQUERADE
29
29
  else
30
30
  # Two-layer: no external interface on fw-main; it routes outbound to the
@@ -53,7 +53,7 @@ else
53
53
  fi
54
54
 
55
55
  # DNS
56
- echo "nameserver 100.100.0.1" > /etc/resolv.conf
56
+ echo "nameserver 203.0.113.1" > /etc/resolv.conf
57
57
 
58
58
  echo "fw-main ready"
59
59
  sleep infinity
@@ -42,7 +42,7 @@ ip route add 10.226.120.0/24 via "$FW_MAIN" 2>/dev/null || true
42
42
  # Technitium at 10.226.1.10 is authoritative for iamtheinternet.org internally,
43
43
  # resolving www/auth to Caddy's DMZ IP (10.226.10.10) rather than the public WAN IP.
44
44
  # Fall back to the simulated public resolver if Technitium is not yet up.
45
- printf 'nameserver %s\nnameserver 100.100.0.1\n' "${INTERNAL_RESOLVER:-10.226.1.10}" > /etc/resolv.conf
45
+ printf 'nameserver %s\nnameserver 203.0.113.1\n' "${INTERNAL_RESOLVER:-10.226.1.10}" > /etc/resolv.conf
46
46
 
47
47
  # Fetch Pebble's runtime ACME root CA (Pebble generates a fresh root at each
48
48
  # startup, unlike the static /config/pebble/pebble-ca.crt baked in at build
@@ -14,7 +14,7 @@ set -u
14
14
 
15
15
  GATEWAY="${OBSERVER_GATEWAY:?OBSERVER_GATEWAY is required}"
16
16
  INTERZONE="${OBSERVER_INTERZONE:-0}"
17
- RESOLVERS="${OBSERVER_RESOLVERS:-100.100.0.1}"
17
+ RESOLVERS="${OBSERVER_RESOLVERS:-203.0.113.1}"
18
18
 
19
19
  echo "Observer starting: gateway=${GATEWAY} interzone=${INTERZONE} resolvers='${RESOLVERS}'"
20
20
 
@@ -2,7 +2,7 @@
2
2
  # Entrypoint wrapper for a simulator on `internet-external` — a host out on
3
3
  # the simulated public internet.
4
4
  #
5
- # Such a host must reach the customer's public prefix (100.100.0.0/24) through
5
+ # Such a host must reach the customer's public prefix (203.0.113.0/24) through
6
6
  # the ISP edge, fw-ext. Docker's default bridge gateway has no path across
7
7
  # networks, so without this route a reply to the customer's WAN address is
8
8
  # simply dropped and the connection hangs.
@@ -13,7 +13,7 @@ CADDY_IP="${CADDY_IP:-10.226.10.10}"
13
13
  # vars in docker-compose.
14
14
  DOMAINS="${DOMAINS:-iamtheinternet.org,example.net}"
15
15
 
16
- # Routing — reach comcast-resolver (100.100.0.1) via fw-main or fw-isp.
16
+ # Routing — reach comcast-resolver (203.0.113.1) via fw-main or fw-isp.
17
17
  # DEFAULT_GATEWAY is set by docker-compose from the topology config.
18
18
  GATEWAY="${DEFAULT_GATEWAY:-10.226.1.254}"
19
19
  ip route del default 2>/dev/null || true
@@ -75,7 +75,7 @@ $(printf "${SPLIT_HORIZON_BLOCK}")
75
75
 
76
76
  forward-zone:
77
77
  name: "."
78
- forward-addr: 100.100.0.1
78
+ forward-addr: 203.0.113.1
79
79
  EOF
80
80
 
81
81
  /usr/sbin/unbound -d &
@@ -6,7 +6,7 @@ ip route del default 2>/dev/null || true
6
6
  ip route add default via 172.30.0.1
7
7
 
8
8
  # Keep route to simulated internet services via fw-ext
9
- ip route add 100.64.0.0/24 via 100.100.0.101
9
+ ip route add 100.64.0.0/24 via 203.0.113.101
10
10
 
11
11
  # Start unbound
12
12
  /usr/sbin/unbound -d &
@@ -17,7 +17,7 @@ ip route del default 2>/dev/null || true
17
17
  ip route add default via "$GATEWAY"
18
18
 
19
19
  # DNS
20
- echo "nameserver 100.100.0.1" > /etc/resolv.conf
20
+ echo "nameserver 203.0.113.1" > /etc/resolv.conf
21
21
 
22
22
  echo "Target machine ready: $(hostname)"
23
23
  sleep infinity
@@ -31,7 +31,7 @@ ip route del default 2>/dev/null || true
31
31
  ip route add default via "$GATEWAY"
32
32
 
33
33
  # DNS
34
- echo "nameserver 100.100.0.1" > /etc/resolv.conf
34
+ echo "nameserver 203.0.113.1" > /etc/resolv.conf
35
35
 
36
36
  # Fetch Pebble's runtime ACME root CA (Pebble generates a fresh root
37
37
  # at each startup; the static pebble-ca.crt baked into the image only
@@ -12,8 +12,8 @@ case "$VANTAGE" in
12
12
  # Residential-user view. Default route via fw-ext (.101) which DNATs
13
13
  # public-internet traffic into the dmz (caddy at 10.226.10.10). DNS via
14
14
  # comcast-resolver (.1) which walks the root → TLD → namecheap chain.
15
- GATEWAY="100.100.0.101"
16
- NAMESERVER="100.100.0.1"
15
+ GATEWAY="203.0.113.101"
16
+ NAMESERVER="203.0.113.1"
17
17
  ;;
18
18
  internal)
19
19
  # Operator-on-LAN view. Default route via fw-isp (.1); split-horizon
@@ -12,10 +12,21 @@ RUN apt-get update && apt-get install -y \
12
12
  dnsutils \
13
13
  procps \
14
14
  python3 \
15
- wireguard-tools \
16
15
  wireguard-go \
17
16
  && rm -rf /var/lib/apt/lists/*
18
17
 
18
+ # `wireguard-tools` is DELIBERATELY not installed here. The wireguard module's
19
+ # job includes putting it on the host, and baking it in meant the rig proved the
20
+ # tunnel worked on the one kind of host that could never exercise that: the
21
+ # module only ever ran `wg --version`, found it, and moved on. On a real firewall
22
+ # the deploy failed and told the operator to install wireguard by hand — over the
23
+ # access the VPN was being deployed to provide.
24
+ #
25
+ # `wireguard-go` stays, and is a different kind of thing. A container cannot load
26
+ # a kernel module, so wg-quick needs a userspace implementation to fall back to;
27
+ # that is the simulator standing in for hardware it does not have, not a step
28
+ # celilo is supposed to perform.
29
+
19
30
  # Containers have no wireguard kernel module to load, and a test must not depend
20
31
  # on whether the Docker host happens to have one. wg-quick falls back to this
21
32
  # userspace implementation when `ip link add type wireguard` fails, so the tunnel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -18,7 +18,7 @@
18
18
  "celilo-e2e-infra": "./bin/e2e-infra"
19
19
  },
20
20
  "scripts": {
21
- "test": "bun test tests/",
21
+ "test": "bun test --timeout 30000 tests/",
22
22
  "test:completion": "bun test tests/completion",
23
23
  "test:integration": "bun test tests-integration/"
24
24
  },
@@ -37,8 +37,9 @@
37
37
  "README.md"
38
38
  ],
39
39
  "dependencies": {
40
- "@celilo/cli-display": "^0.1.10",
41
- "@celilo/event-bus": "^0.4.0",
40
+ "@celilo/capabilities": "^1.2.0",
41
+ "@celilo/cli-display": "^0.2.0",
42
+ "@celilo/event-bus": "^0.5.0",
42
43
  "yaml": "^2.8.0",
43
44
  "zod": "^3.24.1"
44
45
  },
@@ -12,6 +12,7 @@
12
12
 
13
13
  import {
14
14
  addPortMapping,
15
+ currentDevice,
15
16
  getDhcpPool,
16
17
  getPublicIp,
17
18
  isAuthenticated,
@@ -19,6 +20,7 @@ import {
19
20
  login,
20
21
  logout,
21
22
  setDhcpPool,
23
+ swapDevice,
22
24
  } from './state';
23
25
  import type { CgiResponse } from './types';
24
26
 
@@ -78,6 +80,39 @@ const server = Bun.serve({
78
80
  async fetch(req) {
79
81
  const url = new URL(req.url);
80
82
 
83
+ // --- POST /sim/swap-device — HARNESS CONTROL, not part of the CGI API ---
84
+ //
85
+ // Replaces the physical device this container impersonates, on a RUNNING
86
+ // network, modelling an ISP swapping the box on the wall. Namespaced under
87
+ // /sim/ and deliberately unauthenticated: it is not reachable from any
88
+ // module (nothing in modules/ knows this path exists) and the whole
89
+ // container is a simulator. Real firmware has no such endpoint, which is
90
+ // exactly why it cannot live under /cgi/.
91
+ if (url.pathname === '/sim/swap-device' && req.method === 'POST') {
92
+ const body = await req.text();
93
+ const params = parseUrlEncoded(body);
94
+ if (!params.vendorPrefix) {
95
+ return new Response('vendorPrefix is required', { status: 400 });
96
+ }
97
+ const result = swapDevice({
98
+ vendorPrefix: params.vendorPrefix,
99
+ username: params.username,
100
+ password: params.password,
101
+ });
102
+ return new Response(JSON.stringify({ ...result, device: currentDevice() }), {
103
+ status: 200,
104
+ headers: { 'Content-Type': 'application/json' },
105
+ });
106
+ }
107
+
108
+ // --- GET /sim/device — what is currently being impersonated ---
109
+ if (url.pathname === '/sim/device' && req.method === 'GET') {
110
+ return new Response(JSON.stringify(currentDevice()), {
111
+ status: 200,
112
+ headers: { 'Content-Type': 'application/json' },
113
+ });
114
+ }
115
+
81
116
  // --- POST /cgi/cgi_action (login/logout) ---
82
117
  if (url.pathname === '/cgi/cgi_action' && req.method === 'POST') {
83
118
  const body = await req.text();
@@ -2,9 +2,9 @@ import { execSync } from 'node:child_process';
2
2
  import { writeFileSync } from 'node:fs';
3
3
  import type { CgiObject, CgiParam, PortMapping, Session } from './types';
4
4
 
5
- const VALID_USERNAME = process.env.ROUTER_USERNAME || 'admin';
6
- const VALID_PASSWORD = process.env.ROUTER_PASSWORD || 'admin';
7
- const PUBLIC_IP = process.env.ROUTER_PUBLIC_IP || '100.100.0.100';
5
+ let validUsername = process.env.ROUTER_USERNAME || 'admin';
6
+ let validPassword = process.env.ROUTER_PASSWORD || 'admin';
7
+ const PUBLIC_IP = process.env.ROUTER_PUBLIC_IP || '203.0.113.100';
8
8
 
9
9
  // The network interface facing the ISP (isp-external network)
10
10
  const EXTERNAL_INTERFACE = process.env.EXTERNAL_INTERFACE || 'eth1';
@@ -18,15 +18,68 @@ const EXTERNAL_INTERFACE = process.env.EXTERNAL_INTERFACE || 'eth1';
18
18
  * folds both into `X_AXON_`. Defaults to the GreenWave spelling so the existing
19
19
  * greenwave suite is byte-for-byte unaffected.
20
20
  */
21
- const VENDOR_PREFIX = process.env.ROUTER_VENDOR_PREFIX || 'X_GWS_';
22
- const PARAM_VIA = `${VENDOR_PREFIX}Via`;
21
+ let vendorPrefix = process.env.ROUTER_VENDOR_PREFIX || 'X_GWS_';
23
22
 
24
23
  /**
25
24
  * The C4000XG named the interface selector after the chipset vendor rather than
26
25
  * the device vendor, so it does NOT follow the prefix. Axon firmware does.
27
26
  */
28
- const PARAM_INTERFACE =
29
- VENDOR_PREFIX === 'X_GWS_' ? 'X_LANTIQ_COM_INTERFACE' : `${VENDOR_PREFIX}INTERFACE`;
27
+ function paramVia(): string {
28
+ return `${vendorPrefix}Via`;
29
+ }
30
+
31
+ function paramInterface(): string {
32
+ return vendorPrefix === 'X_GWS_' ? 'X_LANTIQ_COM_INTERFACE' : `${vendorPrefix}INTERFACE`;
33
+ }
34
+
35
+ /**
36
+ * Replace the physical device this container is impersonating, WITHOUT
37
+ * restarting it — the way an ISP swaps the box on the wall while the line, the
38
+ * public IP and everything downstream stay put.
39
+ *
40
+ * `ROUTER_VENDOR_PREFIX` alone cannot model this: it is baked into the compose
41
+ * file at network-build time and read once at module load, so a mid-run swap
42
+ * was not expressible and the migration this whole change exists for could not
43
+ * be tested end to end.
44
+ *
45
+ * Three things change together, because all three are true of a real swap and
46
+ * each one matters to what is under test:
47
+ *
48
+ * 1. The vendor prefix, so a module written for the old device now speaks the
49
+ * wrong TR-181 spelling.
50
+ * 2. The CREDENTIALS. This is why the fleet wedges in practice rather than
51
+ * merely misbehaving: the old module cannot even authenticate, so it can
52
+ * neither drive the new box nor tear its own state down.
53
+ * 3. Every port forward is GONE. A new device arrives empty. This is the
54
+ * assertion that gives the migration test its teeth — the forwards must be
55
+ * recreated by unpausing the consumers that own them, and a simulator that
56
+ * kept its old table would let a completely broken unpause pass.
57
+ */
58
+ export function swapDevice(options: {
59
+ vendorPrefix: string;
60
+ username?: string;
61
+ password?: string;
62
+ }): { forwardsDiscarded: number } {
63
+ const forwardsDiscarded = portMappings.length;
64
+
65
+ vendorPrefix = options.vendorPrefix;
66
+ if (options.username !== undefined) validUsername = options.username;
67
+ if (options.password !== undefined) validPassword = options.password;
68
+
69
+ portMappings.length = 0;
70
+ nextIndex = 1;
71
+ // Sessions are per-device: a cookie minted by the old box is meaningless to
72
+ // the new one, so anything still holding one gets a 401 rather than silently
73
+ // continuing to work.
74
+ sessions.clear();
75
+
76
+ return { forwardsDiscarded };
77
+ }
78
+
79
+ /** What the simulator is currently impersonating — for assertions and debugging. */
80
+ export function currentDevice(): { vendorPrefix: string; username: string } {
81
+ return { vendorPrefix, username: validUsername };
82
+ }
30
83
 
31
84
  const sessions = new Map<string, Session>();
32
85
  const portMappings: PortMapping[] = [];
@@ -47,7 +100,7 @@ const dhcpPool: Record<string, string> = {
47
100
  };
48
101
 
49
102
  export function login(username: string, password: string): string | null {
50
- if (username !== VALID_USERNAME || password !== VALID_PASSWORD) {
103
+ if (username !== validUsername || password !== validPassword) {
51
104
  return null;
52
105
  }
53
106
  const sessionId = `sim-${Date.now()}-${Math.random().toString(36).slice(2)}`;
@@ -91,7 +144,7 @@ export function addPortMapping(params: Record<string, string>): CgiObject[] {
91
144
  enable: params.Enable || '1',
92
145
  description: params.Description || '',
93
146
  protocol: params.Protocol || 'TCP',
94
- via: params[PARAM_VIA] || 'UI',
147
+ via: params[paramVia()] || 'UI',
95
148
  remoteHost: params.RemoteHost || '',
96
149
  };
97
150
 
@@ -219,9 +272,9 @@ function mappingToParams(pm: PortMapping): CgiParam[] {
219
272
  { ParamName: 'Enable', ParamValue: pm.enable },
220
273
  { ParamName: 'Description', ParamValue: pm.description },
221
274
  { ParamName: 'Protocol', ParamValue: pm.protocol },
222
- { ParamName: PARAM_VIA, ParamValue: pm.via },
275
+ { ParamName: paramVia(), ParamValue: pm.via },
223
276
  { ParamName: 'RemoteHost', ParamValue: pm.remoteHost },
224
- { ParamName: PARAM_INTERFACE, ParamValue: 'wan' },
277
+ { ParamName: paramInterface(), ParamValue: 'wan' },
225
278
  { ParamName: 'AllInterfaces', ParamValue: '0' },
226
279
  ];
227
280
  }
@@ -38,7 +38,7 @@ const PORT = Number(process.env.ECHO_PORT) || 80;
38
38
 
39
39
  /**
40
40
  * Strip the IPv6-mapped-IPv4 prefix Bun returns for IPv4 connections over a
41
- * dual-stack socket (`::ffff:100.100.0.100` → `100.100.0.100`). Same fix as
41
+ * dual-stack socket (`::ffff:203.0.113.100` → `203.0.113.100`). Same fix as
42
42
  * the namecheap-ddns simulator: the mapped form is not a valid A-record value,
43
43
  * and here it would simply never compare equal to what DNS serves.
44
44
  */
@@ -17,8 +17,8 @@
17
17
 
18
18
  import { describe, expect, test } from 'bun:test';
19
19
  import { execSync } from 'node:child_process';
20
+ import { subnetContains } from '@celilo/capabilities';
20
21
  import { SIM_PRIVATE_SUPERNET, ZONE_GATEWAYS, ZONE_SUBNETS, type Zone } from './types';
21
- import { inCidr } from './zone-classifier';
22
22
 
23
23
  const REPO_ROOT = execSync('git rev-parse --show-toplevel', {
24
24
  cwd: import.meta.dir,
@@ -73,9 +73,9 @@ describe('simulated address plan (#539)', () => {
73
73
  test('every zone lives inside the sim supernet', () => {
74
74
  for (const zone of Object.keys(ZONE_SUBNETS) as Zone[]) {
75
75
  const base = ZONE_SUBNETS[zone].split('/')[0];
76
- expect(inCidr(base, SIM_PRIVATE_SUPERNET)).toBe(true);
77
- expect(inCidr(ZONE_GATEWAYS[zone], SIM_PRIVATE_SUPERNET)).toBe(true);
78
- expect(inCidr(ZONE_GATEWAYS[zone], ZONE_SUBNETS[zone])).toBe(true);
76
+ expect(subnetContains(SIM_PRIVATE_SUPERNET, base)).toBe(true);
77
+ expect(subnetContains(SIM_PRIVATE_SUPERNET, ZONE_GATEWAYS[zone])).toBe(true);
78
+ expect(subnetContains(ZONE_SUBNETS[zone], ZONE_GATEWAYS[zone])).toBe(true);
79
79
  }
80
80
  });
81
81
 
package/src/cli/build.ts CHANGED
@@ -24,8 +24,16 @@ import { basename, join, resolve } from 'node:path';
24
24
  import { gunzipSync } from 'node:zlib';
25
25
  import { stageAptRepo } from '../../scripts/stage-apt-repo';
26
26
  import { stageLibsignal } from '../../scripts/stage-libsignal';
27
+ import { explainBuildFailure } from '../doctor';
27
28
  import { ensureRegistryServerBundle } from '../registry-bundle';
28
29
 
30
+ /**
31
+ * Ceiling on a single `docker build`. The slowest image here is a few minutes
32
+ * cold; 15 leaves generous headroom while bounding a wedged build, which
33
+ * otherwise hangs the whole invocation with no signal at all.
34
+ */
35
+ const PER_IMAGE_BUILD_TIMEOUT_MS = 15 * 60_000;
36
+
29
37
  /**
30
38
  * Walk up from pkgDir to find the celilo monorepo root.
31
39
  *
@@ -533,12 +541,34 @@ function buildDockerImages(pkgDir: string): void {
533
541
  const result = spawnSync('docker', ['build', '-t', tag, '-f', join(dockerDir, file), context], {
534
542
  cwd: pkgDir,
535
543
  stdio: 'pipe',
544
+ timeout: PER_IMAGE_BUILD_TIMEOUT_MS,
536
545
  });
537
546
  const elapsed = Math.round((Date.now() - start) / 1000);
538
547
 
548
+ // A wedged `docker build` used to hang here forever: spawnSync blocks the
549
+ // event loop, so even the run-lock heartbeat stops — the holder looks alive
550
+ // (its PID is) while beating nothing, and the only way anyone noticed was
551
+ // comparing beatAt to wall-clock by hand. Twice in one evening, ~20 and ~32
552
+ // minutes. A bounded wait turns that into an immediate, nameable failure.
553
+ if (result.error && (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
554
+ console.log(`${red}✗ TIMED OUT${reset} ${dim}${elapsed}s${reset}`);
555
+ console.error(
556
+ `\n${red}Building ${name} exceeded ${Math.round(PER_IMAGE_BUILD_TIMEOUT_MS / 60_000)} minutes and was killed.${reset}`,
557
+ );
558
+ console.error(
559
+ `${dim}Docker itself is usually still responsive. Reproduce it alone with:${reset}`,
560
+ );
561
+ console.error(`${dim} docker build -f docker/${file} -t ${tag} .${reset}`);
562
+ console.error(
563
+ `${dim}If that succeeds, the stall was transient — re-run \`cele2e build-infra\`.${reset}\n`,
564
+ );
565
+ process.exit(1);
566
+ }
567
+
539
568
  if (result.status !== 0) {
540
569
  console.log(`${red}✗${reset} ${dim}${elapsed}s${reset}`);
541
570
  console.error(result.stderr?.toString());
571
+ console.error(explainBuildFailure(result.stderr?.toString() ?? ''));
542
572
  process.exit(1);
543
573
  }
544
574
 
@@ -99,6 +99,22 @@ export const COMMANDS: CommandDef[] = [
99
99
  description: 'Show run-lock holder + network status (exit 3 if busy)',
100
100
  flags: [{ name: '--json', description: 'Emit a machine-readable lock snapshot for polling' }],
101
101
  },
102
+ {
103
+ name: 'doctor',
104
+ description:
105
+ "Check everything a run needs (baked images, base images, lock, disk) — run's implicit preflight",
106
+ flags: [{ name: '--json', description: 'Emit the full report as JSON' }],
107
+ },
108
+ {
109
+ name: 'last',
110
+ description: "Show the most recent run's results dir and counts",
111
+ flags: [
112
+ {
113
+ name: '--json',
114
+ description: 'Emit {runId, resultsDir, total, passed, failed} for scripting',
115
+ },
116
+ ],
117
+ },
102
118
  {
103
119
  name: 'release',
104
120
  description: 'Free a run-lock left by --keep/up (does not tear down)',
@@ -141,4 +157,7 @@ export const RUN_FLAGS = [
141
157
 
142
158
  export const DOWN_FLAGS = ['--keep', '--all'];
143
159
 
160
+ /** Read-only reporters whose only flag is `--json`. */
161
+ export const JSON_FLAG_COMMANDS = ['status', 'doctor', 'last'];
162
+
144
163
  export const BUILD_INFRA_FLAGS = ['--save', '--skip-modules', '--published'];