@celilo/e2e 0.20.5 → 0.21.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 (35) hide show
  1. package/bin/e2e-bake-management +362 -10
  2. package/config/routing/management-routes.sh +11 -0
  3. package/config/routing/minio-startup.sh +27 -32
  4. package/config/routing/resolver-internal-routes.sh +14 -6
  5. package/config/socks/startup.sh +46 -0
  6. package/config/ssh/sshd-celilo-api.conf +6 -0
  7. package/docker/Dockerfile.management +47 -0
  8. package/docker/Dockerfile.minio +34 -16
  9. package/docker/Dockerfile.socks-proxy +14 -0
  10. package/package.json +2 -2
  11. package/registry-server/src/bootstrap.ts +43 -5
  12. package/registry-server/src/index.ts +1 -0
  13. package/registry-server/src/server.ts +18 -1
  14. package/simulators/greenwave/state.ts +85 -4
  15. package/src/bake-probe-explanation.test.ts +43 -0
  16. package/src/bake-probe-fails-closed.test.ts +35 -0
  17. package/src/bake-probe-no-shell-expansion.test.ts +40 -0
  18. package/src/cli/build.test.ts +50 -0
  19. package/src/cli/build.ts +36 -1
  20. package/src/cli/host.ts +71 -2
  21. package/src/docker-compose-generator.ts +7 -3
  22. package/src/doctor.test.ts +87 -6
  23. package/src/doctor.ts +112 -23
  24. package/src/greenwave-external-interface.test.ts +63 -0
  25. package/src/index.ts +1 -0
  26. package/src/installed-cli-digest.test.ts +113 -0
  27. package/src/installed-cli-digest.ts +102 -0
  28. package/src/parse-line.ts +8 -2
  29. package/src/resolver-internal-config.test.ts +65 -0
  30. package/src/shared-infra-pebble-root.test.ts +26 -0
  31. package/src/shared-infra-self-contained.test.ts +53 -0
  32. package/src/shared-infra.ts +61 -0
  33. package/src/socks-proxy.ts +45 -11
  34. package/src/stale-acme-root.ts +66 -0
  35. package/src/types.ts +8 -0
@@ -0,0 +1,6 @@
1
+ # Remote-API authorisation for the e2e management box. See the COPY of this
2
+ # file in docker/Dockerfile.management for why the account is root here and
3
+ # celilo-api in production.
4
+ Match User root
5
+ AuthorizedKeysCommand /usr/local/bin/celilo api authorized-keys
6
+ AuthorizedKeysCommandUser root
@@ -124,11 +124,58 @@ ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
124
124
  # fall through to Bun's normal resolution.
125
125
  RUN printf '[install.scopes]\n"@celilo" = "http://npm-registry.lab"\n' > /root/.bunfig.toml
126
126
 
127
+ # --- The fleet browser runtime (managed-browser-runtime) ----------------------
128
+ #
129
+ # Browser-driven health checks (celilo-web-console is the first) resolve
130
+ # `/var/lib/celilo/browsers/current/chrome` and a descriptor describing what
131
+ # was installed. This image provisions that runtime the way the celilo-mgmt
132
+ # role does on a real management host (`modules/celilo-mgmt/ansible/roles/
133
+ # celilo-mgmt/tasks/debian.yml`), pinned to the SAME playwright version that
134
+ # role declares — the fleet's single browser-version declaration. A module
135
+ # bundling a different client is a pin-drift finding, not a failure: the
136
+ # consumer passes the stable path explicitly.
137
+ #
138
+ # Revision comes from the pinned client itself (`install --dry-run`), never
139
+ # from a hand-maintained version→revision table — same reasoning as the role.
140
+ ARG PLAYWRIGHT_VERSION=1.60.0
141
+ RUN set -eux; \
142
+ REVISION="$(PLAYWRIGHT_BROWSERS_PATH=/var/lib/celilo/browsers bun x "playwright-core@${PLAYWRIGHT_VERSION}" install --dry-run chromium-headless-shell \
143
+ | grep -oE 'chromium_headless_shell-[0-9]+' | head -1 | cut -d- -f2)"; \
144
+ PLAYWRIGHT_BROWSERS_PATH=/var/lib/celilo/browsers bun x "playwright-core@${PLAYWRIGHT_VERSION}" install chromium-headless-shell; \
145
+ PLAYWRIGHT_BROWSERS_PATH=/var/lib/celilo/browsers bun x "playwright-core@${PLAYWRIGHT_VERSION}" install-deps chromium-headless-shell; \
146
+ BIN="$(find /var/lib/celilo/browsers/chromium_headless_shell-"${REVISION}" -type f \
147
+ \( -name chrome-headless-shell -o -name headless_shell \) | head -1)"; \
148
+ test -x "${BIN}"; \
149
+ mkdir -p /var/lib/celilo/browsers/current; \
150
+ ln -sf "${BIN}" /var/lib/celilo/browsers/current/chrome; \
151
+ BROWSER_VERSION="$(/var/lib/celilo/browsers/current/chrome --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)"; \
152
+ printf '{"schemaVersion":1,"browser":"chromium","flavor":"headless-shell","browserVersion":"%s","playwrightVersion":"%s","revision":"%s","writtenBy":"e2e management image"}' \
153
+ "${BROWSER_VERSION}" "${PLAYWRIGHT_VERSION}" "${REVISION}" \
154
+ > /var/lib/celilo/browsers/current/celilo-browser.json; \
155
+ cat /var/lib/celilo/browsers/current/celilo-browser.json
156
+
127
157
  # Standard modules pre-packaged as .netapp files — built by e2e-build.
