@celilo/e2e 0.11.3 → 0.13.0

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 (51) 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/resolver/unbound.conf +4 -0
  8. package/config/routing/fw-ext-routes.sh +8 -8
  9. package/config/routing/fw-isp-routes.sh +2 -2
  10. package/config/routing/fw-main-routes.sh +5 -5
  11. package/config/routing/management-routes.sh +1 -1
  12. package/config/routing/observer-setup.sh +1 -1
  13. package/config/routing/public-resolver-routes.sh +33 -0
  14. package/config/routing/public-sim-entrypoint.sh +1 -1
  15. package/config/routing/resolver-internal-routes.sh +7 -2
  16. package/config/routing/resolver-routes.sh +1 -1
  17. package/config/routing/target-routes.sh +1 -1
  18. package/config/routing/target-setup.sh +1 -1
  19. package/config/socks/startup.sh +2 -2
  20. package/docker/Dockerfile.ip-echo +20 -0
  21. package/docker/Dockerfile.resolver +5 -1
  22. package/package.json +5 -4
  23. package/simulators/greenwave/server.ts +35 -0
  24. package/simulators/greenwave/state.ts +79 -7
  25. package/simulators/ip-echo/server.ts +76 -0
  26. package/src/address-plan.test.ts +4 -4
  27. package/src/cli/build.ts +30 -0
  28. package/src/cli/command-registry.ts +19 -0
  29. package/src/cli/completion.ts +16 -1
  30. package/src/cli/index.ts +85 -4
  31. package/src/container-manager.ts +75 -12
  32. package/src/docker-compose-generator.ts +97 -29
  33. package/src/doctor.test.ts +279 -0
  34. package/src/doctor.ts +421 -0
  35. package/src/extract-failure.ts +41 -0
  36. package/src/index.ts +12 -0
  37. package/src/last-run.test.ts +62 -0
  38. package/src/last-run.ts +54 -0
  39. package/src/network-builder.ts +11 -0
  40. package/src/observer.test.ts +2 -2
  41. package/src/observer.ts +1 -1
  42. package/src/router-swap.ts +121 -0
  43. package/src/run-lock.test.ts +73 -2
  44. package/src/run-lock.ts +110 -14
  45. package/src/runner.ts +99 -5
  46. package/src/simulator-ips.ts +21 -0
  47. package/src/socks-proxy.ts +2 -2
  48. package/src/types.ts +34 -4
  49. package/src/vantage.test.ts +1 -1
  50. package/src/zone-classifier.test.ts +11 -16
  51. 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
@@ -38,6 +38,10 @@ server:
38
38
  # releases, so the signal module's deploy-time download resolves inside
39
39
  # the sealed network. See SIMULATOR_IPS.SIGNAL_RELEASE.
40
40
  local-data: "signal-release.lab. IN A 100.64.0.62"
41
+ # IP echo simulator — the rig's api.ipify.org. celilo's `public_dns` check
42
+ # asks it the one thing the fleet cannot ask itself: what address it is
43
+ # currently reachable at. See SIMULATOR_IPS.IP_ECHO.
44
+ local-data: "ip-echo.lab. IN A 100.64.0.65"
41
45
  # External website prober simulator — without this override, isitup.org
42
46
  # falls through the "forward-zone: ." below and resolves to the REAL
43
47
  # Namecheap-parked domain on the public internet.
@@ -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
 
