@celilo/e2e 0.9.3 → 0.10.2

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 (62) hide show
  1. package/config/cpanel-host/cpanel-host-ca.crt +20 -0
  2. package/config/cpanel-host/docroot/.htaccess +14 -0
  3. package/config/cpanel-host/docroot/cms.html +4 -0
  4. package/config/cpanel-host/site-tls.crt +20 -0
  5. package/config/cpanel-host/site-tls.key +28 -0
  6. package/config/cpanel-host/site.conf +27 -0
  7. package/config/dns/com.zone +7 -0
  8. package/config/dns/knot-namecheap.conf +6 -0
  9. package/config/dns/tangohost.com.zone +14 -0
  10. package/config/resolver/unbound.conf +26 -0
  11. package/config/routing/fw-ext-routes.sh +16 -1
  12. package/config/routing/management-routes.sh +14 -4
  13. package/config/routing/minio-startup.sh +9 -0
  14. package/config/routing/public-sim-entrypoint.sh +24 -0
  15. package/config/routing/resolver-internal-routes.sh +10 -0
  16. package/docker/Dockerfile.apt-repo-sim +8 -0
  17. package/docker/Dockerfile.cpanel-host-sim +75 -0
  18. package/docker/Dockerfile.firewall +12 -0
  19. package/docker/Dockerfile.management +6 -0
  20. package/docker/Dockerfile.minio +7 -0
  21. package/docker/Dockerfile.npm-registry-sim +8 -0
  22. package/docker/Dockerfile.observer +11 -0
  23. package/docker/Dockerfile.registry +8 -0
  24. package/docker/Dockerfile.signal-cli +60 -0
  25. package/docker/Dockerfile.signal-release +44 -0
  26. package/docker/Dockerfile.signal-sim +17 -0
  27. package/docker/Dockerfile.target-machine +9 -0
  28. package/package.json +3 -3
  29. package/registry-server/package.json +17 -0
  30. package/registry-server/src/auth.test.ts +76 -0
  31. package/registry-server/src/auth.ts +105 -0
  32. package/registry-server/src/bootstrap-packaging.test.ts +71 -0
  33. package/registry-server/src/bootstrap.ts +250 -0
  34. package/registry-server/src/index.ts +16 -0
  35. package/registry-server/src/introspection.test.ts +247 -0
  36. package/registry-server/src/introspection.ts +204 -0
  37. package/registry-server/src/landing.ts +90 -0
  38. package/registry-server/src/module-owner-store.test.ts +85 -0
  39. package/registry-server/src/module-owner-store.ts +108 -0
  40. package/registry-server/src/rate-limit.test.ts +62 -0
  41. package/registry-server/src/rate-limit.ts +83 -0
  42. package/registry-server/src/scoped-token-store.test.ts +93 -0
  43. package/registry-server/src/scoped-token-store.ts +110 -0
  44. package/registry-server/src/server.test.ts +771 -0
  45. package/registry-server/src/server.ts +701 -0
  46. package/registry-server/src/storage.test.ts +148 -0
  47. package/registry-server/src/storage.ts +150 -0
  48. package/registry-server/src/validation.test.ts +86 -0
  49. package/registry-server/src/validation.ts +60 -0
  50. package/registry-server/tsconfig.json +15 -0
  51. package/scripts/stage-libsignal.ts +116 -0
  52. package/simulators/signal-cli/server.ts +328 -0
  53. package/src/cli/build.ts +7 -0
  54. package/src/cli/index.ts +6 -0
  55. package/src/container-manager.ts +47 -10
  56. package/src/docker-compose-generator.ts +120 -16
  57. package/src/exit-cleanup.test.ts +56 -0
  58. package/src/index.ts +7 -0
  59. package/src/network-builder.ts +66 -0
  60. package/src/public-sim-routes.test.ts +117 -0
  61. package/src/simulator-ips.ts +9 -0
  62. package/src/types.ts +71 -1
