@celilo/e2e 0.11.2 → 0.12.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.
@@ -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.
@@ -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
@@ -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
@@ -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.2",
3
+ "version": "0.12.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",
@@ -38,7 +38,7 @@
38
38
  ],
39
39
  "dependencies": {
40
40
  "@celilo/cli-display": "^0.1.10",
41
- "@celilo/event-bus": "^0.2.0",
41
+ "@celilo/event-bus": "^0.4.0",
42
42
  "yaml": "^2.8.0",
43
43
  "zod": "^3.24.1"
44
44
  },
@@ -9,6 +9,25 @@ const PUBLIC_IP = process.env.ROUTER_PUBLIC_IP || '100.100.0.100';
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
+ const VENDOR_PREFIX = process.env.ROUTER_VENDOR_PREFIX || 'X_GWS_';
22
+ const PARAM_VIA = `${VENDOR_PREFIX}Via`;
23
+
24
+ /**
25
+ * The C4000XG named the interface selector after the chipset vendor rather than
26
+ * the device vendor, so it does NOT follow the prefix. Axon firmware does.
27
+ */
28
+ const PARAM_INTERFACE =
29
+ VENDOR_PREFIX === 'X_GWS_' ? 'X_LANTIQ_COM_INTERFACE' : `${VENDOR_PREFIX}INTERFACE`;
30
+
12
31
  const sessions = new Map<string, Session>();
13
32
  const portMappings: PortMapping[] = [];
14
33
  let nextIndex = 1;
@@ -72,7 +91,7 @@ export function addPortMapping(params: Record<string, string>): CgiObject[] {
72
91
  enable: params.Enable || '1',
73
92
  description: params.Description || '',
74
93
  protocol: params.Protocol || 'TCP',
75
- via: params.X_GWS_Via || 'UI',
94
+ via: params[PARAM_VIA] || 'UI',
76
95
  remoteHost: params.RemoteHost || '',
77
96
  };
78
97
 
@@ -200,9 +219,9 @@ function mappingToParams(pm: PortMapping): CgiParam[] {
200
219
  { ParamName: 'Enable', ParamValue: pm.enable },
201
220
  { ParamName: 'Description', ParamValue: pm.description },
202
221
  { ParamName: 'Protocol', ParamValue: pm.protocol },
203
- { ParamName: 'X_GWS_Via', ParamValue: pm.via },
222
+ { ParamName: PARAM_VIA, ParamValue: pm.via },
204
223
  { ParamName: 'RemoteHost', ParamValue: pm.remoteHost },
205
- { ParamName: 'X_LANTIQ_COM_INTERFACE', ParamValue: 'wan' },
224
+ { ParamName: PARAM_INTERFACE, ParamValue: 'wan' },
206
225
  { ParamName: 'AllInterfaces', ParamValue: '0' },
207
226
  ];
208
227
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * IP echo simulator — the rig's stand-in for api.ipify.org.
3
+ *
4
+ * Reports the source address a request appears to come from, and nothing else.
5
+ * celilo's `public_dns` check uses it for the ONE thing it cannot ask itself:
6
+ * what address the fleet is currently reachable at.
7
+ *
8
+ * Why an independent service rather than the registrar's own answer: the DDNS
9
+ * response carries the address the registrar says it published, so comparing
10
+ * public DNS against it is self-agreement. Worse than useless, in fact —
11
+ * Namecheap returns `ErrCount 0` with the requested address echoed back for
12
+ * `www` updates it does not apply, so the response can be actively false. The
13
+ * expectation has to come from somewhere that has no stake in the answer.
14
+ *
15
+ * This sits on `internet-external`, so a request from the customer fleet
16
+ * arrives having been SNAT'd by the customer firewall — the source address it
17
+ * sees IS the firewall's external address, which is the address the internet
18
+ * must dial. That is the same mechanism DDNS source-IP detection relies on,
19
+ * observed independently.
20
+ *
21
+ * Plain HTTP on purpose. Real ipify is HTTPS, but TLS here would mean an ACME
22
+ * dance (Caddy + Pebble, as the isitup sim does) to test nothing this check
23
+ * cares about: the property under test is "an off-fleet observer reports our
24
+ * egress", not certificate validation. The path from the fleet to here still
25
+ * crosses both firewalls exactly as a real request would.
26
+ */
27
+
28
+ // Marks this file a module rather than a script. Without it the simulators
29
+ // share one global scope at typecheck time, and `PORT`/`server` collide with
30
+ // the identically-named top-level bindings in simulators/isitup/server.ts.
31
+ export {};
32
+
33
+ interface RequestIpProvider {
34
+ requestIP(req: Request): { address: string } | null;
35
+ }
36
+
37
+ const PORT = Number(process.env.ECHO_PORT) || 80;
38
+
39
+ /**
40
+ * Strip the IPv6-mapped-IPv4 prefix Bun returns for IPv4 connections over a
41
+ * dual-stack socket (`::ffff:100.100.0.100` → `100.100.0.100`). Same fix as
42
+ * the namecheap-ddns simulator: the mapped form is not a valid A-record value,
43
+ * and here it would simply never compare equal to what DNS serves.
44
+ */
45
+ function normalizeIp(address: string): string {
46
+ const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
47
+ return mapped ? mapped[1] : address;
48
+ }
49
+
50
+ function sourceIp(req: Request, server: RequestIpProvider): string | null {
51
+ const forwarded = req.headers.get('x-forwarded-for');
52
+ if (forwarded) return normalizeIp(forwarded.split(',')[0].trim());
53
+ const remote = server.requestIP(req);
54
+ return remote?.address ? normalizeIp(remote.address) : null;
55
+ }
56
+
57
+ const server = Bun.serve({
58
+ port: PORT,
59
+ fetch(req, server) {
60
+ const url = new URL(req.url);
61
+
62
+ if (url.pathname === '/health') return Response.json({ ok: true });
63
+
64
+ const ip = sourceIp(req, server);
65
+ if (!ip) {
66
+ // NEVER a sentinel. A prior version of the DDNS simulator returned
67
+ // `0.0.0.0` when it could not determine the source, and that value went
68
+ // straight into a public zone file. An unanswerable probe must read as
69
+ // an error, which celilo counts as *undetermined* — never as a pass.
70
+ return new Response('source address could not be determined', { status: 500 });
71
+ }
72
+ return new Response(ip, { headers: { 'Content-Type': 'text/plain' } });
73
+ },
74
+ });
75
+
76
+ console.log(`[ip-echo] listening on port ${server.port}`);
@@ -146,7 +146,7 @@ const PROTECTED_SUBNETS = (['dmz', 'app', 'secure', 'secure-mgmt'] as const)
146
146
  .map((zone) => ZONE_SUBNETS[zone])