@@ -0,0 +1,33 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # The OFF-FLEET public resolver — the rig's stand-in for 1.1.1.1.
5
+ #
6
+ # It exists because the fleet's own resolver cannot answer the question the
7
+ # `public_dns` check asks. `system init` points celilo at comcast-resolver
8
+ # (the ISP's), and a deployed fleet at the internal split-horizon resolver;
9
+ # both answer with whatever is right for a client INSIDE. celilo refuses to use
10
+ # either as evidence about the public internet, so the simulation has to
11
+ # contain a second, genuinely independent public resolver — exactly the
12
+ # real-world distinction between your ISP's resolver and 1.1.1.1.
13
+ #
14
+ # Unlike comcast-resolver this sits ON internet-external (the public internet
15
+ # network) alongside the authoritative servers, so its stub-zones reach
16
+ # namecheap-dns on the same wire with no route needed.
17
+ #
18
+ # Replying to the CUSTOMER is a different matter, and getting this wrong is why
19
+ # the first run of the public_dns e2e stage saw nothing: a query from the
20
+ # fleet arrives with the customer's public source address, and the answer goes
21
+ # out via Docker's bridge gateway, which has no path across networks. The reply
22
+ # is dropped and the query times out — which celilo correctly records as
23
+ # *undetermined* rather than a pass, so the check went quiet instead of firing.
24
+ # Every host on internet-external exits by the ISP edge; this is that route.
25
+ # See config/routing/public-sim-entrypoint.sh, and the recurrence gate in
26
+ # src/public-sim-routes.test.ts that catches a simulator shipped without it.
27
+ ip route del default 2>/dev/null || true
28
+ ip route add default via 100.64.0.1 2>/dev/null || true
29
+
30
+ /usr/sbin/unbound -d &
31
+
32
+ echo "public-resolver ready on 100.64.0.64 - the rig's 1.1.1.1 (SIMULATOR_IPS.PUBLIC_RESOLVER)"
33
+ sleep infinity
@@ -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
@@ -57,6 +57,11 @@ $(printf "${SPLIT_HORIZON_BLOCK}")
57
57
  # MinIO S3 simulator — backup/restore target for the migration e2e.
58
58
  # See SIMULATOR_IPS.MINIO.
59
59
  local-data: "minio.lab. IN A 100.64.0.61"
60
+ # IP echo simulator — the rig's api.ipify.org. celilo's public_dns check
61
+ # asks it the one thing the fleet cannot ask itself: what address it is
62
+ # currently reachable at. See SIMULATOR_IPS.IP_ECHO.
63
+ # (Backtick-free on purpose — see the note above; this heredoc is unquoted.)
64
+ local-data: "ip-echo.lab. IN A 100.64.0.65"
60
65
  # signal-cli daemon (real, unlinked) and its simulator counterpart. Both
61
66
  # sit on the `internal` network — celilo-mgr reaches a notification
62
67
  # transport locally; a transport needing public ingress could not tell
@@ -70,7 +75,7 @@ $(printf "${SPLIT_HORIZON_BLOCK}")
70
75
 
71
76
  forward-zone:
72
77
  name: "."
73
- forward-addr: 100.100.0.1
78
+ forward-addr: 203.0.113.1
74
79
  EOF
75
80
 
76
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
@@ -0,0 +1,20 @@
1
+ FROM oven/bun:latest
2
+
3
+ # iproute2 so the entrypoint can see its own addressing when debugging; the
4
+ # service itself needs no routing changes (it sits on internet-external with
5
+ # the other public simulators and answers requests that arrive there).
6
+ RUN apt-get update && apt-get install -y iproute2 curl \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ COPY simulators/ip-echo/server.ts /simulator/server.ts
10
+
11
+ # Route the customer's public prefix via the ISP edge, like every other host
12
+ # on internet-external. Without it a reply to the customer's WAN address goes
13
+ # to Docker's bridge gateway, which has no path across networks, and the
14
+ # connection simply hangs. See config/routing/public-sim-entrypoint.sh.
15
+ COPY config/routing/public-sim-entrypoint.sh /usr/local/bin/public-sim-entrypoint.sh
16
+
17
+ WORKDIR /simulator
18
+ EXPOSE 80
19
+ ENTRYPOINT ["/bin/sh", "/usr/local/bin/public-sim-entrypoint.sh"]
20
+ CMD ["bun", "run", "server.ts"]
@@ -14,7 +14,11 @@ RUN chown -R root:unbound /etc/unbound
14
14
  COPY config/resolver/unbound.conf /etc/unbound/unbound.conf
