@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.
- package/README.md +5 -5
- package/bin/e2e-status +1 -1
- package/bin/e2e-up +2 -2
- package/config/dns/tangohost.com.zone +1 -1
- package/config/dns/templates/example.net.zone +3 -3
- package/config/dns/templates/iamtheinternet.org.zone +2 -2
- package/config/routing/fw-ext-routes.sh +8 -8
- package/config/routing/fw-isp-routes.sh +2 -2
- package/config/routing/fw-main-routes.sh +5 -5
- package/config/routing/management-routes.sh +1 -1
- package/config/routing/observer-setup.sh +1 -1
- package/config/routing/public-sim-entrypoint.sh +1 -1
- package/config/routing/resolver-internal-routes.sh +2 -2
- package/config/routing/resolver-routes.sh +1 -1
- package/config/routing/target-routes.sh +1 -1
- package/config/routing/target-setup.sh +1 -1
- package/config/socks/startup.sh +2 -2
- package/docker/Dockerfile.firewall +12 -1
- package/package.json +5 -4
- package/simulators/greenwave/server.ts +35 -0
- package/simulators/greenwave/state.ts +64 -11
- package/simulators/ip-echo/server.ts +1 -1
- package/src/address-plan.test.ts +4 -4
- package/src/cli/build.ts +30 -0
- package/src/cli/command-registry.ts +19 -0
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +85 -4
- package/src/container-manager.ts +191 -14
- package/src/docker-compose-generator.ts +55 -26
- package/src/doctor.test.ts +279 -0
- package/src/doctor.ts +421 -0
- package/src/extract-failure.ts +41 -0
- package/src/index.ts +12 -0
- package/src/last-run.test.ts +62 -0
- package/src/last-run.ts +54 -0
- package/src/observer.test.ts +2 -2
- package/src/observer.ts +1 -1
- package/src/router-swap.ts +121 -0
- package/src/run-lock.test.ts +73 -2
- package/src/run-lock.ts +110 -14
- package/src/runner.ts +99 -5
- package/src/socks-proxy.ts +2 -2
- package/src/types.ts +48 -8
- package/src/vantage.test.ts +1 -1
- package/src/zone-classifier.test.ts +11 -16
- 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** |
|
|
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,
|
|
47
|
-
| **fw-ext** | Edge router + transparent HTTPS proxy (Squid) | isp-external, internet-external, real-internet |
|
|
48
|
-
| **comcast-resolver** | Unbound recursive DNS resolver | isp-external, real-internet |
|
|
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=
|
|
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:
|
|
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:
|
|
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:
|
|
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 (
|
|
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 (
|
|
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
|
|
16
|
-
www IN A
|
|
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 (
|
|
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
|
|
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
|
|
10
|
-
ip route add 10.226.10.0/24 via
|
|
11
|
-
ip route add 10.226.20.0/24 via
|
|
12
|
-
ip route add 10.226.30.0/24 via
|
|
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
|
-
#
|
|
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
|
|
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 '
|
|
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
|
|
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
|
|
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
|
|
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 '
|
|
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
|
|
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
|
-
#
|
|
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 '/
|
|
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
|
|
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
|
|
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:-
|
|
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 (
|
|
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 (
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
package/config/socks/startup.sh
CHANGED
|
@@ -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="
|
|
16
|
-
NAMESERVER="
|
|
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.
|
|
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/
|
|
41
|
-
"@celilo/
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
const PUBLIC_IP = process.env.ROUTER_PUBLIC_IP || '
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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 !==
|
|
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[
|
|
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:
|
|
275
|
+
{ ParamName: paramVia(), ParamValue: pm.via },
|
|
223
276
|
{ ParamName: 'RemoteHost', ParamValue: pm.remoteHost },
|
|
224
|
-
{ ParamName:
|
|
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:
|
|
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
|
*/
|
package/src/address-plan.test.ts
CHANGED
|
@@ -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(
|
|
77
|
-
expect(
|
|
78
|
-
expect(
|
|
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'];
|