@@ -0,0 +1,328 @@
1
+ /**
2
+ * signal-cli JSON-RPC daemon simulator.
3
+ *
4
+ * Mimics `signal-cli daemon --http <addr>`: JSON-RPC 2.0 over POST, with the
5
+ * behaviours that make the real thing awkward, because those are the ones
6
+ * celilo has to survive:
7
+ *
8
+ * - errors arrive as a JSON-RPC `error` member with HTTP **200**, so code
9
+ * that checks `response.ok` believes a rejected send succeeded;
10
+ * - an unlinked/revoked account answers HTTP perfectly and can send nothing;
11
+ * - `receive` returns receipts and typing indicators alongside real
12
+ * messages, and those are not replies;
13
+ * - `receive` is REFUSED unless the daemon was started with
14
+ * `--receive-mode=manual`, which is the real default and the reason the
15
+ * ack path shipped broken.
16
+ *
17
+ * It pushes back rather than saying yes. Sending to a number that has not been
18
+ * registered with the simulator fails the way Signal fails.
19
+ *
20
+ * ── FIDELITY ───────────────────────────────────────────────────────────────
21
+ * Every shape below is verified against signal-cli 0.14.6 running in a
22
+ * container, plus its published JSON schemas — not against recollection. The
23
+ * contract test in e2e re-checks the real daemon so this file cannot silently
24
+ * drift from it. See design.md D16 for the verified table.
25
+ *
26
+ * Still true: this cannot prove that a LINKED account delivers to a phone.
27
+ * That needs Signal's real network, and CDSI/SVR2 attestation makes a local
28
+ * substitute impossible. Everything short of delivery is verified.
29
+ * ───────────────────────────────────────────────────────────────────────────
30
+ *
31
+ * Control surface (simulator-only, not part of signal-cli):
32
+ * POST /_control/inbound {from, body} queue a reply as if a human sent it
33
+ * POST /_control/soft-fail {recipient} make a recipient fail INSIDE a success
34
+ * POST /_control/unlink revoke the device link
35
+ * POST /_control/relink restore it
36
+ * GET /_control/sent every message sent, for assertions
37
+ */
38
+
39
+ const ACCOUNT = process.env.SIGNAL_ACCOUNT ?? '+15551234567';
40
+ const PORT = Number(process.env.SIGNAL_RPC_PORT ?? 8080);
41
+
42
+ /**
43
+ * Mirrors `signal-cli daemon --receive-mode=<mode>`, and DEFAULTS TO THE REAL
44
+ * DEFAULT (`on-start`) rather than to the convenient one.
45
+ *
46
+ * In on-start the daemon registers a strong receive handler, drains every
47
+ * inbound message into its own SSE stream, and REFUSES the `receive` JSON-RPC
48
+ * call outright. A simulator that answers `receive` regardless is a placebo
49
+ * for the whole ack path: that is precisely how the deployed module shipped
50
+ * with an unreadable transport while this suite stayed green. Only `manual`
51
+ * lets `receive` work — so a caller has to be started the way it must be
52
+ * deployed.
53
+ *
54
+ * Verified in signal-cli 0.14.6: DaemonCommand.java:78, :114;
55
+ * ManagerImpl.java:1494-1501, :1605; ReceiveCommand.java:125.
56
+ */
57
+ const RECEIVE_MODE = process.env.SIGNAL_RECEIVE_MODE ?? 'on-start';
58
+
59
+ /** Numbers the simulator will accept as recipients. Anything else is rejected. */
60
+ const KNOWN_RECIPIENTS = new Set(
61
+ (process.env.SIGNAL_KNOWN_RECIPIENTS ?? '')
62
+ .split(',')
63
+ .map((n) => n.trim())
64
+ .filter(Boolean),
65
+ );
66
+
67
+ interface SentMessage {
68
+ recipient: string;
69
+ message: string;
70
+ timestamp: number;
71
+ }
72
+
73
+ interface QueuedInbound {
74
+ from: string;
75
+ body: string;
76
+ timestamp: number;
77
+ }
78
+
79
+ const sent: SentMessage[] = [];
80
+ /**
81
+ * Recipients that fail INSIDE a successful response, with a non-SUCCESS
82
+ * `type`. signal-cli only raises a JSON-RPC error when nothing succeeded, so
83
+ * this is how a partial failure looks — and how a client that checks only
84
+ * `error` ends up recording an undelivered page as sent.
85
+ */
86
+ const unregisteredRecipients = new Set<string>();
87
+ const inboundQueue: QueuedInbound[] = [];
88
+ let linked = true;
89
+ let clock = 1_722_200_000_000;
90
+
91
+ const nextTimestamp = () => ++clock;
92
+
93
+ function rpcResult(id: unknown, result: unknown): Response {
94
+ return Response.json({ jsonrpc: '2.0', id, result });
95
+ }
96
+
97
+ /**
98
+ * A JSON-RPC error, returned with HTTP 200 exactly as signal-cli does. This is
99
+ * the single most important fidelity detail in the simulator.
100
+ */
101
+ function rpcError(id: unknown, code: number, message: string): Response {
102
+ return Response.json({ jsonrpc: '2.0', id, error: { code, message } }, { status: 200 });
103
+ }
104
+
105
+ function handleSend(id: unknown, params: Record<string, unknown>): Response {
106
+ if (!linked) {
107
+ return rpcError(id, -32000, 'Account is not registered or the device link was removed');
108
+ }
109
+
110
+ const recipients = Array.isArray(params.recipient) ? (params.recipient as string[]) : [];
111
+ const message = typeof params.message === 'string' ? params.message : '';
112
+
113
+ if (recipients.length === 0) return rpcError(id, -32602, 'No recipient given');
114
+
115
+ for (const recipient of recipients) {
116
+ // Push back like Signal: an unregistered number is a hard failure, not a
117
+ // silent no-op.
118
+ if (KNOWN_RECIPIENTS.size > 0 && !KNOWN_RECIPIENTS.has(recipient)) {
119
+ return rpcError(id, -32000, `Unregistered user: ${recipient}`);
120
+ }
121
+ }
122
+
123
+ const timestamp = nextTimestamp();
124
+ for (const recipient of recipients) {
125
+ sent.push({ recipient, message, timestamp });
126
+ console.log(`[signal-sim] -> ${recipient}: ${message.split('\n')[0]}`);
127
+ }
128
+
129
+ // Verified shape: { timestamp, results: [ { type, recipientAddress } ] }.
130
+ // The per-recipient `type` is the THIRD failure mode — a call can succeed
131
+ // at the JSON-RPC level and still not have delivered.
132
+ return rpcResult(id, {
133
+ timestamp,
134
+ results: recipients.map((recipient) => ({
135
+ type: unregisteredRecipients.has(recipient) ? 'UNREGISTERED_FAILURE' : 'SUCCESS',
136
+ recipientAddress: { number: recipient },
137
+ })),
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Drain the inbound queue.
143
+ *
144
+ * Interleaves a delivery receipt and a typing indicator with real messages —
145
+ * the real daemon does, and a consumer that treats every envelope as a reply
146
+ * will misbehave on them.
147
+ */
148
+ function handleReceive(id: unknown): Response {
149
+ // The refusal comes BEFORE the link check, as it does in the real daemon:
150
+ // the receive thread is started per account at daemon start, so an
151
+ // already-receiving manager rejects the call without ever looking at what
152
+ // is queued. Verbatim message from ReceiveCommand.java:125.
153
+ if (RECEIVE_MODE !== 'manual') {
154
+ return rpcError(
155
+ id,
156
+ -32001,
157
+ 'Receive command cannot be used if messages are already being received.',
158
+ );
159
+ }
160
+
161
+ if (!linked) {
162
+ return rpcError(id, -32000, 'Account is not registered or the device link was removed');
163
+ }
164
+
165
+ const envelopes: unknown[] = [
166
+ {
167
+ envelope: {
168
+ sourceNumber: ACCOUNT,
169
+ timestamp: nextTimestamp(),
170
+ receiptMessage: { isDelivery: true },
171
+ },
172
+ },
173
+ ];
174
+
175
+ while (inboundQueue.length > 0) {
176
+ const item = inboundQueue.shift() as QueuedInbound;
177
+ // A reply from the linked account itself — the default single-operator
178
+ // setup, where the route points at the number the transport is a
179
+ // secondary device of — is a NOTE-TO-SELF, and the real daemon delivers
180
+ // it as a sync transcript, never as a dataMessage. Emitting it as a
181
+ // dataMessage would let a client that only reads dataMessage look
182
+ // correct here while dropping every reply in production (#460).
183
+ envelopes.push(
184
+ item.from === ACCOUNT
185
+ ? {
186
+ envelope: {
187
+ sourceNumber: ACCOUNT,
188
+ timestamp: nextTimestamp(),
189
+ syncMessage: {
190
+ sentMessage: {
191
+ destinationNumber: ACCOUNT,
192
+ timestamp: item.timestamp,
193
+ message: item.body,
194
+ },
195
+ },
196
+ },
197
+ }
198
+ : {
199
+ envelope: {
200
+ sourceNumber: item.from,
201
+ timestamp: item.timestamp,
202
+ dataMessage: { message: item.body },
203
+ },
204
+ },
205
+ );
206
+ console.log(`[signal-sim] <- ${item.from}: ${item.body}`);
207
+ }
208
+
209
+ envelopes.push({
210
+ envelope: {
211
+ sourceNumber: ACCOUNT,
212
+ timestamp: nextTimestamp(),
213
+ typingMessage: { action: 'STARTED' },
214
+ },
215
+ });
216
+
217
+ return rpcResult(id, envelopes);
218
+ }
219
+
220
+ function handleListAccounts(id: unknown): Response {
221
+ // A revoked link answers happily with an empty account list — reachability
222
+ // is not health.
223
+ return rpcResult(id, linked ? [{ number: ACCOUNT }] : []);
224
+ }
225
+
226
+ async function handleControl(url: URL, req: Request): Promise<Response> {
227
+ switch (url.pathname) {
228
+ case '/_control/inbound': {
229
+ const body = (await req.json()) as { from?: string; body?: string };
230
+ if (!body.from || !body.body) {
231
+ return Response.json({ error: 'from and body required' }, { status: 400 });
232
+ }
233
+ inboundQueue.push({ from: body.from, body: body.body, timestamp: nextTimestamp() });
234
+ return Response.json({ queued: inboundQueue.length });
235
+ }
236
+ case '/_control/soft-fail': {
237
+ const body = (await req.json()) as { recipient?: string };
238
+ if (!body.recipient) {
239
+ return Response.json({ error: 'recipient required' }, { status: 400 });
240
+ }
241
+ unregisteredRecipients.add(body.recipient);
242
+ return Response.json({ softFailing: [...unregisteredRecipients] });
243
+ }
244
+ case '/_control/unlink':
245
+ linked = false;
246
+ console.log('[signal-sim] device link revoked');
247
+ return Response.json({ linked });
248
+ case '/_control/relink':
249
+ linked = true;
250
+ console.log('[signal-sim] device link restored');
251
+ return Response.json({ linked });
252
+ case '/_control/sent':
253
+ return Response.json({ sent });
254
+ case '/_control/reset':
255
+ sent.length = 0;
256
+ inboundQueue.length = 0;
257
+ unregisteredRecipients.clear();
258
+ linked = true;
259
+ return Response.json({ ok: true });
260
+ default:
261
+ return new Response('Not found', { status: 404 });
262
+ }
263
+ }
264
+
265
+ Bun.serve({
266
+ port: PORT,
267
+ hostname: '0.0.0.0',
268
+ async fetch(req) {
269
+ const url = new URL(req.url);
270
+
271
+ if (url.pathname.startsWith('/_control/')) return handleControl(url, req);
272
+
273
+ // Verified endpoints on the real daemon.
274
+ if (url.pathname === '/api/v1/check') {
275
+ return req.method === 'GET' ? new Response(null, { status: 200 }) : new Response(null, { status: 405 });
276
+ }
277
+ if (url.pathname === '/api/v1/events') {
278
+ return new Response('', { status: 200, headers: { 'content-type': 'text/event-stream' } });
279
+ }
280
+ if (url.pathname !== '/api/v1/rpc') return new Response('Not found', { status: 404 });
281
+
282
+ // Verified: the real daemon returns 415 when Content-Type is not JSON.
283
+ const contentType = req.headers.get('content-type');
284
+ if (!contentType?.startsWith('application/json')) {
285
+ return new Response(null, { status: 415 });
286
+ }
287
+
288
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
289
+
290
+ let payload: { id?: unknown; method?: string; params?: Record<string, unknown> };
291
+ try {
292
+ payload = (await req.json()) as typeof payload;
293
+ } catch {
294
+ return Response.json(
295
+ { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } },
296
+ { status: 200 },
297
+ );
298
+ }
299
+
300
+ const { id = null, method, params = {} } = payload;
301
+
302
+ switch (method) {
303
+ case 'send':
304
+ return handleSend(id, params);
305
+ case 'receive':
306
+ return handleReceive(id);
307
+ case 'listAccounts':
308
+ return handleListAccounts(id);
309
+ case 'version':
310
+ // Verified: { "version": "0.14.6" }
311
+ return rpcResult(id, { version: '0.14.6' });
312
+ default:
313
+ return rpcError(id, -32601, `Method not found: ${method}`);
314
+ }
315
+ },
316
+ });
317
+
318
+ console.log(
319
+ `[signal-sim] signal-cli JSON-RPC simulator on :${PORT} as ${ACCOUNT} (receive-mode=${RECEIVE_MODE})`,
320
+ );
321
+ if (KNOWN_RECIPIENTS.size > 0) {
322
+ console.log(`[signal-sim] known recipients: ${[...KNOWN_RECIPIENTS].join(', ')}`);
323
+ }
324
+
325
+ // Marks this file a module so its top-level constants get their own scope.
326
+ // Without it, `PORT` here collides with the same name in the isitup simulator,
327
+ // which shares a tsconfig.
328
+ export {};
package/src/cli/build.ts CHANGED
@@ -23,6 +23,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } fro
23
23
  import { basename, join, resolve } from 'node:path';
24
24
  import { gunzipSync } from 'node:zlib';
25
25
  import { stageAptRepo } from '../../scripts/stage-apt-repo';
26
+ import { stageLibsignal } from '../../scripts/stage-libsignal';
26
27
  import { ensureRegistryServerBundle } from '../registry-bundle';
27
28
 
28
29
  /**
@@ -176,6 +177,12 @@ async function stageSimulatorInputs(pkgDir: string): Promise<void> {
176
177
  // time, so this must run before buildDockerImages().
177
178
  stageAptRepo(repoRoot, pkgDir);
178
179
 
180
+ // (d) Build + stage the libsignal aarch64 native as a .deb, into the SAME
181
+ // pool stageAptRepo just populated (so it must run after it — that function
182
+ // recreates the pool empty). Compiled here on the host's own network rather
183
+ // than inside the sealed e2e network; see stage-libsignal.ts.
184
+ stageLibsignal(repoRoot, pkgDir);
185
+
179
186
  console.log('');
180
187
  }
181
188
 
package/src/cli/index.ts CHANGED
@@ -61,6 +61,12 @@ function detectTestDir(): { stateDir: string; testsPath: string } {
61
61
  }
62
62
  }
63
63
  if (existsSync(join(cwd, 'tests'))) return { stateDir: cwd, testsPath: join(cwd, 'tests') };
64
+ // The repo's own top-level suite lives at e2e/tests/, which is what `list`
65
+ // enumerates. Without this, `cele2e list` shows a test that `cele2e run
66
+ // <name>` then cannot find — the run/list disagreement in #412.
67
+ if (existsSync(join(cwd, 'e2e', 'tests'))) {
68
+ return { stateDir: join(cwd, 'e2e'), testsPath: join(cwd, 'e2e', 'tests') };
69
+ }
64
70
  return { stateDir: cwd, testsPath: join(cwd, 'tests') };
65
71
  }
66
72
 
@@ -4,7 +4,11 @@ import { tmpdir } from 'node:os';
4
4
  import { basename, join, resolve } from 'node:path';
5
5
  import { startBrowser } from './browser';
6
6
  import { MIN_CLI_VERSION, checkCliVersion } from './cli-version-contract';
7
- import { SHARED_PROJECT_NAME, generateTestComposeYaml } from './docker-compose-generator';
7
+ import {
8
+ SHARED_PROJECT_NAME,
9
+ generateTestComposeYaml,
10
+ getAllMachines,
11
+ } from './docker-compose-generator';
8
12
  import { ensureSharedInfra } from './shared-infra';
9
13
  import { SIMULATOR_IPS } from './simulator-ips';
10
14
  import { startSocksProxy } from './socks-proxy';
@@ -67,8 +71,16 @@ const activeProjects: Set<string> = new Set();
67
71
  *
68
72
  * NOTE: only tears down per-test projects. Shared infra is managed
69
73
  * separately (by the test runner or explicit stopSharedInfra call).
74
+ *
75
+ * MUST stay fully synchronous: it runs from an `exit` handler, where the
76
+ * event loop is already closed and any async work is silently dropped.
70
77
  */
71
78
  function cleanupOnExit() {
79
+ // `--keep` / `--reuse` mean the operator wants the stack to survive for
80
+ // debugging — and they want it MOST after a failure, which is exactly the
81
+ // path this handler covers. Tearing down here would delete the evidence.
82
+ if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') return;
83
+
72
84
  for (const project of activeProjects) {
73
85
  try {
74
86
  execSync(`docker compose -p ${project} down --volumes --remove-orphans`, {
@@ -77,6 +89,7 @@ function cleanupOnExit() {
77
89
  });
78
90
  } catch {}
79
91
  }
92
+ activeProjects.clear();
80
93
  try {
81
94
  execSync('docker network prune -f', { timeout: 10_000, stdio: 'pipe' });
82
95
  } catch {}
@@ -90,7 +103,25 @@ process.on('SIGINT', () => {
90
103
  cleanupOnExit();
91
104
  process.exit(1);
92
105
  });
93
- process.on('beforeExit', () => {
106
+ // `exit`, NOT `beforeExit`: the runner always terminates via `process.exit()`
107
+ // (runner.ts ends with `process.exit(failed > 0 ? 1 : 0)`), and `beforeExit`
108
+ // does not fire for an explicit exit — nor after an uncaught throw. So on the
109
+ // FAILURE path, where the test never reaches `handle.stop()`, teardown was
110
+ // never running and the per-test networks leaked.
111
+ //
112
+ // That leak is not confined to the run that caused it. The sim's networks use
113
+ // the same subnets as a real celilo fleet (dmz 10.0.10.0/24, app 10.0.20.0/24,
114
+ // …), so on a host that lives in those subnets — celilo's own forgejo-builder
115
+ // does — a leaked bridge leaves a DUPLICATE route for a production prefix.
116
+ // Traffic from any later container to the real host at that address is routed
117
+ // into the dead simulated network and blackholed. That took out three
118
+ // consecutive release runs, each dying in `git fetch` after a ~132s connect
119
+ // timeout, long after the e2e run that stranded the network had ended.
120
+ //
121
+ // The start-of-run prefix sweep stays as the crash-recovery backstop (it is
122
+ // the only thing that can clean up after SIGKILL); this makes the common case
123
+ // clean up after itself instead of poisoning the next job. See #508.
124
+ process.on('exit', () => {
94
125
  cleanupOnExit();
95
126
  });
96
127
 
@@ -829,13 +860,11 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
829
860
  );
830
861
  }
831
862
 
832
- // Wait for all target machines to complete setup (systemd boot + routing)
833
- const allMachines = [
834
- ...config.dmzMachines,
835
- ...config.appMachines,
836
- ...config.secureMachines,
837
- ...config.internalMachines,
838
- ];
863
+ // Wait for all target machines to complete setup (systemd boot + routing).
864
+ // Via getAllMachines so a newly-supported zone cannot be silently skipped —
865
+ // this list was hand-maintained and omitted `secure-mgmt`, so the first
866
+ // machine placed there raced `machine add` and failed on SSH intermittently.
867
+ const allMachines = getAllMachines(config);
839
868
  if (allMachines.length > 0) {
840
869
  console.log('[progress:start] waiting for target machines | target machines ready');
841
870
  }
@@ -935,7 +964,15 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
935
964
  `celilo system init --accept-defaults \
936
965
  network.internal.subnet=${ZONE_SUBNETS.internal} \
937
966
  network.internal.gateway=${ZONE_GATEWAYS.internal} \
938
- dns.primary=100.100.0.1 \
967
+ ${
968
+ // The control plane needs its subnet declared if celilo-mgr lives
969
+ // there OR if a machine does — infrastructure selection for a module
970
+ // declaring `zone: secure-mgmt` reads it either way (#436).
971
+ config.managementZone === 'secure-mgmt' ||
972
+ (config.secureMgmtMachines ?? []).length > 0
973
+ ? `network.secure-mgmt.subnet=${ZONE_SUBNETS['secure-mgmt']} network.secure-mgmt.gateway=${ZONE_GATEWAYS['secure-mgmt']} `
974
+ : ''
975
+ }dns.primary=100.100.0.1 \
939
976
  dns.fallback=1.0.0.1,8.8.8.8`,
940
977
  );
941
978
  if (initResult.exitCode !== 0) {