147
147
  .join(' ');
148
148
 
149
- function defaultTopologyServices(): Record<string, unknown> {
149
+ function defaultTopologyServices(config: NetworkConfig): Record<string, unknown> {
150
150
  return {
151
151
  'fw-main': baseService({
152
152
  build: { context: '.', dockerfile: 'docker/Dockerfile.firewall' },
@@ -178,6 +178,9 @@ function defaultTopologyServices(): Record<string, unknown> {
178
178
  devices: ['/dev/net/tun'],
179
179
  sysctls: ROUTER_SYSCTLS,
180
180
  volumes: ['ssh-keys:/ssh-keys:ro'],
181
+ // Which real ISP router this sim stands in for. The protocol is identical
182
+ // across the two; only the TR-181 vendor extension prefix differs.
183
+ environment: { ROUTER_VENDOR_PREFIX: config.routerVendorPrefix ?? 'X_GWS_' },
181
184
  }),
182
185
  };
183
186
  }
@@ -211,7 +214,10 @@ function directInternetTopologyServices(): Record<string, unknown> {
211
214
  };
212
215
  }
213
216
 
214
- const TOPOLOGY_SERVICES: Record<TopologyPreset, () => Record<string, unknown>> = {
217
+ const TOPOLOGY_SERVICES: Record<
218
+ TopologyPreset,
219
+ (config: NetworkConfig) => Record<string, unknown>
220
+ > = {
215
221
  default: defaultTopologyServices,
216
222
  'direct-internet': directInternetTopologyServices,
217
223
  };
@@ -315,6 +321,39 @@ export function generateSharedInfraYaml(): string {
315
321
  volumes: getRegistryVolumes(),
316
322
  });
317
323
 
324
+ // OFF-FLEET recursive resolver — the rig's 1.1.1.1, and the vantage point
325
+ // celilo's `public_dns` check resolves from.
326
+ //
327
+ // A peer of comcast-resolver, not a replacement: the fleet is configured to
328
+ // use THAT one (`system init` writes dns.primary=100.100.0.1), and celilo
329
+ // refuses to accept a resolver it already asks as evidence about the public
330
+ // internet — a check that used the fleet's own resolver would pass whatever
331
+ // the internet sees, which is the celilo#626 blindness one layer up. So the
332
+ // simulation needs two independent public resolvers, exactly as the real
333
+ // world has your ISP's and Cloudflare's.
334
+ //
335
+ // Same image as comcast-resolver (identical unbound.conf → identical
336
+ // authoritative answers) with a different startup: this one sits ON
337
+ // internet-external with the authoritative servers, so it needs no route
338
+ // through fw-ext to reach them.
339
+ services['public-resolver'] = baseService({
340
+ build: { context: '.', dockerfile: 'docker/Dockerfile.resolver' },
341
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.PUBLIC_RESOLVER } },
342
+ cap_add: ['NET_ADMIN'],
343
+ command: '/public-resolver-startup.sh',
344
+ });
345
+
346
+ // IP echo service — mimics api.ipify.org. Reports the source address a
347
+ // request appears to come from, which for the customer fleet is the
348
+ // firewall's external address after SNAT. This is where the `public_dns`
349
+ // check's EXPECTATION comes from; comparing public DNS against the
350
+ // registrar's own response would be self-agreement.
351
+ services['ip-echo'] = baseService({
352
+ build: { context: '.', dockerfile: 'docker/Dockerfile.ip-echo' },
353
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.IP_ECHO } },
354
+ cap_add: ['NET_ADMIN'],
355
+ });
356
+
318
357
  // External website prober simulator — mimics isitup.org's /api.json endpoint.