128
158
  # Tests import from /netapps/<module>.netapp instead of the monorepo mount.
129
159
  COPY netapps/ /netapps/
130
160
 
131
161
  COPY config/ssh/generate-keys.sh /config/ssh/generate-keys.sh
162
+
163
+ # The remote API's sshd landing config. `celilo --remote` and the web console
164
+ # both reach this box as an ssh forced command, and the keys that may do so
165
+ # come from celilo's own api_principals table AT LOGIN -- never from a static
166
+ # authorized_keys somebody has to keep in sync. A module enrols its principal
167
+ # during its deploy, which is always after this image was built, so a file
168
+ # written at build time could not contain it (celilo#1442).
169
+ #
170
+ # Production lands on the unprivileged `celilo-api` account and the deb's
171
+ # wrapper sudo-drops it into the `celilo` user; the celilo-bootstrap postinst
172
+ # writes the same two directives for it. Here the box was provisioned by
173
+ # install.sh, which installs celilo for root and keeps its database under
174
+ # /root -- there is no `celilo` user to drop into, so root IS the privileged
175
+ # account and the landing account too. The mechanism under test is identical;
176
+ # only the account name differs, which is why `api_dest` is module config.
177
+ COPY config/ssh/sshd-celilo-api.conf /etc/ssh/sshd_config.d/50-celilo-api.conf
178
+
132
179
  COPY config/routing/management-routes.sh /startup.sh
133
180
  RUN chmod +x /startup.sh /config/ssh/generate-keys.sh
134
181
 
@@ -1,8 +1,19 @@
1
- # minio — the e2e mirror of AWS S3 (and any S3-compatible backup target).
1
+ # The e2e mirror of AWS S3 (and any S3-compatible backup target).
2
+ #
3
+ # ⚠️ The service, hostname (`minio.lab`) and this file's name are unchanged, but
4
+ # the SERVER IS NO LONGER MinIO. MinIO withdrew public anonymous distribution of
5
+ # its container images: Docker Hub delisted `minio/mc` and `minio/minio`
6
+ # (celilo#1389), the fix moved them to quay.io, and four days later quay.io
7
+ # answered 401 for both the floating tag AND the pinned digest. There was no
8
+ # third registry to move to, so this now runs s3proxy, which implements the S3
9
+ # API over a filesystem backend and is pullable anonymously. The names are kept
10
+ # because renaming the simulator touches the compose generator, simulator-ips,
11
+ # the resolver config and every suite that resolves `minio.lab`; that rename is
12
+ # worth doing and is not worth coupling to an outage fix.
2
13
  #
3
14
  # Celilo's backup storage provider (apps/celilo/src/services/storage-providers/
4
15
  # s3.ts) talks plain S3 with forcePathStyle + a custom endpoint, so it drives
5
- # MinIO unchanged. This sim is the destination for `celilo backup --storage`
16
+ # this unchanged. This sim is the destination for `celilo backup --storage`
6
17
  # (push) and the source for `celilo backup pull` (download) in the migration
7
18
  # restore e2e (P5, openspec/specs/management-server-backup/spec.md).
8
19
  #
@@ -13,31 +24,38 @@
13
24
  # bypasses fw-ext's Squid, so no TLS bump / cert / nonstandard-port filtering).
14
25
  #
15
26
  # The `celilo-backups` bucket is pre-created at startup (celilo's S3 provider
