@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.
- package/bin/e2e-bake-management +362 -10
- package/config/routing/management-routes.sh +11 -0
- package/config/routing/minio-startup.sh +27 -32
- package/config/routing/resolver-internal-routes.sh +14 -6
- package/config/socks/startup.sh +46 -0
- package/config/ssh/sshd-celilo-api.conf +6 -0
- package/docker/Dockerfile.management +47 -0
- package/docker/Dockerfile.minio +34 -16
- package/docker/Dockerfile.socks-proxy +14 -0
- package/package.json +2 -2
- package/registry-server/src/bootstrap.ts +43 -5
- package/registry-server/src/index.ts +1 -0
- package/registry-server/src/server.ts +18 -1
- package/simulators/greenwave/state.ts +85 -4
- package/src/bake-probe-explanation.test.ts +43 -0
- package/src/bake-probe-fails-closed.test.ts +35 -0
- package/src/bake-probe-no-shell-expansion.test.ts +40 -0
- package/src/cli/build.test.ts +50 -0
- package/src/cli/build.ts +36 -1
- package/src/cli/host.ts +71 -2
- package/src/docker-compose-generator.ts +7 -3
- package/src/doctor.test.ts +87 -6
- package/src/doctor.ts +112 -23
- package/src/greenwave-external-interface.test.ts +63 -0
- package/src/index.ts +1 -0
- package/src/installed-cli-digest.test.ts +113 -0
- package/src/installed-cli-digest.ts +102 -0
- package/src/parse-line.ts +8 -2
- package/src/resolver-internal-config.test.ts +65 -0
- package/src/shared-infra-pebble-root.test.ts +26 -0
- package/src/shared-infra-self-contained.test.ts +53 -0
- package/src/shared-infra.ts +61 -0
- package/src/socks-proxy.ts +45 -11
- package/src/stale-acme-root.ts +66 -0
- 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
|
|
package/docker/Dockerfile.minio
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
#
|
|
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
|
-
#
|
|
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).
|
|
17
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
56
|
+
EXPOSE 80
|
|
42
57
|
|
|
43
|
-
|
|
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.
|
|
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": "^
|
|
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 {
|
|
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
|
-
/**
|
|
108
|
-
|
|
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
|
|
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
|
-
|
|
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]);
|
|
@@ -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 =
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/cli/build.test.ts
CHANGED
|
@@ -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
|
+
});
|