15
15
  COPY config/resolver/root.hints /etc/unbound/root.hints
16
16
  COPY config/routing/resolver-routes.sh /startup.sh
17
- RUN chmod +x /startup.sh
17
+ # The off-fleet public resolver runs from the SAME image with a different
18
+ # startup (it sits on internet-external, so it needs no route through fw-ext).
19
+ # One image, two `command:` values — see docker-compose-generator.ts.
20
+ COPY config/routing/public-resolver-routes.sh /public-resolver-startup.sh
21
+ RUN chmod +x /startup.sh /public-resolver-startup.sh
18
22
 
19
23
  EXPOSE 53/tcp 53/udp
20
24
  CMD ["/startup.sh"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.11.3",
3
+ "version": "0.13.0",
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.3.0",
40
+ "@celilo/capabilities": "^1.1.0",
41
+ "@celilo/cli-display": "^0.2.0",
42
+ "@celilo/event-bus": "^0.4.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,13 +2,85 @@ 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';
11
11
 
12
+ /**
13
+ * Vendor extension prefix for TR-181 parameters.
14
+ *
15
+ * This simulator stands in for two real devices that speak an identical CGI
16
+ * protocol and differ only here: the GreenWave C4000XG (`X_GWS_Via`, plus the
17
+ * Lantiq-chipset `X_LANTIQ_COM_INTERFACE`) and the Axon Networks Q1000K, which
18
+ * folds both into `X_AXON_`. Defaults to the GreenWave spelling so the existing
19
+ * greenwave suite is byte-for-byte unaffected.
20
+ */
21
+ let vendorPrefix = process.env.ROUTER_VENDOR_PREFIX || 'X_GWS_';
22
+
23
+ /**
24
+ * The C4000XG named the interface selector after the chipset vendor rather than
25
+ * the device vendor, so it does NOT follow the prefix. Axon firmware does.
26
+ */
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
+ }
83
+
12
84
  const sessions = new Map<string, Session>();
13
85
  const portMappings: PortMapping[] = [];
14
86
  let nextIndex = 1;
@@ -28,7 +100,7 @@ const dhcpPool: Record<string, string> = {
28
100
  };
29
101
 
30
102
  export function login(username: string, password: string): string | null {
31
- if (username !== VALID_USERNAME || password !== VALID_PASSWORD) {
103
+ if (username !== validUsername || password !== validPassword) {
32
104
  return null;
33
105
  }
34
106
  const sessionId = `sim-${Date.now()}-${Math.random().toString(36).slice(2)}`;
@@ -72,7 +144,7 @@ export function addPortMapping(params: Record<string, string>): CgiObject[] {
72
144
  enable: params.Enable || '1',
73
145
  description: params.Description || '',
74
146
  protocol: params.Protocol || 'TCP',
75
- via: params.X_GWS_Via || 'UI',
147
+ via: params[paramVia()] || 'UI',
76
148
  remoteHost: params.RemoteHost || '',
77
149
  };
78
150
 
@@ -200,9 +272,9 @@ function mappingToParams(pm: PortMapping): CgiParam[] {
200
272
  { ParamName: 'Enable', ParamValue: pm.enable },
201
273
  { ParamName: 'Description', ParamValue: pm.description },
202
274
  { ParamName: 'Protocol', ParamValue: pm.protocol },
203
- { ParamName: 'X_GWS_Via', ParamValue: pm.via },
275
+ { ParamName: paramVia(), ParamValue: pm.via },
204
276
  { ParamName: 'RemoteHost', ParamValue: pm.remoteHost },
205
- { ParamName: 'X_LANTIQ_COM_INTERFACE', ParamValue: 'wan' },
277
+ { ParamName: paramInterface(), ParamValue: 'wan' },
206
278
  { ParamName: 'AllInterfaces', ParamValue: '0' },
207
279
  ];
208
280
  }