16
- # does NOT create buckets — its initialize() only probes). Root creds are the
17
- # well-known minioadmin/minioadmin; the migration test passes them to
27
+ # does NOT create buckets — its initialize() only probes with ListObjectsV2).
28
+ # Under the filesystem backend a bucket IS a directory, so that is now a plain
29
+ # `mkdir` — no S3 round trip, and no client binary to pull. Root creds are the
30
+ # well-known minioadmin/minioadmin, kept verbatim because
31
+ # e2e/tests/migration-restore.test.ts:40-41 passes them to
18
32
  # `celilo storage add s3 --access-key-id ... --secret-access-key ...`.
19
33
 
20
- FROM quay.io/minio/mc:latest@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727 AS mc
21
-
22
- # Static busybox purely for its `ip` applet: the MinIO image has no iproute2
34
+ # Static busybox purely for its `ip` applet: the s3proxy image has no iproute2
23
35
  # and no package manager worth invoking, but it still needs the ISP route
24
36
  # every host on internet-external carries (see minio-startup.sh).
25
37
  FROM busybox:stable-musl@sha256:3c6ae8008e2c2eedd141725c30b20d9c36b026eb796688f88205845ef17aa213 AS busybox
26
38
 
27
- FROM quay.io/minio/minio:latest@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e
39
+ FROM andrewgaul/s3proxy@sha256:87662b2a5afcdfa5f478a1c61650bae4c87bbd15f6ff2823236bcfd66ef7fe04
28
40
 
29
41
  COPY --from=busybox /bin/busybox /usr/local/bin/busybox
30
42
 
31
- # Bring the MinIO client in from the mc image so the entrypoint can create the
32
- # bucket once the server is up.
33
- COPY --from=mc /usr/bin/mc /usr/bin/mc
34
-
35
43
  COPY config/routing/minio-startup.sh /usr/local/bin/minio-startup.sh
36
44
 
37
- ENV MINIO_ROOT_USER=minioadmin \
38
- MINIO_ROOT_PASSWORD=minioadmin \
45
+ # aws-v2-or-v4 because celilo's SDK signs v4 while older tooling may sign v2;
46
+ # accepting both costs nothing and the alternative is a signature-version
47
+ # mismatch that surfaces as an opaque 403.
48
+ ENV S3PROXY_AUTHORIZATION=aws-v2-or-v4 \
49
+ S3PROXY_IDENTITY=minioadmin \
50
+ S3PROXY_CREDENTIAL=minioadmin \
51
+ S3PROXY_ENDPOINT=http://0.0.0.0:80 \
52
+ JCLOUDS_PROVIDER=filesystem \
53
+ JCLOUDS_FILESYSTEM_BASEDIR=/data \
39
54
  MINIO_BUCKET=celilo-backups
40
55
 
41
- EXPOSE 80 9001
56
+ EXPOSE 80
42
57
 
43
- ENTRYPOINT ["/bin/sh", "/usr/local/bin/minio-startup.sh"]
58
+ # dumb-init stays the entrypoint so signals reach the server; the startup script
59
+ # takes CMD and execs s3proxy's own launcher at the end.
60
+ ENTRYPOINT ["/usr/bin/dumb-init", "--"]
61
+ CMD ["/bin/sh", "/usr/local/bin/minio-startup.sh"]
@@ -18,6 +18,20 @@ RUN apt-get update && apt-get install -y \
18
18
  iproute2 \
19
19
  iputils-ping \
20
20
  dnsutils \
21
+ # curl: the proxy is not only a SOCKS daemon, it is a VANTAGE. Tests reach
22
+ # the deployed app "as a user inside the simulation" by running a client
23
+ # INSIDE this container (SocksProxyHandle.exec is `docker exec <proxy> bash
24
+ # -c ...`, socks-proxy.ts:234), so whatever the test runs has to exist here.
25
+ # Without curl, `proxy.exec("curl ... -w '%{http_code}'")` produced an EMPTY
26
+ # string, and an assertion reading only stdout cannot tell "the server
27
+ # returned nothing" from "I could not run the client". celilo#1426's stage 6
28
+ # failed that way and had never once passed.
29
+ curl \
30
+ # wireguard-tools: the control-plane-vpn vantage. A browser that reaches
31
+ # celilo-web-console (secure-mgmt, VPN-only per D7) must enter as a real
32
+ # VPN peer — the proxy brings up a wg tunnel, it does not attach directly
33
+ # to the zone. Needs /dev/net/tun at `docker run` time (socks-proxy.ts).
34
+ wireguard-tools \
21
35
  && rm -rf /var/lib/apt/lists/*
22
36
 
23
37
  COPY config/socks/danted.conf /etc/danted.conf
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.20.5",
3
+ "version": "0.21.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
  "README.md"
39
39
  ],
40
40
  "dependencies": {
41
- "@celilo/capabilities": "^5.1.1",
41
+ "@celilo/capabilities": "^6.1.0",
42
42
  "@celilo/cli-display": "^0.2.0",
43
43
  "@celilo/event-bus": "^0.7.0",
44
44
  "@celilo/terraform-fake": "^0.3.1",
@@ -16,7 +16,14 @@
16
16
  */
