@celilo/e2e 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +360 -0
  2. package/bin/cache-docker-images +46 -0
  3. package/bin/e2e-build +66 -0
  4. package/bin/e2e-down +67 -0
  5. package/bin/e2e-infra +92 -0
  6. package/bin/e2e-load +40 -0
  7. package/bin/e2e-run +354 -0
  8. package/bin/e2e-shell +27 -0
  9. package/bin/e2e-status +29 -0
  10. package/bin/e2e-up +160 -0
  11. package/config/dhcp/dnsmasq.conf +27 -0
  12. package/config/dns/com.zone +11 -0
  13. package/config/dns/iamtheinternet.org.zone +15 -0
  14. package/config/dns/knot-namecheap.conf +11 -0
  15. package/config/dns/knot-root.conf +7 -0
  16. package/config/dns/knot-tld.conf +11 -0
  17. package/config/dns/org.zone +14 -0
  18. package/config/dns/park-your-domain.com.zone +10 -0
  19. package/config/dns/root.zone +14 -0
  20. package/config/pebble/pebble-ca.crt +20 -0
  21. package/config/pebble/pebble-config.json +11 -0
  22. package/config/pebble/pebble-tls.crt +21 -0
  23. package/config/pebble/pebble-tls.key +28 -0
  24. package/config/proxy/generate-ca.sh +20 -0
  25. package/config/proxy/squid-ca.crt +22 -0
  26. package/config/proxy/squid-ca.pem +50 -0
  27. package/config/proxy/squid.conf +38 -0
  28. package/config/proxy/startup.sh +11 -0
  29. package/config/resolver/root.hints +2 -0
  30. package/config/resolver/unbound.conf +28 -0
  31. package/config/routing/apt-cache-routes.sh +21 -0
  32. package/config/routing/dhcp-client-setup.sh +17 -0
  33. package/config/routing/dns-routes.sh +12 -0
  34. package/config/routing/fw-ext-routes.sh +50 -0
  35. package/config/routing/fw-isp-routes.sh +37 -0
  36. package/config/routing/fw-main-routes.sh +31 -0
  37. package/config/routing/management-routes.sh +96 -0
  38. package/config/routing/namecheap-startup.sh +17 -0
  39. package/config/routing/pebble-startup.sh +9 -0
  40. package/config/routing/resolver-routes.sh +15 -0
  41. package/config/routing/target-routes.sh +23 -0
  42. package/config/routing/target-setup.service +13 -0
  43. package/config/routing/target-setup.sh +36 -0
  44. package/config/ssh/generate-keys.sh +17 -0
  45. package/docker/Dockerfile.apt-cache +16 -0
  46. package/docker/Dockerfile.authoritative-dns +15 -0
  47. package/docker/Dockerfile.dhcp-client +16 -0
  48. package/docker/Dockerfile.firewall +28 -0
  49. package/docker/Dockerfile.forward-proxy +28 -0
  50. package/docker/Dockerfile.greenwave-sim +29 -0
  51. package/docker/Dockerfile.management +30 -0
  52. package/docker/Dockerfile.namecheap-dns +25 -0
  53. package/docker/Dockerfile.pebble +18 -0
  54. package/docker/Dockerfile.resolver +20 -0
  55. package/docker/Dockerfile.router +33 -0
  56. package/docker/Dockerfile.target-machine +58 -0
  57. package/docker/Dockerfile.target-machine-docker +105 -0
  58. package/package.json +45 -0
  59. package/simulators/greenwave/server.ts +164 -0
  60. package/simulators/greenwave/state.ts +206 -0
  61. package/simulators/greenwave/types.ts +31 -0
  62. package/simulators/namecheap-ddns/server.ts +97 -0
  63. package/simulators/namecheap-ddns/types.ts +12 -0
  64. package/simulators/namecheap-ddns/zone-updater.ts +49 -0
  65. package/src/container-manager.ts +324 -0
  66. package/src/docker-compose-generator.ts +499 -0
  67. package/src/fixtures.ts +40 -0
  68. package/src/index.ts +59 -0
  69. package/src/network-builder.ts +91 -0
  70. package/src/shared-infra.ts +163 -0
  71. package/src/types.ts +74 -0
