@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.
- 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/resolver/unbound.conf +4 -0
- 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-resolver-routes.sh +33 -0
- package/config/routing/public-sim-entrypoint.sh +1 -1
- package/config/routing/resolver-internal-routes.sh +7 -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.ip-echo +20 -0
- package/docker/Dockerfile.resolver +5 -1
- package/package.json +5 -4
- package/simulators/greenwave/server.ts +35 -0
- package/simulators/greenwave/state.ts +79 -7
- package/simulators/ip-echo/server.ts +76 -0
- 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 +75 -12
- package/src/docker-compose-generator.ts +97 -29
- 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/network-builder.ts +11 -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/simulator-ips.ts +21 -0
- package/src/socks-proxy.ts +2 -2
- package/src/types.ts +34 -4
- 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
|
|
@@ -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
|
|
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
|
|
|
@@ -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 (
|
|
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
|
|
@@ -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:
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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.
|
|
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/
|
|
41
|
-
"@celilo/
|
|
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
|
-
|
|
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';
|
|
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 !==
|
|
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
|
|
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:
|
|
275
|
+
{ ParamName: paramVia(), ParamValue: pm.via },
|
|
204
276
|
{ ParamName: 'RemoteHost', ParamValue: pm.remoteHost },
|
|
205
|
-
{ ParamName:
|
|
277
|
+
{ ParamName: paramInterface(), ParamValue: 'wan' },
|
|
206
278
|
{ ParamName: 'AllInterfaces', ParamValue: '0' },
|
|
207
279
|
];
|
|
208
280
|
}
|