17
17
 
18
18
  import { createHash } from 'node:crypto';
19
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
19
+ import {
20
+ existsSync,
21
+ mkdirSync,
22
+ readFileSync,
23
+ readdirSync,
24
+ renameSync,
25
+ writeFileSync,
26
+ } from 'node:fs';
20
27
  import { basename, dirname, join, relative } from 'node:path';
21
28
  import type { IndexEntry } from './storage';
22
29
 
@@ -104,18 +111,42 @@ export function scanUploadsDir(uploadsDir: string): Map<string, BootstrapEntry>
104
111
  return entries;
105
112
  }
106
113
 
107
- /** Render a bootstrap entry as an IndexEntry suitable for the sparse index. */
108
- export function bootstrapIndexEntry(entry: BootstrapEntry): IndexEntry {
114
+ /**
115
+ * Render a bootstrap entry as an IndexEntry suitable for the sparse index.
116
+ *
117
+ * `cksum` defaults to the literal sentinel `bootstrap`, which the client reads
118
+ * as "no integrity data" and skips (apps/celilo/src/registry/client.ts
119
+ * isVerifiableCksum). Pass the real digest whenever it is knowable — see
120
+ * `bootstrapCksum` — so the consumer's integrity check actually runs.
121
+ */
122
+ export function bootstrapIndexEntry(entry: BootstrapEntry, cksum = 'bootstrap'): IndexEntry {
109
123
  const vers = entry.version.includes('+') ? entry.version : `${entry.version}+1`;
110
124
  return {
111
125
  name: entry.name,
112
126
  vers,
113
127
  deps: [],
114
- cksum: 'bootstrap',
128
+ cksum,
115
129
  yanked: false,
116
130
  };
117
131
  }
118
132
 
133
+ /**
134
+ * sha256 of the exact .netapp bytes this entry serves, packaging it if it is
135
+ * not cached yet.
136
+ *
137
+ * The bootstrap path packages on demand, so it holds the bytes at the moment
138
+ * it serves them — the digest IS knowable, it was just never computed
139
+ * (celilo#1262). Publishing it means every e2e import exercises the download
140
+ * integrity check instead of skipping it on the sentinel, and a corrupt
141
+ * bootstrap artifact is caught at the consumer rather than surfacing later as
142
+ * "zlib: unexpected end of file".
143
+ */
144
+ export async function bootstrapCksum(entry: BootstrapEntry, cacheDir: string): Promise<string> {
145
+ const netappPath = await packageBootstrapModule(entry, cacheDir);
146
+ const bytes = await Bun.file(netappPath).arrayBuffer();
147
+ return createHash('sha256').update(new Uint8Array(bytes)).digest('hex');
148
+ }
149
+
119
150
  /**
120
151
  * Walk a directory, skipping hidden entries, *.test.ts files, the
121
152
  * module's e2e/ tree (tests + their deps don't ship with the module),
@@ -238,11 +269,18 @@ export async function packageBootstrapModule(
238
269
  }
239
270
  writeFileSync(join(stagingDir, 'checksums.json'), checksumsJson);
240
271
 
241
- const proc = Bun.spawn(['tar', '-czf', cachePath, '-C', stagingDir, '.'], {
272
+ // Write to a temp path and rename into place. The cached artifact is now
273
+ // hashed and published as the entry's cksum, so a concurrent reader that
274
+ // catches a half-written tar would fail its integrity check for a reason
275
+ // that has nothing to do with the download. Rename is atomic on the same
276
+ // filesystem, so a reader sees either no cache or the whole artifact.
277
+ const tmpPath = `${cachePath}.${process.pid}.${Date.now()}.tmp`;
278
+ const proc = Bun.spawn(['tar', '-czf', tmpPath, '-C', stagingDir, '.'], {
242
279
  stderr: 'inherit',
243
280
  });
244
281
  const exitCode = await proc.exited;
245
282
  if (exitCode !== 0) throw new Error(`tar exited with code ${exitCode}`);
283
+ renameSync(tmpPath, cachePath);
246
284
  } finally {
247
285
  try {
248
286
  Bun.spawnSync(['rm', '-rf', stagingDir]);
@@ -4,6 +4,7 @@ export { TokenAuth } from './auth';
4
4
  export { startServer, startFromEnv } from './server';
5
5
  export {
6
6
  scanBootstrapDir,
7
+ bootstrapCksum,
7
8
  bootstrapIndexEntry,
8
9
  packageBootstrapModule,
9
10
  } from './bootstrap';
@@ -24,6 +24,7 @@ import { join } from 'node:path';
24
24
  import { ADMIN_SCOPE, TokenAuth } from './auth';
25
25
  import {
26
26
  type BootstrapEntry,
27
+ bootstrapCksum,
27
28
  bootstrapIndexEntry,
28
29
  packageBootstrapModule,
29
30
  scanBootstrapDir,
@@ -263,6 +264,22 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
263
264
  return b ? [bootstrapIndexEntry(b)] : [];
264
265
  }
265
266
 
267
+ /**
268
+ * Same resolution as `resolveEntries`, but a bootstrap-served entry carries
269
+ * the real sha256 of the .netapp it will serve rather than the `bootstrap`
270
+ * sentinel (celilo#1262). Packaging happens here so the digest is knowable;
271
+ * only the sparse-index endpoint needs it, and only that endpoint pays for
272
+ * it — the search and metadata endpoints never read `cksum`.
273
+ */
274
+ async function resolveIndexEntries(name: string): Promise<IndexEntry[]> {
275
+ if (!isValidName(name)) return [];
276
+ const published = storage.readIndex(name);
277
+ if (published.length > 0) return published;
278
+ const b = resolveBootstrap().get(name);
279
+ if (!b) return [];
280
+ return [bootstrapIndexEntry(b, await bootstrapCksum(b, bootstrapCacheDir))];
281
+ }
282
+
266
283
  async function serveBootstrapDownload(entry: BootstrapEntry): Promise<Response> {
267
284
  const netappPath = await packageBootstrapModule(entry, bootstrapCacheDir);
268
285
  return new Response(Bun.file(netappPath), {
@@ -542,7 +559,7 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
542
559
  if (method === 'GET' && suffix.startsWith('/index/')) {
543
560
  const name = decodeURIComponent(suffix.slice(suffix.lastIndexOf('/') + 1));
544
561
  if (!isValidName(name)) return notFound();
545
- const entries = resolveEntries(name);
562
+ const entries = await resolveIndexEntries(name);
546
563
  if (entries.length === 0) return notFound();
547
564
  const body = `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`;
548
565
  return new Response(body, {
@@ -6,8 +6,68 @@ let validUsername = process.env.ROUTER_USERNAME || 'admin';
6
6
  let validPassword = process.env.ROUTER_PASSWORD || 'admin';
7
7
  const PUBLIC_IP = process.env.ROUTER_PUBLIC_IP || '203.0.113.100';
8
8
 
9
- // The network interface facing the ISP (isp-external network)
10
- const EXTERNAL_INTERFACE = process.env.EXTERNAL_INTERFACE || 'eth1';
9
+ /**
10
+ * The interface facing the ISP — DERIVED from the address it has to serve, not
11
+ * guessed.
12
+ *
13
+ * The port-forward DNAT used to bind an interface (`-i ${EXTERNAL_INTERFACE}`),
14
+ * defaulting to `eth1`, and nothing anywhere set that variable — not the
15
+ * compose generator, not any config. On fw-isp `eth1` is the INTERNAL leg
16
+ * (10.226.1.1/24) and `eth0` carries the public address, so every forward was
17
+ * bound to the interface facing AWAY from the internet.
18
+ *
19
+ * What that costs, measured 2026-09-26: inbound 80/443 to 203.0.113.100 was
20
+ * refused, so Pebble could complete NEITHER http-01 (`dial 203.0.113.100:80:
21
+ * connect: connection refused`) nor tls-alpn-01 (`Failed to connect to
22
+ * 203.0.113.100:443`). No per-test domain could ever obtain a certificate.
23
+ * celilo-web-console-oidc died at stage 2 on authentik's HTTPS readiness and
24
+ * again at stage 4 on OIDC discovery — two failures, one cause, neither of them
25
+ * naming the interface. Adding the eth0 rule by hand made ACME finish in 12s.
26
+ *
27
+ * Hairpin traffic from inside DID match, so forwards appeared to work from
28
+ * every vantage inside the simulation. Only the internet could not get in,
29
+ * which is the one direction a port forward exists for.
30
+ *
31
+ * Deriving it removes the class: whichever interface holds PUBLIC_IP is by
32
+ * definition the one inbound traffic arrives on. The env var still wins when
33
+ * set, and the old default remains the last resort so a container whose address
34
+ * is not yet up behaves as before rather than throwing.
35
+ */
36
+ function interfaceCarrying(address: string): string | null {
37
+ try {
38
+ const out = execSync(`ip -4 -o addr show | grep -w ${address} || true`, {
39
+ encoding: 'utf8',
40
+ timeout: 5_000,
41
+ });
42
+ // `ip -o addr` columns: "<idx>: <iface> inet <addr>/<len> ..."
43
+ return out.trim().split('\n')[0]?.trim().split(/\s+/)[1] || null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * The interface the DHCP server must bind, derived from the router address it
51
+ * hands out rather than assumed to be eth0.
52
+ *
53
+ * dnsmasq is configured `bind-interfaces`, so this decides which segment it
54
+ * serves. fw-isp is multi-homed and Docker's interface ordering is NOT stable
55
+ * across runs — measured 2026-09-26, eth0 and eth1 swapped between two runs on
56
+ * the same rig. Bound to the wrong one, dnsmasq offers a 10.226.1.0/24 range on
57
+ * the PUBLIC segment and serves nothing on the LAN it exists for, which is a
58
+ * worse failure than not starting.
59
+ *
60
+ * Same defect as the port-forward DNAT above, same file, found by auditing for
61
+ * it after that one bit twice in opposite directions.
62
+ */
63
+ function deriveDhcpInterface(routerIp: string): string {
64
+ const iface = interfaceCarrying(routerIp);
65
+ if (iface) return iface;
66
+ console.error(
67
+ `[greenwave] no interface carries the DHCP router address ${routerIp}; binding eth0 and DHCP may serve the wrong segment`,
68
+ );
69
+ return 'eth0';
70
+ }
11
71
 
12
72
  /**
13
73
  * Vendor extension prefix for TR-181 parameters.
@@ -201,10 +261,11 @@ function rewriteDnsmasqConfig(): void {
201
261
  const dnsServers = dhcpPool.DNSServers || '10.226.1.1';
202
262
  const domainName = dhcpPool.DomainName || '';
203
263
 
264
+ const routerIp = dhcpPool.IPRouters || '10.226.1.1';
204
265
  const lines = [
205
266
  '# Auto-generated by greenwave simulator',
206
267
  'port=0',
207
- 'interface=eth0',
268
+ `interface=${deriveDhcpInterface(routerIp)}`,
208
269
  'bind-interfaces',
209
270
  'dhcp-range=10.226.1.200,10.226.1.220,255.255.255.0,60s',
210
271
  'dhcp-option=option:router,10.226.1.1',
@@ -244,7 +305,27 @@ function applyIptablesRule(mapping: PortMapping): void {
244
305
  function addRules(p: string) {
245
306
  // DNAT incoming traffic on external interface destined for our public IP
246
307
  execSync(
247
- `iptables -t nat -A PREROUTING -i ${EXTERNAL_INTERFACE} -d ${PUBLIC_IP} -p ${p} --dport ${extPort} -j DNAT --to-destination ${intIp}:${intPort}`,
308
+ // NO `-i` HERE, DELIBERATELY. The destination address IS the
309
+ // discriminator: only traffic actually addressed to PUBLIC_IP can match,
310
+ // whichever leg it arrives on. Binding an interface as well makes exactly
311
+ // one direction work and silently breaks the other.
312
+ //
313
+ // Measured 2026-09-26, and it is a COIN FLIP because Docker's interface
314
+ // ordering is not stable across runs. Same rule, two runs:
315
+ // run24/25 eth0=203.0.113.100 (public), eth1=10.226.1.1
316
+ // -> `-i eth1` matched hairpin only; INBOUND refused, so no
317
+ // per-test domain could obtain a certificate at all.
318
+ // run28 eth0=10.226.1.1, eth1=203.0.113.100 (public)
319
+ // -> `-i eth1` matched inbound only; HAIRPIN refused, so the
320
+ // SOCKS browser got ERR_SOCKS_CONNECTION_FAILED reaching
321
+ // the public login path from inside the simulation.
322
+ // Verified on the live stack with the rule as it stood: from the internal
323
+ // proxy REFUSED, from Pebble on internet-external OPEN, simultaneously.
324
+ //
325
+ // Deriving the "right" interface does not fix this — it only chooses
326
+ // which direction breaks. Both are real paths: ACME comes in from the
327
+ // internet, and a browser inside the simulation dials the public name.
328
+ `iptables -t nat -A PREROUTING -d ${PUBLIC_IP} -p ${p} --dport ${extPort} -j DNAT --to-destination ${intIp}:${intPort}`,
248
329
  { timeout: 5000 },
249
330
  );
250
331
  // Allow forwarded traffic
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * The bake probe waits for HTTPS to VERIFY, so a trust failure and an
7
+ * unfinished issuance both spend the whole budget and both used to print
8
+ * "timed out" — which is why celilo#1365 and celilo#1427 read as timeouts for
9
+ * weeks while `curl -k` returned 200 throughout. They need opposite responses,
10
+ * so the headline must branch on curl's exit code.
11
+ *
12
+ * The explainer lives in a bin script (no import surface), so this gate reads
13
+ * the real source and pins the behaviour that matters: the trust codes and the
14
+ * reach codes are classified, disjointly, and the trust branch says waiting
15
+ * cannot help.
16
+ */
17
+ describe('bake probe explains which failure it hit', () => {
18
+ const src = readFileSync(join(import.meta.dir, '..', 'bin', 'e2e-bake-management'), 'utf-8');
19
+ const fn = /function explainProbeFailure\(curlExit: number\): string \{[\s\S]*?\n\}/.exec(src);
20
+
21
+ test('the explainer exists', () => {
22
+ expect(fn).not.toBeNull();
23
+ });
24
+
25
+ const body = fn?.[0] ?? '';
26
+
27
+ test('trust codes and reach codes are classified disjointly', () => {
28
+ const arrays = [...body.matchAll(/\[([\d,\s]+)\]\.includes\(curlExit\)/g)].map((m) =>
29
+ m[1].split(',').map((n) => Number(n.trim())),
30
+ );
31
+ expect(arrays.length).toBe(2);
32
+ const [trust, reach] = arrays;
33
+ // 60 unknown issuer is the dead-root signature; 6 unresolved is the reach one.
34
+ expect(trust).toContain(60);
35
+ expect(reach).toContain(6);
36
+ expect(trust.filter((c) => reach.includes(c))).toEqual([]);
37
+ });
38
+
39
+ test('the trust branch says waiting cannot fix it', () => {
40
+ const trustBranch = body.slice(0, body.indexOf('6, 7, 28'));
41
+ expect(trustBranch).toMatch(/waiting longer cannot fix it/i);
42
+ });
43
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * The HTTPS probe must FAIL CLOSED when it cannot read curl's exit code.
7
+ *
8
+ * Its command ends in `echo "curl-exit=$?"`, so the shell always exits 0 — a
9
+ * fallback to the exec's own exit code therefore scores "I could not read the
10
+ * answer" as "the site is serving". Measured on run 7258: the probe printed
11
+ * `pebble CA ready ✔ 0s` while celilo.computer served NO certificate at all
12
+ * (empty `openssl s_client` chain, server aborting with `internal error
13
+ * (592)`), and the real defect surfaced one step later as install.sh's
14
+ * curl (35). A probe that cannot fail turned celilo#1365 into a green step.
15
+ *
16
+ * Same shape as the bin script's other gate: the explainer has no import
17
+ * surface, so this reads the real source.
18
+ */
19
+ describe('the bake HTTPS probe fails closed', () => {
20
+ const src = readFileSync(join(import.meta.dir, '..', 'bin', 'e2e-bake-management'), 'utf-8');
21
+
22
+ test('an unreadable probe does NOT fall back to the shell exit code', () => {
23
+ expect(src).not.toContain('Number(code[1]) : probe.exitCode');
24
+ });
25
+
26
+ test('an unreadable probe is scored as not-ready', () => {
27
+ expect(src).toContain('Number(code[1]) : PROBE_UNREADABLE');
28
+ });
29
+
30
+ test('the unreadable case has its own explanation, not the timeout headline', () => {
31
+ const fn = /function explainProbeFailure\(curlExit: number\): string \{[\s\S]*?\n\}/.exec(src);
32
+ expect(fn).not.toBeNull();
33
+ expect(fn?.[0]).toContain('PROBE_UNREADABLE');
34
+ });
35
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * No command this script sends into a container may use `$?`.
7
+ *
8
+ * dockerExec wraps the command as `bash -c ${JSON.stringify(cmd)}` and hands
9
+ * that to a HOST shell (container-manager.ts:636), so a `$?` inside it is
10
+ * expanded by the host BEFORE the container sees it — to the host's own last
11
+ * status, which is 0.
12
+ *
13
+ * Measured on celilo-builder run 7262, where one layer printed both lines:
14
+ *
15
+ * curl: (60) SSL certificate problem: unable to get local issuer certificate
16
+ * curl-exit=0
17
+ *
18
+ * The bake's HTTPS probe read that 0 and passed. It had passed on every run in
19
+ * celilo#1365's history — first hiding the defect behind a 30s timeout, later
20
+ * behind a green step and an unexplained install.sh failure. A probe that
21
+ * cannot fail is not a probe, and the failure it hid was the real one.
22
+ *
23
+ * curl's own `%{exitcode}` carries the status with no shell variable to expand.
24
+ */
25
+ describe('no container command relies on host-expanded shell variables', () => {
26
+ const src = readFileSync(join(import.meta.dir, '..', 'bin', 'e2e-bake-management'), 'utf-8');
27
+ // Comments explain the hazard and must be allowed to name it.
28
+ const code = src
29
+ .split('\n')
30
+ .filter((line) => !line.trim().startsWith('//') && !line.trim().startsWith('*'))
31
+ .join('\n');
32
+
33
+ test('`$?` appears in no executed command', () => {
34
+ expect(code).not.toContain('$?');
35
+ });
36
+
37
+ test("the probe reads curl's exit code from curl itself", () => {
38
+ expect(code).toContain('curl-exit=%{exitcode}');
39
+ });
40
+ });
@@ -12,10 +12,12 @@ import {
12
12
  import { tmpdir } from 'node:os';
13
13
  import { basename, join } from 'node:path';
14
14
  import { gzipSync } from 'node:zlib';
15
+ import { readDockerfileBases } from '../doctor';
15
16
  import {
16
17
  assertGzipValid,
17
18
  bakeManagement,
18
19
  packageNetapp,
20
+ pullDockerfileBases,
19
21
  reportBakeChildFailure,
20
22
  stageNetappsFromRegistry,
21
23
  verifyNetapp,
@@ -234,3 +236,51 @@ test('bakeManagement classifies a real child exit 3 end to end and exits 3', ()
234
236
  rmSync(pkgDir, { recursive: true, force: true });
235
237
  }
236
238
  });
239
+
240
+ /**
241
+ * Reach probe for the base-image pull (celilo#1244).
242
+ *
243
+ * `build-infra` is the one-command remedy doctor's base-images warn names, so
244
+ * it has to reach EVERY base, and the set it walks is computed — a directory
245
+ * walk over `docker/Dockerfile.*`. Reasoning about that walk is what let the
246
+ * old check skip ten images, so measure it instead: mirror the REAL Dockerfiles
247
+ * (never a hand-written list, which would give confident reach data about a
248
+ * directory that does not exist), put a recording shim on PATH in place of
249
+ * docker, and read back which refs it was actually asked to pull.
250
+ */
251
+ test('build-infra pulls every base image a real Dockerfile names', () => {
252
+ const pkgDir = join(import.meta.dir, '..', '..');
253
+ const expected = [...new Set(readDockerfileBases(pkgDir).flatMap((d) => d.bases))].sort();
254
+ expect(expected.length).toBeGreaterThan(0);
255
+
256
+ // Mirror the real docker/ dir so the walk has the real shape to walk.
257
+ const mirror = mkdtempSync(join(tmpdir(), 'base-reach-'));
258
+ mkdirSync(join(mirror, 'docker'));
259
+ for (const f of readdirSync(join(pkgDir, 'docker')).filter((f) => f.startsWith('Dockerfile.'))) {
260
+ writeFileSync(join(mirror, 'docker', f), readFileSync(join(pkgDir, 'docker', f), 'utf-8'));
261
+ }
262
+
263
+ const log = join(mirror, 'pulls.txt');
264
+ const shimDir = join(mirror, 'bin');
265
+ mkdirSync(shimDir);
266
+ writeFileSync(
267
+ join(shimDir, 'docker'),
268
+ `#!/bin/sh
269
+ [ "$1" = pull ] && printf '%s\n' "$3" >> ${log}
270
+ exit 0
271
+ `,
272
+ { mode: 0o755 },
273
+ );
274
+
275
+ const savedPath = process.env.PATH;
276
+ process.env.PATH = `${shimDir}:${savedPath}`;
277
+ try {
278
+ expect(pullDockerfileBases(mirror)).toEqual([]);
279
+ } finally {
280
+ process.env.PATH = savedPath;
281
+ }
282
+
283
+ const pulled = readFileSync(log, 'utf-8').trim().split('\n').sort();
284
+ expect(pulled).toEqual(expected);
285
+ rmSync(mirror, { recursive: true, force: true });
286
+ });