@@ -0,0 +1,499 @@
1
+ import { stringify } from 'yaml';
2
+ import type { MachineSpec, NetworkConfig, TopologyPreset } from './types';
3
+ import { ZONE_GATEWAYS } from './types';
4
+
5
+ const NETWORK_DRIVER_OPTS = {
6
+ 'com.docker.network.bridge.enable_ip_masquerade': 'false',
7
+ 'com.docker.network.endpoint.sysctls': 'net.ipv4.conf.IFNAME.rp_filter=0',
8
+ };
9
+
10
+ const ROUTER_SYSCTLS = {
11
+ 'net.ipv4.ip_forward': '1',
12
+ 'net.ipv4.conf.all.rp_filter': '0',
13
+ };
14
+
15
+ function networkDef(subnet: string, gateway: string) {
16
+ return {
17
+ driver: 'bridge',
18
+ driver_opts: NETWORK_DRIVER_OPTS,
19
+ ipam: { config: [{ subnet, gateway }] },
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Image tag prefix for pre-built e2e images. When images with these
25
+ * tags exist locally, `docker compose build` skips rebuilding them.
26
+ * The e2e-build script creates these tags; e2e-save/e2e-load persist
27
+ * them across colima restarts.
28
+ */
29
+ const IMAGE_PREFIX = 'celilo-e2e';
30
+
31
+ function imageTag(dockerfile: string): string {
32
+ // docker/Dockerfile.management -> celilo-e2e/management
33
+ const name = dockerfile.replace('docker/Dockerfile.', '');
34
+ return `${IMAGE_PREFIX}/${name}`;
35
+ }
36
+
37
+ function baseService(opts: {
38
+ build?: { context: string; dockerfile: string };
39
+ image?: string;
40
+ networks: Record<string, { ipv4_address: string }>;
41
+ cap_add?: string[];
42
+ sysctls?: Record<string, string>;
43
+ volumes?: string[];
44
+ command?: string;
45
+ depends_on?: string[];
46
+ environment?: Record<string, string>;
47
+ tmpfs?: string[];
48
+ privileged?: boolean;
49
+ }) {
50
+ // Auto-assign an image tag from the Dockerfile name so Docker can
51
+ // skip builds when pre-built images exist.
52
+ const service: Record<string, unknown> = { ...opts, restart: 'unless-stopped' };
53
+ if (opts.build && !opts.image) {
54
+ service.image = imageTag(opts.build.dockerfile);
55
+ }
56
+ return service;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Shared infrastructure project name. Used by both the shared infra
61
+ // manager and per-test compose files (to reference external networks).
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export const SHARED_PROJECT_NAME = 'celilo-e2e-shared';
65
+
66
+ /** Docker network names as created by the shared infrastructure project */
67
+ export const SHARED_NETWORKS = {
68
+ 'isp-external': `${SHARED_PROJECT_NAME}_isp-external`,
69
+ 'internet-external': `${SHARED_PROJECT_NAME}_internet-external`,
70
+ 'real-internet': `${SHARED_PROJECT_NAME}_real-internet`,
71
+ } as const;
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Topology: services south of fw-ext (firewalls, ISP routers)
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Default topology: fw-isp (greenwave) + fw-main (iptables)
79
+ * Two-layer NAT: internet → fw-isp → fw-main → target
80
+ */
81
+ function defaultTopologyServices(): Record<string, unknown> {
82
+ return {
83
+ 'fw-main': baseService({
84
+ build: { context: '.', dockerfile: 'docker/Dockerfile.firewall' },
85
+ networks: {
86
+ internal: { ipv4_address: '192.168.0.254' },
87
+ dmz: { ipv4_address: '10.0.10.1' },
88
+ app: { ipv4_address: '10.0.20.1' },
89
+ secure: { ipv4_address: '10.0.30.1' },
90
+ },
91
+ cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
92
+ sysctls: ROUTER_SYSCTLS,
93
+ volumes: ['ssh-keys:/ssh-keys:ro'],
94
+ }),
95
+ 'fw-isp': baseService({
96
+ build: { context: '.', dockerfile: 'docker/Dockerfile.greenwave-sim' },
97
+ networks: {
98
+ internal: { ipv4_address: '192.168.0.1' },
99
+ 'isp-external': { ipv4_address: '100.100.0.100' },
100
+ },
101
+ cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
102
+ sysctls: ROUTER_SYSCTLS,
103
+ volumes: ['ssh-keys:/ssh-keys:ro'],
104
+ }),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Direct internet topology: fw-main has external interface, no fw-isp
110
+ * Single-layer NAT: internet → fw-main → target
111
+ * Management routes default through fw-main instead of fw-isp
112
+ */
113
+ function directInternetTopologyServices(): Record<string, unknown> {
114
+ return {
115
+ 'fw-main': baseService({
116
+ build: { context: '.', dockerfile: 'docker/Dockerfile.firewall' },
117
+ networks: {
118
+ internal: { ipv4_address: '192.168.0.254' },
119
+ dmz: { ipv4_address: '10.0.10.1' },
120
+ app: { ipv4_address: '10.0.20.1' },
121
+ secure: { ipv4_address: '10.0.30.1' },
122
+ 'isp-external': { ipv4_address: '100.100.0.100' },
123
+ },
124
+ cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
125
+ sysctls: ROUTER_SYSCTLS,
126
+ volumes: ['ssh-keys:/ssh-keys:ro'],
127
+ }),
128
+ // No fw-isp — fw-main handles external routing directly
129
+ };
130
+ }
131
+
132
+ const TOPOLOGY_SERVICES: Record<TopologyPreset, () => Record<string, unknown>> = {
133
+ default: defaultTopologyServices,
134
+ 'direct-internet': directInternetTopologyServices,
135
+ };
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Shared infrastructure compose (DNS, Pebble, apt-cache, fw-ext)
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /**
142
+ * Generate the compose YAML for shared infrastructure that runs once
143
+ * per test suite. Contains: fw-ext, DNS hierarchy, Pebble ACME, apt-cache.
144
+ * Owns the isp-external, internet-external, and real-internet networks.
145
+ */
146
+ export function generateSharedInfraYaml(): string {
147
+ const networks: Record<string, unknown> = {
148
+ 'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
149
+ 'internet-external': networkDef('100.64.0.0/24', '100.64.0.250'),
150
+ 'real-internet': {
151
+ driver: 'bridge',
152
+ ipam: { config: [{ subnet: '172.30.0.0/24' }] },
153
+ },
154
+ };
155
+
156
+ const volumes: Record<string, unknown> = {
157
+ 'apt-cache': { driver: 'local' },
158
+ };
159
+
160
+ const services: Record<string, unknown> = {};
161
+
162
+ services['fw-ext'] = baseService({
163
+ build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
164
+ networks: {
165
+ 'isp-external': { ipv4_address: '100.100.0.101' },
166
+ 'internet-external': { ipv4_address: '100.64.0.1' },
167
+ 'real-internet': { ipv4_address: '172.30.0.5' },
168
+ },
169
+ cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
170
+ sysctls: ROUTER_SYSCTLS,
171
+ });
172
+
173
+ services['comcast-resolver'] = baseService({
174
+ build: { context: '.', dockerfile: 'docker/Dockerfile.resolver' },
175
+ networks: {
176
+ 'isp-external': { ipv4_address: '100.100.0.1' },
177
+ 'real-internet': { ipv4_address: '172.30.0.4' },
178
+ },
179
+ cap_add: ['NET_ADMIN'],
180
+ });
181
+
182
+ services['root-dns'] = baseService({
183
+ build: { context: '.', dockerfile: 'docker/Dockerfile.authoritative-dns' },
184
+ networks: { 'internet-external': { ipv4_address: '100.64.0.53' } },
185
+ cap_add: ['NET_ADMIN'],
186
+ volumes: [
187
+ './config/dns/knot-root.conf:/config/knot.conf:ro',
188
+ './config/dns/root.zone:/config/root.zone:ro',
189
+ ],
190
+ });
191
+
192
+ services['tld-dns'] = baseService({
193
+ build: { context: '.', dockerfile: 'docker/Dockerfile.authoritative-dns' },
194
+ networks: { 'internet-external': { ipv4_address: '100.64.0.54' } },
195
+ cap_add: ['NET_ADMIN'],
196
+ volumes: [
197
+ './config/dns/knot-tld.conf:/config/knot.conf:ro',
198
+ './config/dns/com.zone:/config/com.zone:ro',
199
+ './config/dns/org.zone:/config/org.zone:ro',
200
+ ],
201
+ });
202
+
203
+ services['namecheap-dns'] = baseService({
204
+ build: { context: '.', dockerfile: 'docker/Dockerfile.namecheap-dns' },
205
+ networks: { 'internet-external': { ipv4_address: '100.64.0.55' } },
206
+ cap_add: ['NET_ADMIN'],
207
+ volumes: [
208
+ './config/dns/knot-namecheap.conf:/config/knot.conf:ro',
209
+ './config/dns/iamtheinternet.org.zone:/config/iamtheinternet.org.zone',
210
+ './config/dns/park-your-domain.com.zone:/config/park-your-domain.com.zone',
211
+ ],
212
+ });
213
+
214
+ services['apt-cache'] = baseService({
215
+ build: { context: '.', dockerfile: 'docker/Dockerfile.apt-cache' },
216
+ networks: {
217
+ 'internet-external': { ipv4_address: '100.64.0.2' },
218
+ 'real-internet': { ipv4_address: '172.30.0.2' },
219
+ },
220
+ cap_add: ['NET_ADMIN'],
221
+ volumes: ['apt-cache:/var/cache/apt-cacher-ng'],
222
+ });
223
+
224
+ services.letsencrypt = baseService({
225
+ build: { context: '.', dockerfile: 'docker/Dockerfile.pebble' },
226
+ networks: { 'internet-external': { ipv4_address: '100.64.0.100' } },
227
+ cap_add: ['NET_ADMIN'],
228
+ command: '-config /config/pebble-config.json -dnsserver 100.100.0.1:53',
229
+ volumes: [
230
+ './config/pebble/pebble-config.json:/config/pebble-config.json:ro',
231
+ './config/pebble/pebble-tls.crt:/config/pebble-tls.crt:ro',
232
+ './config/pebble/pebble-tls.key:/config/pebble-tls.key:ro',
233
+ ],
234
+ });
235
+
236
+ const compose = { networks, volumes, services };
237
+ return stringify(compose, { lineWidth: 120 });
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Per-test compose generation
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /**
245
+ * Generate the compose YAML for a single test's containers.
246
+ * References the shared infrastructure's isp-external network as external.
247
+ * Creates its own internal, dmz, app, secure networks.
248
+ */
249
+ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot = '..'): string {
250
+ const networks: Record<string, unknown> = {
251
+ internal: networkDef('192.168.0.0/24', '192.168.0.250'),
252
+ dmz: networkDef('10.0.10.0/24', '10.0.10.250'),
253
+ app: networkDef('10.0.20.0/24', '10.0.20.250'),
254
+ secure: networkDef('10.0.30.0/24', '10.0.30.250'),
255
+ // isp-external is owned by the shared infra project — reference it
256
+ 'isp-external': {
257
+ external: true,
258
+ name: SHARED_NETWORKS['isp-external'],
259
+ },
260
+ };
261
+
262
+ const volumes: Record<string, unknown> = {
263
+ 'ssh-keys': { driver: 'local' },
264
+ };
265
+
266
+ const services: Record<string, unknown> = {};
267
+
268
+ // --- Management (always present) ---
269
+ const managementDefaultGw = config.topology === 'direct-internet' ? '192.168.0.254' : '192.168.0.1';
270
+
271
+ services.management = baseService({
272
+ build: { context: '.', dockerfile: 'docker/Dockerfile.management' },
273
+ networks: { internal: { ipv4_address: '192.168.0.100' } },
274
+ cap_add: ['NET_ADMIN'],
275
+ volumes: [
276
+ `${celiloRoot}:/celilo`,
277
+ 'ssh-keys:/ssh-keys',
278
+ ...(config.managementVolumes ?? []),
279
+ ],
280
+ environment: {
281
+ DDNS_ENDPOINT: 'http://100.64.0.55:8080/update',
282
+ DEFAULT_GATEWAY: managementDefaultGw,
283
+ },
284
+ });
285
+
286
+ // --- Topology-specific infrastructure (south of fw-ext) ---
287
+ const topologyFn = TOPOLOGY_SERVICES[config.topology] || TOPOLOGY_SERVICES.default;
288
+ Object.assign(services, topologyFn());
289
+
290
+ // --- Dynamic test machines ---
291
+ const allMachines = [
292
+ ...config.dmzMachines,
293
+ ...config.appMachines,
294
+ ...config.secureMachines,
295
+ ...config.internalMachines,
296
+ ];
297
+
298
+ for (const machine of allMachines) {
299
+ const zoneNetwork = machine.zone;
300
+ const gateway = ZONE_GATEWAYS[machine.zone];
301
+
302
+ const isAppZone = machine.zone === 'app';
303
+ const dockerfile = isAppZone
304
+ ? 'docker/Dockerfile.target-machine-docker'
305
+ : 'docker/Dockerfile.target-machine';
306
+
307
+ const machineVolumes = ['ssh-keys:/ssh-keys:ro'];
308
+ if (isAppZone) {
309
+ machineVolumes.push('./docker-image-cache:/docker-image-cache:ro');
310
+ }
311
+
312
+ services[machine.name] = baseService({
313
+ build: { context: '.', dockerfile },
314
+ networks: { [zoneNetwork]: { ipv4_address: machine.ip } },
315
+ privileged: true,
316
+ volumes: machineVolumes,
317
+ environment: { GATEWAY: gateway },
318
+ tmpfs: ['/run', '/run/lock', '/tmp'],
319
+ });
320
+ }
321
+
322
+ // --- Optional DHCP client ---
323
+ if (config.dhcpClient) {
324
+ services['dhcp-client'] = baseService({
325
+ build: { context: '.', dockerfile: 'docker/Dockerfile.dhcp-client' },
326
+ networks: { internal: { ipv4_address: '192.168.0.210' } },
327
+ cap_add: ['NET_ADMIN'],
328
+ });
329
+ }
330
+
331
+ const compose = { networks, volumes, services };
332
+ return stringify(compose, { lineWidth: 120 });
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Legacy: monolithic compose (kept for standalone/fallback use)
337
+ // ---------------------------------------------------------------------------
338
+
339
+ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): string {
340
+ const networks: Record<string, unknown> = {
341
+ internal: networkDef('192.168.0.0/24', '192.168.0.250'),
342
+ dmz: networkDef('10.0.10.0/24', '10.0.10.250'),
343
+ app: networkDef('10.0.20.0/24', '10.0.20.250'),
344
+ secure: networkDef('10.0.30.0/24', '10.0.30.250'),
345
+ 'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
346
+ 'internet-external': networkDef('100.64.0.0/24', '100.64.0.250'),
347
+ 'real-internet': {
348
+ driver: 'bridge',
349
+ ipam: { config: [{ subnet: '172.30.0.0/24' }] },
350
+ },
351
+ };
352
+
353
+ const volumes: Record<string, unknown> = {
354
+ 'ssh-keys': { driver: 'local' },
355
+ 'apt-cache': { driver: 'local' },
356
+ };
357
+
358
+ const services: Record<string, unknown> = {};
359
+
360
+ const managementDefaultGw = config.topology === 'direct-internet' ? '192.168.0.254' : '192.168.0.1';
361
+
362
+ services.management = baseService({
363
+ build: { context: '.', dockerfile: 'docker/Dockerfile.management' },
364
+ networks: { internal: { ipv4_address: '192.168.0.100' } },
365
+ cap_add: ['NET_ADMIN'],
366
+ volumes: [
367
+ `${celiloRoot}:/celilo`,
368
+ 'ssh-keys:/ssh-keys',
369
+ ...(config.managementVolumes ?? []),
370
+ ],
371
+ environment: {
372
+ DDNS_ENDPOINT: 'http://100.64.0.55:8080/update',
373
+ DEFAULT_GATEWAY: managementDefaultGw,
374
+ },
375
+ });
376
+
377
+ const topologyFn = TOPOLOGY_SERVICES[config.topology] || TOPOLOGY_SERVICES.default;
378
+ Object.assign(services, topologyFn());
379
+
380
+ services['fw-ext'] = baseService({
381
+ build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
382
+ networks: {
383
+ 'isp-external': { ipv4_address: '100.100.0.101' },
384
+ 'internet-external': { ipv4_address: '100.64.0.1' },
385
+ 'real-internet': { ipv4_address: '172.30.0.5' },
386
+ },
387
+ cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
388
+ sysctls: ROUTER_SYSCTLS,
389
+ });
390
+
391
+ services['comcast-resolver'] = baseService({
392
+ build: { context: '.', dockerfile: 'docker/Dockerfile.resolver' },
393
+ networks: {
394
+ 'isp-external': { ipv4_address: '100.100.0.1' },
395
+ 'real-internet': { ipv4_address: '172.30.0.4' },
396
+ },
397
+ cap_add: ['NET_ADMIN'],
398
+ });
399
+
400
+ services['root-dns'] = baseService({
401
+ build: { context: '.', dockerfile: 'docker/Dockerfile.authoritative-dns' },
402
+ networks: { 'internet-external': { ipv4_address: '100.64.0.53' } },
403
+ cap_add: ['NET_ADMIN'],
404
+ volumes: [
405
+ './config/dns/knot-root.conf:/config/knot.conf:ro',
406
+ './config/dns/root.zone:/config/root.zone:ro',
407
+ ],
408
+ });
409
+
410
+ services['tld-dns'] = baseService({
411
+ build: { context: '.', dockerfile: 'docker/Dockerfile.authoritative-dns' },
412
+ networks: { 'internet-external': { ipv4_address: '100.64.0.54' } },
413
+ cap_add: ['NET_ADMIN'],
414
+ volumes: [
415
+ './config/dns/knot-tld.conf:/config/knot.conf:ro',
416
+ './config/dns/com.zone:/config/com.zone:ro',
417
+ './config/dns/org.zone:/config/org.zone:ro',
418
+ ],
419
+ });
420
+
421
+ services['namecheap-dns'] = baseService({
422
+ build: { context: '.', dockerfile: 'docker/Dockerfile.namecheap-dns' },
423
+ networks: { 'internet-external': { ipv4_address: '100.64.0.55' } },
424
+ cap_add: ['NET_ADMIN'],
425
+ volumes: [
426
+ './config/dns/knot-namecheap.conf:/config/knot.conf:ro',
427
+ './config/dns/iamtheinternet.org.zone:/config/iamtheinternet.org.zone',
428
+ './config/dns/park-your-domain.com.zone:/config/park-your-domain.com.zone',
429
+ ],
430
+ });
431
+
432
+ services['apt-cache'] = baseService({
433
+ build: { context: '.', dockerfile: 'docker/Dockerfile.apt-cache' },
434
+ networks: {
435
+ 'internet-external': { ipv4_address: '100.64.0.2' },
436
+ 'real-internet': { ipv4_address: '172.30.0.2' },
437
+ },
438
+ cap_add: ['NET_ADMIN'],
439
+ volumes: ['apt-cache:/var/cache/apt-cacher-ng'],
440
+ });
441
+
442
+ services.letsencrypt = baseService({
443
+ build: { context: '.', dockerfile: 'docker/Dockerfile.pebble' },
444
+ networks: { 'internet-external': { ipv4_address: '100.64.0.100' } },
445
+ cap_add: ['NET_ADMIN'],
446
+ command: '-config /config/pebble-config.json -dnsserver 100.100.0.1:53',
447
+ volumes: [
448
+ './config/pebble/pebble-config.json:/config/pebble-config.json:ro',
449
+ './config/pebble/pebble-tls.crt:/config/pebble-tls.crt:ro',
450
+ './config/pebble/pebble-tls.key:/config/pebble-tls.key:ro',
451
+ ],
452
+ });
453
+
454
+ const allMachines = [
455
+ ...config.dmzMachines,
456
+ ...config.appMachines,
457
+ ...config.secureMachines,
458
+ ...config.internalMachines,
459
+ ];
460
+
461
+ for (const machine of allMachines) {
462
+ const zoneNetwork = machine.zone;
463
+ const gateway = ZONE_GATEWAYS[machine.zone];
464
+
465
+ const isAppZone = machine.zone === 'app';
466
+ const dockerfile = isAppZone
467
+ ? 'docker/Dockerfile.target-machine-docker'
468
+ : 'docker/Dockerfile.target-machine';
469
+
470
+ const machineVolumes = ['ssh-keys:/ssh-keys:ro'];
471
+ if (isAppZone) {
472
+ machineVolumes.push('./docker-image-cache:/docker-image-cache:ro');
473
+ }
474
+
475
+ services[machine.name] = baseService({
476
+ build: { context: '.', dockerfile },
477
+ networks: { [zoneNetwork]: { ipv4_address: machine.ip } },
478
+ privileged: true,
479
+ volumes: machineVolumes,
480
+ environment: { GATEWAY: gateway },
481
+ tmpfs: ['/run', '/run/lock', '/tmp'],
482
+ });
483
+ }
484
+
485
+ if (config.dhcpClient) {
486
+ services['dhcp-client'] = baseService({
487
+ build: { context: '.', dockerfile: 'docker/Dockerfile.dhcp-client' },
488
+ networks: { internal: { ipv4_address: '192.168.0.210' } },
489
+ cap_add: ['NET_ADMIN'],
490
+ });
491
+ }
492
+
493
+ const compose = { networks, volumes, services };
494
+ return stringify(compose, { lineWidth: 120 });
495
+ }
496
+
497
+ export function getAllMachines(config: NetworkConfig): MachineSpec[] {
498
+ return [...config.dmzMachines, ...config.appMachines, ...config.secureMachines, ...config.internalMachines];
499
+ }
@@ -0,0 +1,40 @@
1
+ import { NetworkBuilder, network } from './network-builder';
2
+
3
+ /** Single caddy machine in the DMZ — basic web server deployment */
4
+ export const CADDY_DEPLOYMENT = (): NetworkBuilder => network().dmz({ caddy: '10.0.10.10' });
5
+
6
+ /** Full stack: web server, identity provider, database */
7
+ export const FULL_STACK = (): NetworkBuilder =>
8
+ network()
9
+ .dmz({ caddy: '10.0.10.10' })
10
+ .app({ idp: '10.0.20.100' })
11
+ .secure({ db: '10.0.30.50' });
12
+
13
+ /** Minimal: no dynamic machines, just fixed infrastructure */
14
+ export const INFRASTRUCTURE_ONLY = (): NetworkBuilder => network();
15
+
16
+ /** Caddy + Authentik: web server in DMZ, identity provider in app zone */
17
+ export const AUTHENTIK_DEPLOYMENT = (): NetworkBuilder =>
18
+ network()
19
+ .dmz({ caddy: '10.0.10.10' })
20
+ .app({ authentik: '10.0.20.30' });
21
+
22
+ /** Multiple DMZ machines for testing multi-service deployments */
23
+ export const MULTI_DMZ = (): NetworkBuilder =>
24
+ network().dmz({
25
+ caddy: '10.0.10.10',
26
+ 'web-app': '10.0.10.20',
27
+ });
28
+
29
+ /** Technitium DNS + Caddy: internal DNS resolver + full web stack */
30
+ export const TECHNITIUM_STACK = (): NetworkBuilder =>
31
+ network()
32
+ .internal({ 'dns-int': '192.168.0.53' })
33
+ .dmz({ caddy: '10.0.10.10' });
34
+
35
+ /** Technitium DNS with DHCP client: tests DHCP DNS propagation */
36
+ export const TECHNITIUM_DHCP_STACK = (): NetworkBuilder =>
37
+ network()
38
+ .internal({ 'dns-int': '192.168.0.53' })
39
+ .dmz({ caddy: '10.0.10.10' })
40
+ .withDhcpClient();
package/src/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @celilo/e2e — E2E test infrastructure for Celilo-deployed applications.
3
+ *
4
+ * Provides a simulated internet environment in Docker with:
5
+ * - DNS hierarchy (root → TLD → registrar → recursive resolver)
6
+ * - ACME server (Pebble, simulating Let's Encrypt)
7
+ * - APT cache (speeds up package installs in containers)
8
+ * - Firewall topology (ISP router, main firewall, zone routing)
9
+ * - Target machines (systemd-based containers for deployment)
10
+ *
11
+ * Usage:
12
+ * import { network } from '@celilo/e2e';
13
+ *
14
+ * const net = await network()
15
+ * .dmz({ caddy: '10.0.10.10' })
16
+ * .app({ myapp: '10.0.20.10' })
17
+ * .start();
18
+ *
19
+ * await net.celilo('module deploy myapp --no-interactive');
20
+ * // ... assertions ...
21
+ * await net.stop();
22
+ */
23
+
24
+ // Builder API
25
+ export { network, NetworkBuilder } from './network-builder';
26
+
27
+ // Types
28
+ export type {
29
+ NetworkConfig,
30
+ NetworkHandle,
31
+ ExecResult,
32
+ MachineSpec,
33
+ Zone,
34
+ TopologyPreset,
35
+ } from './types';
36
+ export { ZONE_GATEWAYS, ZONE_SUBNETS } from './types';
37
+
38
+ // Common fixtures
39
+ export {
40
+ CADDY_DEPLOYMENT,
41
+ FULL_STACK,
42
+ INFRASTRUCTURE_ONLY,
43
+ AUTHENTIK_DEPLOYMENT,
44
+ MULTI_DMZ,
45
+ TECHNITIUM_STACK,
46
+ TECHNITIUM_DHCP_STACK,
47
+ } from './fixtures';
48
+
49
+ // Shared infrastructure management (for test runners)
50
+ export { ensureSharedInfra, stopSharedInfra, isSharedInfraRunning } from './shared-infra';
51
+
52
+ // Low-level (for custom compose generation)
53
+ export {
54
+ generateSharedInfraYaml,
55
+ generateTestComposeYaml,
56
+ generateComposeYaml,
57
+ SHARED_PROJECT_NAME,
58
+ SHARED_NETWORKS,
59
+ } from './docker-compose-generator';
@@ -0,0 +1,91 @@
1
+ import { startNetwork } from './container-manager';
2
+ import type { NetworkConfig, NetworkHandle, TopologyPreset } from './types';
3
+
4
+ export class NetworkBuilder {
5
+ private config: NetworkConfig = {
6
+ topology: 'default',
7
+ dmzMachines: [],
8
+ appMachines: [],
9
+ secureMachines: [],
10
+ internalMachines: [],
11
+ dhcpClient: false,
12
+ domain: 'iamtheinternet.org',
13
+ ddnsPassword: 'test123',
14
+ verifyRouting: process.env.VERIFY_ROUTING === '1',
15
+ managementVolumes: [],
16
+ };
17
+
18
+ topology(preset: TopologyPreset): this {
19
+ this.config.topology = preset;
20
+ return this;
21
+ }
22
+
23
+ dmz(machines: Record<string, string>): this {
24
+ for (const [name, ip] of Object.entries(machines)) {
25
+ this.config.dmzMachines.push({ name, ip, zone: 'dmz' });
26
+ }
27
+ return this;
28
+ }
29
+
30
+ app(machines: Record<string, string>): this {
31
+ for (const [name, ip] of Object.entries(machines)) {
32
+ this.config.appMachines.push({ name, ip, zone: 'app' });
33
+ }
34
+ return this;
35
+ }
36
+
37
+ secure(machines: Record<string, string>): this {
38
+ for (const [name, ip] of Object.entries(machines)) {
39
+ this.config.secureMachines.push({ name, ip, zone: 'secure' });
40
+ }
41
+ return this;
42
+ }
43
+
44
+ internal(machines: Record<string, string>): this {
45
+ for (const [name, ip] of Object.entries(machines)) {
46
+ this.config.internalMachines.push({ name, ip, zone: 'internal' });
47
+ }
48
+ return this;
49
+ }
50
+
51
+ withDhcpClient(): this {
52
+ this.config.dhcpClient = true;
53
+ return this;
54
+ }
55
+
56
+ domain(domain: string): this {
57
+ this.config.domain = domain;
58
+ return this;
59
+ }
60
+
61
+ withRoutingVerification(): this {
62
+ this.config.verifyRouting = true;
63
+ return this;
64
+ }
65
+
66
+ /** Mount a host directory into the management container */
67
+ mount(hostPath: string, containerPath: string): this {
68
+ this.config.managementVolumes.push(`${hostPath}:${containerPath}`);
69
+ return this;
70
+ }
71
+
72
+ /**
73
+ * Set the path to the Celilo project root. This directory is mounted
74
+ * into the management container at /celilo, providing access to the
75
+ * Celilo CLI and module source code.
76
+ *
77
+ * If not set, auto-detected by walking up from the @celilo/e2e package.
78
+ */
79
+ celilo(rootPath: string): this {
80
+ this.config.celiloRoot = rootPath;
81
+ return this;
82
+ }
83
+
84
+ async start(): Promise<NetworkHandle> {
85
+ return startNetwork(this.config);
86
+ }
87
+ }
88
+
89
+ export function network(): NetworkBuilder {
90
+ return new NetworkBuilder();
91
+ }