@celilo/e2e 0.9.3 → 0.10.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/config/cpanel-host/cpanel-host-ca.crt +20 -0
- package/config/cpanel-host/docroot/.htaccess +14 -0
- package/config/cpanel-host/docroot/cms.html +4 -0
- package/config/cpanel-host/site-tls.crt +20 -0
- package/config/cpanel-host/site-tls.key +28 -0
- package/config/cpanel-host/site.conf +27 -0
- package/config/dns/com.zone +7 -0
- package/config/dns/knot-namecheap.conf +6 -0
- package/config/dns/tangohost.com.zone +14 -0
- package/config/resolver/unbound.conf +26 -0
- package/config/routing/management-routes.sh +9 -4
- package/config/routing/resolver-internal-routes.sh +10 -0
- package/docker/Dockerfile.cpanel-host-sim +70 -0
- package/docker/Dockerfile.firewall +12 -0
- package/docker/Dockerfile.management +6 -0
- package/docker/Dockerfile.observer +11 -0
- package/docker/Dockerfile.signal-cli +60 -0
- package/docker/Dockerfile.signal-release +36 -0
- package/docker/Dockerfile.signal-sim +17 -0
- package/docker/Dockerfile.target-machine +9 -0
- package/package.json +3 -3
- package/registry-server/package.json +17 -0
- package/registry-server/src/auth.test.ts +76 -0
- package/registry-server/src/auth.ts +105 -0
- package/registry-server/src/bootstrap-packaging.test.ts +71 -0
- package/registry-server/src/bootstrap.ts +246 -0
- package/registry-server/src/index.ts +16 -0
- package/registry-server/src/introspection.test.ts +247 -0
- package/registry-server/src/introspection.ts +204 -0
- package/registry-server/src/landing.ts +90 -0
- package/registry-server/src/module-owner-store.test.ts +85 -0
- package/registry-server/src/module-owner-store.ts +108 -0
- package/registry-server/src/rate-limit.test.ts +62 -0
- package/registry-server/src/rate-limit.ts +83 -0
- package/registry-server/src/scoped-token-store.test.ts +93 -0
- package/registry-server/src/scoped-token-store.ts +110 -0
- package/registry-server/src/server.test.ts +771 -0
- package/registry-server/src/server.ts +701 -0
- package/registry-server/src/storage.test.ts +148 -0
- package/registry-server/src/storage.ts +150 -0
- package/registry-server/src/validation.test.ts +86 -0
- package/registry-server/src/validation.ts +60 -0
- package/registry-server/tsconfig.json +15 -0
- package/scripts/stage-libsignal.ts +116 -0
- package/simulators/signal-cli/server.ts +272 -0
- package/src/cli/build.ts +7 -0
- package/src/cli/index.ts +6 -0
- package/src/container-manager.ts +5 -1
- package/src/docker-compose-generator.ts +108 -4
- package/src/network-builder.ts +52 -0
- package/src/simulator-ips.ts +9 -0
- package/src/types.ts +34 -0
|
@@ -0,0 +1,272 @@
|
|
|
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
|
+
*
|
|
14
|
+
* It pushes back rather than saying yes. Sending to a number that has not been
|
|
15
|
+
* registered with the simulator fails the way Signal fails.
|
|
16
|
+
*
|
|
17
|
+
* ── FIDELITY ───────────────────────────────────────────────────────────────
|
|
18
|
+
* Every shape below is verified against signal-cli 0.14.6 running in a
|
|
19
|
+
* container, plus its published JSON schemas — not against recollection. The
|
|
20
|
+
* contract test in e2e re-checks the real daemon so this file cannot silently
|
|
21
|
+
* drift from it. See design.md D16 for the verified table.
|
|
22
|
+
*
|
|
23
|
+
* Still true: this cannot prove that a LINKED account delivers to a phone.
|
|
24
|
+
* That needs Signal's real network, and CDSI/SVR2 attestation makes a local
|
|
25
|
+
* substitute impossible. Everything short of delivery is verified.
|
|
26
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* Control surface (simulator-only, not part of signal-cli):
|
|
29
|
+
* POST /_control/inbound {from, body} queue a reply as if a human sent it
|
|
30
|
+
* POST /_control/soft-fail {recipient} make a recipient fail INSIDE a success
|
|
31
|
+
* POST /_control/unlink revoke the device link
|
|
32
|
+
* POST /_control/relink restore it
|
|
33
|
+
* GET /_control/sent every message sent, for assertions
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const ACCOUNT = process.env.SIGNAL_ACCOUNT ?? '+15551234567';
|
|
37
|
+
const PORT = Number(process.env.SIGNAL_RPC_PORT ?? 8080);
|
|
38
|
+
|
|
39
|
+
/** Numbers the simulator will accept as recipients. Anything else is rejected. */
|
|
40
|
+
const KNOWN_RECIPIENTS = new Set(
|
|
41
|
+
(process.env.SIGNAL_KNOWN_RECIPIENTS ?? '')
|
|
42
|
+
.split(',')
|
|
43
|
+
.map((n) => n.trim())
|
|
44
|
+
.filter(Boolean),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
interface SentMessage {
|
|
48
|
+
recipient: string;
|
|
49
|
+
message: string;
|
|
50
|
+
timestamp: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface QueuedInbound {
|
|
54
|
+
from: string;
|
|
55
|
+
body: string;
|
|
56
|
+
timestamp: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const sent: SentMessage[] = [];
|
|
60
|
+
/**
|
|
61
|
+
* Recipients that fail INSIDE a successful response, with a non-SUCCESS
|
|
62
|
+
* `type`. signal-cli only raises a JSON-RPC error when nothing succeeded, so
|
|
63
|
+
* this is how a partial failure looks — and how a client that checks only
|
|
64
|
+
* `error` ends up recording an undelivered page as sent.
|
|
65
|
+
*/
|
|
66
|
+
const unregisteredRecipients = new Set<string>();
|
|
67
|
+
const inboundQueue: QueuedInbound[] = [];
|
|
68
|
+
let linked = true;
|
|
69
|
+
let clock = 1_722_200_000_000;
|
|
70
|
+
|
|
71
|
+
const nextTimestamp = () => ++clock;
|
|
72
|
+
|
|
73
|
+
function rpcResult(id: unknown, result: unknown): Response {
|
|
74
|
+
return Response.json({ jsonrpc: '2.0', id, result });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A JSON-RPC error, returned with HTTP 200 exactly as signal-cli does. This is
|
|
79
|
+
* the single most important fidelity detail in the simulator.
|
|
80
|
+
*/
|
|
81
|
+
function rpcError(id: unknown, code: number, message: string): Response {
|
|
82
|
+
return Response.json({ jsonrpc: '2.0', id, error: { code, message } }, { status: 200 });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function handleSend(id: unknown, params: Record<string, unknown>): Response {
|
|
86
|
+
if (!linked) {
|
|
87
|
+
return rpcError(id, -32000, 'Account is not registered or the device link was removed');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const recipients = Array.isArray(params.recipient) ? (params.recipient as string[]) : [];
|
|
91
|
+
const message = typeof params.message === 'string' ? params.message : '';
|
|
92
|
+
|
|
93
|
+
if (recipients.length === 0) return rpcError(id, -32602, 'No recipient given');
|
|
94
|
+
|
|
95
|
+
for (const recipient of recipients) {
|
|
96
|
+
// Push back like Signal: an unregistered number is a hard failure, not a
|
|
97
|
+
// silent no-op.
|
|
98
|
+
if (KNOWN_RECIPIENTS.size > 0 && !KNOWN_RECIPIENTS.has(recipient)) {
|
|
99
|
+
return rpcError(id, -32000, `Unregistered user: ${recipient}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const timestamp = nextTimestamp();
|
|
104
|
+
for (const recipient of recipients) {
|
|
105
|
+
sent.push({ recipient, message, timestamp });
|
|
106
|
+
console.log(`[signal-sim] -> ${recipient}: ${message.split('\n')[0]}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Verified shape: { timestamp, results: [ { type, recipientAddress } ] }.
|
|
110
|
+
// The per-recipient `type` is the THIRD failure mode — a call can succeed
|
|
111
|
+
// at the JSON-RPC level and still not have delivered.
|
|
112
|
+
return rpcResult(id, {
|
|
113
|
+
timestamp,
|
|
114
|
+
results: recipients.map((recipient) => ({
|
|
115
|
+
type: unregisteredRecipients.has(recipient) ? 'UNREGISTERED_FAILURE' : 'SUCCESS',
|
|
116
|
+
recipientAddress: { number: recipient },
|
|
117
|
+
})),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Drain the inbound queue.
|
|
123
|
+
*
|
|
124
|
+
* Interleaves a delivery receipt and a typing indicator with real messages —
|
|
125
|
+
* the real daemon does, and a consumer that treats every envelope as a reply
|
|
126
|
+
* will misbehave on them.
|
|
127
|
+
*/
|
|
128
|
+
function handleReceive(id: unknown): Response {
|
|
129
|
+
if (!linked) {
|
|
130
|
+
return rpcError(id, -32000, 'Account is not registered or the device link was removed');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const envelopes: unknown[] = [
|
|
134
|
+
{
|
|
135
|
+
envelope: {
|
|
136
|
+
sourceNumber: ACCOUNT,
|
|
137
|
+
timestamp: nextTimestamp(),
|
|
138
|
+
receiptMessage: { isDelivery: true },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
while (inboundQueue.length > 0) {
|
|
144
|
+
const item = inboundQueue.shift() as QueuedInbound;
|
|
145
|
+
envelopes.push({
|
|
146
|
+
envelope: {
|
|
147
|
+
sourceNumber: item.from,
|
|
148
|
+
timestamp: item.timestamp,
|
|
149
|
+
dataMessage: { message: item.body },
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
console.log(`[signal-sim] <- ${item.from}: ${item.body}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
envelopes.push({
|
|
156
|
+
envelope: {
|
|
157
|
+
sourceNumber: ACCOUNT,
|
|
158
|
+
timestamp: nextTimestamp(),
|
|
159
|
+
typingMessage: { action: 'STARTED' },
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return rpcResult(id, envelopes);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function handleListAccounts(id: unknown): Response {
|
|
167
|
+
// A revoked link answers happily with an empty account list — reachability
|
|
168
|
+
// is not health.
|
|
169
|
+
return rpcResult(id, linked ? [{ number: ACCOUNT }] : []);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function handleControl(url: URL, req: Request): Promise<Response> {
|
|
173
|
+
switch (url.pathname) {
|
|
174
|
+
case '/_control/inbound': {
|
|
175
|
+
const body = (await req.json()) as { from?: string; body?: string };
|
|
176
|
+
if (!body.from || !body.body) {
|
|
177
|
+
return Response.json({ error: 'from and body required' }, { status: 400 });
|
|
178
|
+
}
|
|
179
|
+
inboundQueue.push({ from: body.from, body: body.body, timestamp: nextTimestamp() });
|
|
180
|
+
return Response.json({ queued: inboundQueue.length });
|
|
181
|
+
}
|
|
182
|
+
case '/_control/soft-fail': {
|
|
183
|
+
const body = (await req.json()) as { recipient?: string };
|
|
184
|
+
if (!body.recipient) {
|
|
185
|
+
return Response.json({ error: 'recipient required' }, { status: 400 });
|
|
186
|
+
}
|
|
187
|
+
unregisteredRecipients.add(body.recipient);
|
|
188
|
+
return Response.json({ softFailing: [...unregisteredRecipients] });
|
|
189
|
+
}
|
|
190
|
+
case '/_control/unlink':
|
|
191
|
+
linked = false;
|
|
192
|
+
console.log('[signal-sim] device link revoked');
|
|
193
|
+
return Response.json({ linked });
|
|
194
|
+
case '/_control/relink':
|
|
195
|
+
linked = true;
|
|
196
|
+
console.log('[signal-sim] device link restored');
|
|
197
|
+
return Response.json({ linked });
|
|
198
|
+
case '/_control/sent':
|
|
199
|
+
return Response.json({ sent });
|
|
200
|
+
case '/_control/reset':
|
|
201
|
+
sent.length = 0;
|
|
202
|
+
inboundQueue.length = 0;
|
|
203
|
+
unregisteredRecipients.clear();
|
|
204
|
+
linked = true;
|
|
205
|
+
return Response.json({ ok: true });
|
|
206
|
+
default:
|
|
207
|
+
return new Response('Not found', { status: 404 });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
Bun.serve({
|
|
212
|
+
port: PORT,
|
|
213
|
+
hostname: '0.0.0.0',
|
|
214
|
+
async fetch(req) {
|
|
215
|
+
const url = new URL(req.url);
|
|
216
|
+
|
|
217
|
+
if (url.pathname.startsWith('/_control/')) return handleControl(url, req);
|
|
218
|
+
|
|
219
|
+
// Verified endpoints on the real daemon.
|
|
220
|
+
if (url.pathname === '/api/v1/check') {
|
|
221
|
+
return req.method === 'GET' ? new Response(null, { status: 200 }) : new Response(null, { status: 405 });
|
|
222
|
+
}
|
|
223
|
+
if (url.pathname === '/api/v1/events') {
|
|
224
|
+
return new Response('', { status: 200, headers: { 'content-type': 'text/event-stream' } });
|
|
225
|
+
}
|
|
226
|
+
if (url.pathname !== '/api/v1/rpc') return new Response('Not found', { status: 404 });
|
|
227
|
+
|
|
228
|
+
// Verified: the real daemon returns 415 when Content-Type is not JSON.
|
|
229
|
+
const contentType = req.headers.get('content-type');
|
|
230
|
+
if (!contentType?.startsWith('application/json')) {
|
|
231
|
+
return new Response(null, { status: 415 });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
235
|
+
|
|
236
|
+
let payload: { id?: unknown; method?: string; params?: Record<string, unknown> };
|
|
237
|
+
try {
|
|
238
|
+
payload = (await req.json()) as typeof payload;
|
|
239
|
+
} catch {
|
|
240
|
+
return Response.json(
|
|
241
|
+
{ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } },
|
|
242
|
+
{ status: 200 },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const { id = null, method, params = {} } = payload;
|
|
247
|
+
|
|
248
|
+
switch (method) {
|
|
249
|
+
case 'send':
|
|
250
|
+
return handleSend(id, params);
|
|
251
|
+
case 'receive':
|
|
252
|
+
return handleReceive(id);
|
|
253
|
+
case 'listAccounts':
|
|
254
|
+
return handleListAccounts(id);
|
|
255
|
+
case 'version':
|
|
256
|
+
// Verified: { "version": "0.14.6" }
|
|
257
|
+
return rpcResult(id, { version: '0.14.6' });
|
|
258
|
+
default:
|
|
259
|
+
return rpcError(id, -32601, `Method not found: ${method}`);
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
console.log(`[signal-sim] signal-cli JSON-RPC simulator on :${PORT} as ${ACCOUNT}`);
|
|
265
|
+
if (KNOWN_RECIPIENTS.size > 0) {
|
|
266
|
+
console.log(`[signal-sim] known recipients: ${[...KNOWN_RECIPIENTS].join(', ')}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Marks this file a module so its top-level constants get their own scope.
|
|
270
|
+
// Without it, `PORT` here collides with the same name in the isitup simulator,
|
|
271
|
+
// which shares a tsconfig.
|
|
272
|
+
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
|
|
package/src/container-manager.ts
CHANGED
|
@@ -935,7 +935,11 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
935
935
|
`celilo system init --accept-defaults \
|
|
936
936
|
network.internal.subnet=${ZONE_SUBNETS.internal} \
|
|
937
937
|
network.internal.gateway=${ZONE_GATEWAYS.internal} \
|
|
938
|
-
|
|
938
|
+
${
|
|
939
|
+
config.managementZone === 'secure-mgmt'
|
|
940
|
+
? 'network.secure-mgmt.subnet=10.0.120.0/24 network.secure-mgmt.gateway=10.0.120.1 '
|
|
941
|
+
: ''
|
|
942
|
+
}dns.primary=100.100.0.1 \
|
|
939
943
|
dns.fallback=1.0.0.1,8.8.8.8`,
|
|
940
944
|
);
|
|
941
945
|
if (initResult.exitCode !== 0) {
|
|
@@ -79,6 +79,8 @@ function baseService(opts: {
|
|
|
79
79
|
tmpfs?: string[];
|
|
80
80
|
privileged?: boolean;
|
|
81
81
|
dns?: string[];
|
|
82
|
+
/** Host devices to expose, e.g. `/dev/net/tun` for a userspace WireGuard tunnel. */
|
|
83
|
+
devices?: string[];
|
|
82
84
|
}) {
|
|
83
85
|
// Auto-assign an image tag from the Dockerfile name so Docker can
|
|
84
86
|
// skip builds when pre-built images exist.
|
|
@@ -109,6 +111,16 @@ export const SHARED_NETWORKS = {
|
|
|
109
111
|
* Default topology: fw-isp (greenwave) + fw-main (iptables)
|
|
110
112
|
* Two-layer NAT: internet → fw-isp → fw-main → target
|
|
111
113
|
*/
|
|
114
|
+
/**
|
|
115
|
+
* celilo's own control-plane network, used only when `managementZone` is
|
|
116
|
+
* 'secure-mgmt'. Deliberately NOT one of the segmented data-plane tiers: it is
|
|
117
|
+
* where celilo-mgr itself lives, mirroring the production topology the suite
|
|
118
|
+
* previously could not express.
|
|
119
|
+
*/
|
|
120
|
+
const SECURE_MGMT_SUBNET = '10.0.120.0/24';
|
|
121
|
+
const SECURE_MGMT_GATEWAY = '10.0.120.1';
|
|
122
|
+
const SECURE_MGMT_MANAGEMENT_IP = '10.0.120.100';
|
|
123
|
+
|
|
112
124
|
function defaultTopologyServices(): Record<string, unknown> {
|
|
113
125
|
return {
|
|
114
126
|
'fw-main': baseService({
|
|
@@ -120,6 +132,10 @@ function defaultTopologyServices(): Record<string, unknown> {
|
|
|
120
132
|
secure: { ipv4_address: '10.0.30.1' },
|
|
121
133
|
},
|
|
122
134
|
cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
|
|
135
|
+
// A firewall may also terminate a VPN (the admin tunnel the `wireguard`
|
|
136
|
+
// module owns). wireguard-go needs the tun device; the kernel module is
|
|
137
|
+
// deliberately not relied on — see Dockerfile.firewall.
|
|
138
|
+
devices: ['/dev/net/tun'],
|
|
123
139
|
sysctls: ROUTER_SYSCTLS,
|
|
124
140
|
volumes: ['ssh-keys:/ssh-keys:ro'],
|
|
125
141
|
}),
|
|
@@ -130,6 +146,10 @@ function defaultTopologyServices(): Record<string, unknown> {
|
|
|
130
146
|
'isp-external': { ipv4_address: '100.100.0.100' },
|
|
131
147
|
},
|
|
132
148
|
cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
|
|
149
|
+
// A firewall may also terminate a VPN (the admin tunnel the `wireguard`
|
|
150
|
+
// module owns). wireguard-go needs the tun device; the kernel module is
|
|
151
|
+
// deliberately not relied on — see Dockerfile.firewall.
|
|
152
|
+
devices: ['/dev/net/tun'],
|
|
133
153
|
sysctls: ROUTER_SYSCTLS,
|
|
134
154
|
volumes: ['ssh-keys:/ssh-keys:ro'],
|
|
135
155
|
}),
|
|
@@ -153,6 +173,10 @@ function directInternetTopologyServices(): Record<string, unknown> {
|
|
|
153
173
|
'isp-external': { ipv4_address: '100.100.0.100' },
|
|
154
174
|
},
|
|
155
175
|
cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
|
|
176
|
+
// A firewall may also terminate a VPN (the admin tunnel the `wireguard`
|
|
177
|
+
// module owns). wireguard-go needs the tun device; the kernel module is
|
|
178
|
+
// deliberately not relied on — see Dockerfile.firewall.
|
|
179
|
+
devices: ['/dev/net/tun'],
|
|
156
180
|
sysctls: ROUTER_SYSCTLS,
|
|
157
181
|
volumes: ['ssh-keys:/ssh-keys:ro'],
|
|
158
182
|
}),
|
|
@@ -232,6 +256,7 @@ export function generateSharedInfraYaml(): string {
|
|
|
232
256
|
'./config/dns/celilo.computer.zone:/seed/celilo.computer.zone:ro',
|
|
233
257
|
'./config/dns/example.net.zone:/seed/example.net.zone:ro',
|
|
234
258
|
'./config/dns/park-your-domain.com.zone:/seed/park-your-domain.com.zone:ro',
|
|
259
|
+
'./config/dns/tangohost.com.zone:/seed/tangohost.com.zone:ro',
|
|
235
260
|
],
|
|
236
261
|
});
|
|
237
262
|
|
|
@@ -292,6 +317,17 @@ export function generateSharedInfraYaml(): string {
|
|
|
292
317
|
// packages/e2e/scripts/pack-celilo-packages.ts (cli, capabilities,
|
|
293
318
|
// cli-display, e2e, event-bus). Reachable at http://npm-registry.lab from
|
|
294
319
|
// any container that uses the internal resolver.
|
|
320
|
+
// OFF-FLEET web host (external_web). A PUBLIC PEER on internet-external, not
|
|
321
|
+
// a fleet member and not behind the customer firewall: celilo reaches it by
|
|
322
|
+
// SSH egress the way it would reach any host on the real internet, and public
|
|
323
|
+
// DNS resolves tangohost.com straight to this address. It runs its own Apache
|
|
324
|
+
// and its own pre-installed TLS — celilo governs none of it.
|
|
325
|
+
services['cpanel-host'] = baseService({
|
|
326
|
+
build: { context: '.', dockerfile: 'docker/Dockerfile.cpanel-host-sim' },
|
|
327
|
+
networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.CPANEL_HOST } },
|
|
328
|
+
cap_add: ['NET_ADMIN'],
|
|
329
|
+
});
|
|
330
|
+
|
|
295
331
|
services['npm-registry'] = baseService({
|
|
296
332
|
build: { context: '.', dockerfile: 'docker/Dockerfile.npm-registry-sim' },
|
|
297
333
|
networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.NPM_REGISTRY } },
|
|
@@ -337,11 +373,17 @@ export function generateSharedInfraYaml(): string {
|
|
|
337
373
|
* Creates its own internal, dmz, app, secure networks.
|
|
338
374
|
*/
|
|
339
375
|
export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: string): string {
|
|
376
|
+
const mgmtOnOwnNetwork = config.managementZone === 'secure-mgmt';
|
|
340
377
|
const networks: Record<string, unknown> = {
|
|
341
378
|
internal: networkDef('192.168.0.0/24', '192.168.0.250'),
|
|
342
379
|
dmz: networkDef('10.0.10.0/24', '10.0.10.250'),
|
|
343
380
|
app: networkDef('10.0.20.0/24', '10.0.20.250'),
|
|
344
381
|
secure: networkDef('10.0.30.0/24', '10.0.30.250'),
|
|
382
|
+
// Only when celilo-mgr lives off the internal LAN — keeps the default
|
|
383
|
+
// topology's generated compose byte-identical for every existing test.
|
|
384
|
+
...(mgmtOnOwnNetwork
|
|
385
|
+
? { 'secure-mgmt': networkDef(SECURE_MGMT_SUBNET, '10.0.120.250') }
|
|
386
|
+
: {}),
|
|
345
387
|
'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
|
|
346
388
|
// internet-external is owned by shared infra; real-internet is per-test
|
|
347
389
|
// (it just bridges fw-ext ↔ comcast-resolver, no shared service uses it).
|
|
@@ -392,11 +434,15 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
392
434
|
// first. container-manager's celilo-init + routing-verify target the
|
|
393
435
|
// `management` container by name, so `celilo-mgr-2` comes up fresh and
|
|
394
436
|
// un-init'd — the empty target restore needs.
|
|
395
|
-
const emitManagement = (
|
|
437
|
+
const emitManagement = (
|
|
438
|
+
variant: 'default' | 'vanilla' | undefined,
|
|
439
|
+
ip: string,
|
|
440
|
+
zone: 'internal' | 'secure-mgmt' = 'internal',
|
|
441
|
+
) =>
|
|
396
442
|
baseService({
|
|
397
443
|
image:
|
|
398
444
|
variant === 'vanilla' ? 'celilo-e2e/management:vanilla' : 'celilo-e2e/management:latest',
|
|
399
|
-
networks: {
|
|
445
|
+
networks: { [zone]: { ipv4_address: ip } },
|
|
400
446
|
cap_add: ['NET_ADMIN'],
|
|
401
447
|
// ISS-0157: `module import`'s `bun install` hard-links packages from the
|
|
402
448
|
// bun cache into node_modules, which on overlayfs forces a copy-up + fsync
|
|
@@ -416,12 +462,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
416
462
|
],
|
|
417
463
|
environment: {
|
|
418
464
|
DDNS_ENDPOINT: `http://${SIMULATOR_IPS.NAMECHEAP_DNS}:8080/update`,
|
|
419
|
-
|
|
465
|
+
// On its own control-plane network the only router in reach is fw-main's
|
|
466
|
+
// leg there — both for the default route and for the segmented zones.
|
|
467
|
+
DEFAULT_GATEWAY: zone === 'secure-mgmt' ? SECURE_MGMT_GATEWAY : managementDefaultGw,
|
|
468
|
+
...(zone === 'secure-mgmt' ? { FW_MAIN_HOP: SECURE_MGMT_GATEWAY } : {}),
|
|
420
469
|
CELILO_REGISTRY_URL: 'http://e2e-registry.lab',
|
|
421
470
|
},
|
|
422
471
|
});
|
|
423
472
|
|
|
424
|
-
services.management =
|
|
473
|
+
services.management = mgmtOnOwnNetwork
|
|
474
|
+
? emitManagement(config.managementVariant, SECURE_MGMT_MANAGEMENT_IP, 'secure-mgmt')
|
|
475
|
+
: emitManagement(config.managementVariant, '192.168.0.100');
|
|
425
476
|
|
|
426
477
|
if (config.secondaryManagementVariant) {
|
|
427
478
|
services['celilo-mgr-2'] = emitManagement(config.secondaryManagementVariant, '192.168.0.101');
|
|
@@ -451,6 +502,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
451
502
|
const topologyFn = TOPOLOGY_SERVICES[config.topology] || TOPOLOGY_SERVICES.default;
|
|
452
503
|
Object.assign(services, topologyFn());
|
|
453
504
|
|
|
505
|
+
// Give fw-main a leg on the control-plane network when celilo-mgr lives there,
|
|
506
|
+
// so the management box is routed like any other segmented network rather than
|
|
507
|
+
// being stranded. Applied as a post-step so the default topology's generated
|
|
508
|
+
// compose is untouched.
|
|
509
|
+
if (mgmtOnOwnNetwork) {
|
|
510
|
+
const fwMain = services['fw-main'] as { networks?: Record<string, unknown> } | undefined;
|
|
511
|
+
if (fwMain?.networks) {
|
|
512
|
+
fwMain.networks['secure-mgmt'] = { ipv4_address: SECURE_MGMT_GATEWAY };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
454
516
|
// --- Routing infrastructure (bridges per-test to shared infra) ---
|
|
455
517
|
services['fw-ext'] = baseService({
|
|
456
518
|
build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
|
|
@@ -518,6 +580,44 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
518
580
|
});
|
|
519
581
|
}
|
|
520
582
|
|
|
583
|
+
// --- Optional real signal-cli daemon ---
|
|
584
|
+
// On `internal`, matching the signal module's declared zone — it is a local
|
|
585
|
+
// service celilo talks to, not an internet-facing one, so it does not belong
|
|
586
|
+
// on internet-external. Unlinked: the JSON-RPC surface needs no account.
|
|
587
|
+
if (config.signalCli) {
|
|
588
|
+
services['signal-cli'] = baseService({
|
|
589
|
+
build: { context: '.', dockerfile: 'docker/Dockerfile.signal-cli' },
|
|
590
|
+
networks: { internal: { ipv4_address: '192.168.0.90' } },
|
|
591
|
+
cap_add: ['NET_ADMIN'],
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// --- Optional signal-cli release host ---
|
|
596
|
+
// On internet-external because that is what it models: an artifact the
|
|
597
|
+
// module downloads FROM the internet at deploy time.
|
|
598
|
+
if (config.signalRelease) {
|
|
599
|
+
services['signal-release'] = baseService({
|
|
600
|
+
build: { context: '.', dockerfile: 'docker/Dockerfile.signal-release' },
|
|
601
|
+
networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.SIGNAL_RELEASE } },
|
|
602
|
+
cap_add: ['NET_ADMIN'],
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// --- Optional signal-cli simulator ---
|
|
607
|
+
// Same network placement as the real daemon so a test can swap one for the
|
|
608
|
+
// other without touching the module's configured endpoint.
|
|
609
|
+
if (config.signalSim) {
|
|
610
|
+
services['signal-sim'] = baseService({
|
|
611
|
+
build: { context: '.', dockerfile: 'docker/Dockerfile.signal-sim' },
|
|
612
|
+
networks: { internal: { ipv4_address: '192.168.0.91' } },
|
|
613
|
+
cap_add: ['NET_ADMIN'],
|
|
614
|
+
environment: {
|
|
615
|
+
SIGNAL_ACCOUNT: '+15551234567',
|
|
616
|
+
SIGNAL_KNOWN_RECIPIENTS: '+15550001,+15550002',
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
521
621
|
// --- Observer vantages (passive spies; ISS-0117) ---
|
|
522
622
|
// Each is the SAME image at a different network location with a routing profile that
|
|
523
623
|
// mirrors a real device there — that routing profile is what makes it a faithful seat
|
|
@@ -528,6 +628,9 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
528
628
|
build: { context: '.', dockerfile: 'docker/Dockerfile.observer' },
|
|
529
629
|
networks: { [p.network]: { ipv4_address: p.ip } },
|
|
530
630
|
cap_add: ['NET_ADMIN'],
|
|
631
|
+
// A vantage may dial a VPN and probe from INSIDE the tunnel — the only way
|
|
632
|
+
// to assert VPN reach by real signal instead of by reading a rule string.
|
|
633
|
+
devices: ['/dev/net/tun'],
|
|
531
634
|
environment: observerEnv(p),
|
|
532
635
|
});
|
|
533
636
|
}
|
|
@@ -660,6 +763,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
|
|
|
660
763
|
'./config/dns/celilo.computer.zone:/seed/celilo.computer.zone:ro',
|
|
661
764
|
'./config/dns/example.net.zone:/seed/example.net.zone:ro',
|
|
662
765
|
'./config/dns/park-your-domain.com.zone:/seed/park-your-domain.com.zone:ro',
|
|
766
|
+
'./config/dns/tangohost.com.zone:/seed/tangohost.com.zone:ro',
|
|
663
767
|
],
|
|
664
768
|
});
|
|
665
769
|
|
package/src/network-builder.ts
CHANGED
|
@@ -39,6 +39,9 @@ export class NetworkBuilder {
|
|
|
39
39
|
secureMachines: [],
|
|
40
40
|
internalMachines: [],
|
|
41
41
|
dhcpClient: false,
|
|
42
|
+
signalCli: false,
|
|
43
|
+
signalSim: false,
|
|
44
|
+
signalRelease: false,
|
|
42
45
|
observers: [],
|
|
43
46
|
domain: 'iamtheinternet.org',
|
|
44
47
|
ddnsPassword: 'test123',
|
|
@@ -51,6 +54,17 @@ export class NetworkBuilder {
|
|
|
51
54
|
return this;
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Place the celilo management container in a given zone. Defaults to
|
|
59
|
+
* `internal`; `secure-mgmt` gives celilo-mgr its own control-plane network
|
|
60
|
+
* (the production topology), so a test can exercise celilo reaching and
|
|
61
|
+
* resolving for a fleet from OFF the internal LAN.
|
|
62
|
+
*/
|
|
63
|
+
managementZone(zone: 'internal' | 'secure-mgmt'): this {
|
|
64
|
+
this.config.managementZone = zone;
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
54
68
|
/** Append `{name, ip, zone, ...extra}` for each entry to a zone's machine list. */
|
|
55
69
|
private addMachines(
|
|
56
70
|
target: MachineSpec[],
|
|
@@ -97,6 +111,44 @@ export class NetworkBuilder {
|
|
|
97
111
|
return this;
|
|
98
112
|
}
|
|
99
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Add a real, UNLINKED signal-cli daemon on the internal network.
|
|
116
|
+
*
|
|
117
|
+
* For verifying the JSON-RPC contract against the actual binary. It cannot
|
|
118
|
+
* send to a phone (no account, and CDSI/SVR2 attestation makes a local
|
|
119
|
+
* Signal substitute impossible), but every endpoint, envelope and error
|
|
120
|
+
* shape celilo's client relies on is checkable without one.
|
|
121
|
+
*/
|
|
122
|
+
withSignalCli(): this {
|
|
123
|
+
this.config.signalCli = true;
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Add the signal-cli SIMULATOR on the internal network.
|
|
129
|
+
*
|
|
130
|
+
* Use this for anything that drives the delivery or ack loop: unlike the
|
|
131
|
+
* real daemon it exposes a control surface (`/_control/inbound`,
|
|
132
|
+
* `/_control/sent`, `/_control/unlink`) so a test can make a human "reply"
|
|
133
|
+
* and then assert on what celilo did about it.
|
|
134
|
+
*/
|
|
135
|
+
withSignalSim(): this {
|
|
136
|
+
this.config.signalSim = true;
|
|
137
|
+
return this;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Serve the signal-cli release tarball from the simulated internet.
|
|
142
|
+
*
|
|
143
|
+
* Needed to DEPLOY the signal module: its ansible role downloads signal-cli
|
|
144
|
+
* at deploy time, and the e2e network is sealed. Point the module at it with
|
|
145
|
+
* `module config set signal release_base_url http://signal-release.lab`.
|
|
146
|
+
*/
|
|
147
|
+
withSignalRelease(): this {
|
|
148
|
+
this.config.signalRelease = true;
|
|
149
|
+
return this;
|
|
150
|
+
}
|
|
151
|
+
|
|
100
152
|
/**
|
|
101
153
|
* Inject one or more observer vantages (ISS-0117) — passive spies carrying the probe
|
|
102
154
|
* toolbox, each placed at its vantage's network location with a routing profile that
|
package/src/simulator-ips.ts
CHANGED
|
@@ -35,6 +35,15 @@ export const SIMULATOR_IPS = {
|
|
|
35
35
|
APT_REPO: '100.64.0.60',
|
|
36
36
|
/** MinIO S3-compatible object storage — backup/restore target (replaces AWS S3 in tests). Reachable at http://minio.lab. */
|
|
37
37
|
MINIO: '100.64.0.61',
|
|
38
|
+
/**
|
|
39
|
+
* OFF-FLEET cPanel/SSH web host simulator (`tangohost.com`) — a third-party
|
|
40
|
+
* host celilo publishes to via `external_web` but does NOT govern. A public
|
|
41
|
+
* peer of namecheap-dns/pebble, NOT behind the customer firewall, so public
|
|
42
|
+
* DNS pointing the domain here is a genuine public endpoint.
|
|
43
|
+
*/
|
|
44
|
+
CPANEL_HOST: '100.64.0.63',
|
|
45
|
+
/** signal-cli release host — serves the tarball the signal module downloads at deploy time. */
|
|
46
|
+
SIGNAL_RELEASE: '100.64.0.62',
|
|
38
47
|
/** Pebble ACME server (replaces production Let's Encrypt). */
|
|
39
48
|
PEBBLE: '100.64.0.100',
|
|
40
49
|
} as const;
|
package/src/types.ts
CHANGED
|
@@ -45,6 +45,40 @@ export interface NetworkConfig {
|
|
|
45
45
|
managementVolumes: string[];
|
|
46
46
|
/** Include a DHCP client container on the internal network */
|
|
47
47
|
dhcpClient: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Include a REAL signal-cli daemon on the internal network.
|
|
50
|
+
*
|
|
51
|
+
* Never linked to a Signal account — the JSON-RPC interface is served
|
|
52
|
+
* locally and needs none. It exists so the contract celilo's client depends
|
|
53
|
+
* on is verified against the actual binary rather than against a simulator
|
|
54
|
+
* that encodes the same assumptions. See design.md D16.
|
|
55
|
+
*/
|
|
56
|
+
signalCli: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Include the signal-cli SIMULATOR on the internal network.
|
|
59
|
+
*
|
|
60
|
+
* The drivable counterpart to `signalCli`: it can queue an inbound reply,
|
|
61
|
+
* fail a recipient inside a JSON-RPC success, and revoke the device link on
|
|
62
|
+
* command, which is what a test of the delivery/ack loop needs and what an
|
|
63
|
+
* unlinked real daemon cannot be made to do.
|
|
64
|
+
*/
|
|
65
|
+
signalSim: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Serve the signal-cli release tarball from the simulated internet, so the
|
|
68
|
+
* signal module's deploy-time download resolves inside the sealed network.
|
|
69
|
+
*/
|
|
70
|
+
signalRelease: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Which zone the celilo management container sits in. Defaults to `internal`
|
|
73
|
+
* — the single-network topology every existing test uses.
|
|
74
|
+
*
|
|
75
|
+
* `secure-mgmt` places celilo-mgr on its OWN control-plane network, which is
|
|
76
|
+
* the production topology and the one the suite could not express before. That
|
|
77
|
+
* gap is why the control-plane-network bug reached production: with management
|
|
78
|
+
* always on `internal`, the hardcoded "trusted subnet == network.internal.subnet"
|
|
79
|
+
* assumption was accidentally true in every test.
|
|
80
|
+
*/
|
|
81
|
+
managementZone?: 'internal' | 'secure-mgmt';
|
|
48
82
|
/**
|
|
49
83
|
* Observer vantages to inject into the topology. Each becomes an `observer-*`
|
|
50
84
|
* container — a passive spy carrying the probe tooling, placed at the named
|