319
358
  // Sits on internet-external so probe requests exercise the real public-facing
320
359
  // path (target domain → DNS → ISP → firewall → Caddy). Uses comcast-resolver
@@ -532,7 +571,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
532
571
 
533
572
  // --- Topology-specific infrastructure (south of fw-ext) ---
534
573
  const topologyFn = TOPOLOGY_SERVICES[config.topology] || TOPOLOGY_SERVICES.default;
535
- Object.assign(services, topologyFn());
574
+ Object.assign(services, topologyFn(config));
536
575
 
537
576
  // Give fw-main a leg on the control-plane network when celilo-mgr lives there,
538
577
  // so the management box is routed like any other segmented network rather than
@@ -715,7 +754,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
715
754
  });
716
755
 
717
756
  const topologyFn = TOPOLOGY_SERVICES[config.topology] || TOPOLOGY_SERVICES.default;
718
- Object.assign(services, topologyFn());
757
+ Object.assign(services, topologyFn(config));
719
758
 
720
759
  const legacyCaddyIp =
721
760
  config.dmzMachines.find((m) => m.name === 'caddy')?.ip ??
@@ -59,6 +59,17 @@ export class NetworkBuilder {
59
59
  return this;
60
60
  }
61
61
 
62
+ /**
63
+ * Make the `fw-isp` simulator stand in for an Axon Networks Q1000K rather
64
+ * than the default GreenWave C4000XG. The two speak an identical CGI
65
+ * protocol; only the TR-181 vendor extension prefix differs, so this swaps
66
+ * `X_GWS_Via`/`X_LANTIQ_COM_INTERFACE` for `X_AXON_Via`/`X_AXON_INTERFACE`.
67
+ */
68
+ axonRouter(): this {
69
+ this.config.routerVendorPrefix = 'X_AXON_';
70
+ return this;
71
+ }
72
+
62
73
  /**
63
74
  * Place the celilo management container in a given zone. Defaults to
64
75
  * `internal`; `secure-mgmt` gives celilo-mgr its own control-plane network
@@ -44,6 +44,27 @@ export const SIMULATOR_IPS = {
44
44
  CPANEL_HOST: '100.64.0.63',
45
45
  /** signal-cli release host — serves the tarball the signal module downloads at deploy time. */
46
46
  SIGNAL_RELEASE: '100.64.0.62',
47
+ /**
48
+ * OFF-FLEET recursive resolver — the rig's stand-in for 1.1.1.1, and a peer
49
+ * of comcast-resolver rather than a replacement for it.
50
+ *
51
+ * The distinction is the whole point of celilo's `public_dns` check: the
52
+ * fleet's own resolver (its ISP's, or its internal split-horizon one)
53
+ * answers with whatever is correct for a client INSIDE, which is not
54
+ * evidence about what the internet sees. celilo REFUSES to use a resolver it
55
+ * is itself configured to use, so verifying public reachability requires a
56
+ * second, independent public resolver — and until this existed the topology
57
+ * had exactly one.
58
+ */
59
+ PUBLIC_RESOLVER: '100.64.0.64',
60
+ /**
61
+ * IP echo service — the rig's stand-in for api.ipify.org. Reports the source
62
+ * address a request appears to come from, which for the customer fleet is
63
+ * the firewall's external address after SNAT. The `public_dns` check's
64
+ * expectation comes from here rather than from the registrar's own response,
65
+ * which is self-agreement (and, for a Namecheap `www` update, false).
66
+ */
67
+ IP_ECHO: '100.64.0.65',
47
68
  /** Pebble ACME server (replaces production Let's Encrypt). */
48
69
  PEBBLE: '100.64.0.100',
49
70
  } as const;
package/src/types.ts CHANGED
@@ -55,6 +55,15 @@ export interface NetworkConfig {
55
55
  domain: string;
56
56
  ddnsPassword: string;
57
57
  verifyRouting: boolean;
58
+ /**
59
+ * Which real ISP router the `fw-isp` simulator stands in for, expressed as
60
+ * its TR-181 vendor extension prefix. `X_GWS_` (the default) is the GreenWave
61
+ * C4000XG that `modules/greenwave` drives; `X_AXON_` is the Axon Networks
62
+ * Q1000K that `modules/axon` drives. The CGI protocol is identical across the
63
+ * two — this prefix IS the entire difference, which is why one simulator
64
+ * serves both rather than a near-duplicate second one.
65
+ */
66
+ routerVendorPrefix?: string;
58
67
  /** Extra volume mounts for the management container (host:container format) */
59
68
  managementVolumes: string[];
60
69
  /** Include a DHCP client container on the internal network */