@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,164 @@
1
+ /**
2
+ * Greenwave C4000XG Router Simulator
3
+ *
4
+ * Implements the REST API subset used by celilo's greenwave module:
5
+ * POST /cgi/cgi_action — Login/logout
6
+ * GET /cgi/cgi_get — Read config objects (public IP, port mappings)
7
+ * POST /cgi/cgi_set — Add/modify port forwarding rules
8
+ *
9
+ * Port forwards are actually applied via iptables, making them
10
+ * functional in the simulated Docker network.
11
+ */
12
+
13
+ import {
14
+ addPortMapping,
15
+ getDhcpPool,
16
+ getPublicIp,
17
+ isAuthenticated,
18
+ listPortMappings,
19
+ login,
20
+ logout,
21
+ setDhcpPool,
22
+ } from './state';
23
+ import type { CgiResponse } from './types';
24
+
25
+ const PORT = 443;
26
+
27
+ function getSessionId(req: Request): string | null {
28
+ const cookie = req.headers.get('cookie') || '';
29
+ const match = cookie.match(/Session-Id=([^;]+)/);
30
+ return match ? match[1] : null;
31
+ }
32
+
33
+ function parseUrlEncoded(body: string): Record<string, string> {
34
+ const params: Record<string, string> = {};
35
+ for (const pair of body.split('&')) {
36
+ const [key, ...rest] = pair.split('=');
37
+ if (key) params[decodeURIComponent(key)] = decodeURIComponent(rest.join('='));
38
+ }
39
+ return params;
40
+ }
41
+
42
+ function jsonResponse(data: CgiResponse, status = 200): Response {
43
+ return Response.json(data, { status });
44
+ }
45
+
46
+ // Generate self-signed TLS cert for HTTPS
47
+ const tlsOptions = (() => {
48
+ try {
49
+ const { execSync } = require('node:child_process');
50
+ const { readFileSync, existsSync } = require('node:fs');
51
+
52
+ if (!existsSync('/tmp/router-cert.pem')) {
53
+ execSync(
54
+ 'openssl req -x509 -newkey rsa:2048 -keyout /tmp/router-key.pem -out /tmp/router-cert.pem -days 365 -nodes -subj "/CN=router.local"',
55
+ { timeout: 10000 },
56
+ );
57
+ }
58
+
59
+ return {
60
+ cert: readFileSync('/tmp/router-cert.pem'),
61
+ key: readFileSync('/tmp/router-key.pem'),
62
+ };
63
+ } catch {
64
+ console.warn('[greenwave] Failed to generate TLS cert, falling back to HTTP');
65
+ return undefined;
66
+ }
67
+ })();
68
+
69
+ // Bind to internal interface only (like a real router's management UI)
70
+ // The external interface should only handle port-forwarded traffic via iptables
71
+ const BIND_HOST = process.env.ROUTER_BIND_HOST || '192.168.0.1';
72
+
73
+ const server = Bun.serve({
74
+ port: PORT,
75
+ hostname: BIND_HOST,
76
+ tls: tlsOptions,
77
+
78
+ async fetch(req) {
79
+ const url = new URL(req.url);
80
+
81
+ // --- POST /cgi/cgi_action (login/logout) ---
82
+ if (url.pathname === '/cgi/cgi_action' && req.method === 'POST') {
83
+ const body = await req.text();
84
+ const params = parseUrlEncoded(body);
85
+
86
+ // Logout
87
+ if (params.logout === 'true') {
88
+ const sessionId = getSessionId(req);
89
+ if (sessionId) logout(sessionId);
90
+ return new Response('OK', { status: 200 });
91
+ }
92
+
93
+ // Login
94
+ const { username, password } = params;
95
+ if (!username || !password) {
96
+ return new Response('Missing credentials', { status: 400 });
97
+ }
98
+
99
+ const sessionId = login(username, password);
100
+ if (!sessionId) {
101
+ return new Response('Invalid credentials', { status: 444 });
102
+ }
103
+
104
+ return new Response(JSON.stringify({ SessionId: sessionId }), {
105
+ status: 200,
106
+ headers: {
107
+ 'Content-Type': 'application/json',
108
+ 'Set-Cookie': `Session-Id=${sessionId}; Path=/`,
109
+ },
110
+ });
111
+ }
112
+
113
+ // --- GET /cgi/cgi_get (read objects) ---
114
+ if (url.pathname === '/cgi/cgi_get' && req.method === 'GET') {
115
+ const sessionId = getSessionId(req);
116
+ if (!isAuthenticated(sessionId)) {
117
+ return new Response('Unauthorized', { status: 403 });
118
+ }
119
+
120
+ const objectPath = url.searchParams.get('Object') || '';
121
+
122
+ if (objectPath.startsWith('Device.IP.Interface.3.IPv4Address')) {
123
+ return jsonResponse({ Objects: [getPublicIp()] });
124
+ }
125
+
126
+ if (objectPath.startsWith('Device.NAT.PortMapping')) {
127
+ return jsonResponse({ Objects: listPortMappings() });
128
+ }
129
+
130
+ if (objectPath.startsWith('Device.DHCPv4.Server.Pool.1')) {
131
+ return jsonResponse({ Objects: [getDhcpPool()] });
132
+ }
133
+
134
+ return jsonResponse({ Objects: [] });
135
+ }
136
+
137
+ // --- POST /cgi/cgi_set (modify objects) ---
138
+ if (url.pathname === '/cgi/cgi_set' && req.method === 'POST') {
139
+ const sessionId = getSessionId(req);
140
+ if (!isAuthenticated(sessionId)) {
141
+ return new Response('Unauthorized', { status: 403 });
142
+ }
143
+
144
+ const body = await req.text();
145
+ const params = parseUrlEncoded(body);
146
+
147
+ if (params.Operation === 'Add' && params.Object?.startsWith('Device.NAT.PortMapping')) {
148
+ const result = addPortMapping(params);
149
+ return jsonResponse({ Objects: result });
150
+ }
151
+
152
+ if (params.Object?.startsWith('Device.DHCPv4.Server.Pool.1')) {
153
+ const result = setDhcpPool(params);
154
+ return jsonResponse({ Objects: result });
155
+ }
156
+
157
+ return jsonResponse({ Objects: [] });
158
+ }
159
+
160
+ return new Response('Not Found', { status: 404 });
161
+ },
162
+ });
163
+
164
+ console.log(`[greenwave] C4000XG simulator listening on ${BIND_HOST}:${server.port} (${tlsOptions ? 'HTTPS' : 'HTTP'})`);
@@ -0,0 +1,206 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { writeFileSync } from 'node:fs';
3
+ import type { CgiObject, CgiParam, PortMapping, Session } from './types';
4
+
5
+ const VALID_USERNAME = process.env.ROUTER_USERNAME || 'admin';
6
+ const VALID_PASSWORD = process.env.ROUTER_PASSWORD || 'admin';
7
+ const PUBLIC_IP = process.env.ROUTER_PUBLIC_IP || '100.100.0.100';
8
+
9
+ // The network interface facing the ISP (isp-external network)
10
+ const EXTERNAL_INTERFACE = process.env.EXTERNAL_INTERFACE || 'eth1';
11
+
12
+ const sessions = new Map<string, Session>();
13
+ const portMappings: PortMapping[] = [];
14
+ let nextIndex = 1;
15
+
16
+ // DHCP pool state
17
+ const dhcpPool: Record<string, string> = {
18
+ Alias: 'cpe-Pool-1',
19
+ Enable: 'true',
20
+ MinAddress: '192.168.0.2',
21
+ MaxAddress: '192.168.0.150',
22
+ SubnetMask: '255.255.255.0',
23
+ IPRouters: '192.168.0.1',
24
+ DNSServers: '192.168.0.1',
25
+ DomainName: '',
26
+ LeaseTime: '3600',
27
+ Status: 'Enabled',
28
+ };
29
+
30
+ export function login(username: string, password: string): string | null {
31
+ if (username !== VALID_USERNAME || password !== VALID_PASSWORD) {
32
+ return null;
33
+ }
34
+ const sessionId = `sim-${Date.now()}-${Math.random().toString(36).slice(2)}`;
35
+ sessions.set(sessionId, { username, createdAt: Date.now() });
36
+ return sessionId;
37
+ }
38
+
39
+ export function logout(sessionId: string): void {
40
+ sessions.delete(sessionId);
41
+ }
42
+
43
+ export function isAuthenticated(sessionId: string | null): boolean {
44
+ if (!sessionId) return false;
45
+ return sessions.has(sessionId);
46
+ }
47
+
48
+ export function getPublicIp(): CgiObject {
49
+ return {
50
+ ObjName: 'Device.IP.Interface.3.IPv4Address.1.',
51
+ Param: [{ ParamName: 'IPAddress', ParamValue: PUBLIC_IP }],
52
+ };
53
+ }
54
+
55
+ export function listPortMappings(): CgiObject[] {
56
+ return portMappings.map((pm) => ({
57
+ ObjName: pm.objName,
58
+ Param: mappingToParams(pm),
59
+ }));
60
+ }
61
+
62
+ export function addPortMapping(params: Record<string, string>): CgiObject[] {
63
+ const index = nextIndex++;
64
+ const objName = `Device.NAT.PortMapping.${index}.`;
65
+
66
+ const mapping: PortMapping = {
67
+ objName,
68
+ externalPort: params.ExternalPort || '',
69
+ externalPortEndRange: params.ExternalPortEndRange || params.ExternalPort || '',
70
+ internalPort: params.InternalPort || '',
71
+ internalClient: params.InternalClient || '',
72
+ enable: params.Enable || '1',
73
+ description: params.Description || '',
74
+ protocol: params.Protocol || 'TCP',
75
+ via: params.X_GWS_Via || 'UI',
76
+ remoteHost: params.RemoteHost || '',
77
+ };
78
+
79
+ portMappings.push(mapping);
80
+
81
+ // Actually apply the iptables rule so port forwarding works
82
+ applyIptablesRule(mapping);
83
+
84
+ console.log(
85
+ `[greenwave] Added port forward: ${mapping.externalPort} -> ${mapping.internalClient}:${mapping.internalPort} (${mapping.protocol})`,
86
+ );
87
+
88
+ return [{ ObjName: objName, Param: mappingToParams(mapping) }];
89
+ }
90
+
91
+ export function getDhcpPool(): CgiObject {
92
+ const params: CgiParam[] = Object.entries(dhcpPool).map(([key, value]) => ({
93
+ ParamName: key,
94
+ ParamValue: value,
95
+ }));
96
+ return {
97
+ ObjName: 'Device.DHCPv4.Server.Pool.1.',
98
+ Param: params,
99
+ };
100
+ }
101
+
102
+ export function setDhcpPool(params: Record<string, string>): CgiObject[] {
103
+ let dhcpChanged = false;
104
+ for (const [key, value] of Object.entries(params)) {
105
+ if (key === 'Object' || key === 'Operation') continue;
106
+ if (key in dhcpPool) {
107
+ dhcpPool[key] = value;
108
+ console.log(`[greenwave] DHCP pool: ${key} = ${value}`);
109
+ if (key === 'DNSServers' || key === 'DomainName') {
110
+ dhcpChanged = true;
111
+ }
112
+ }
113
+ }
114
+
115
+ // Update running dnsmasq when DNS/domain settings change
116
+ if (dhcpChanged) {
117
+ rewriteDnsmasqConfig();
118
+ }
119
+
120
+ return [{ ObjName: 'Device.DHCPv4.Server.Pool.1.', Param: [{ ParamName: 'Success', ParamValue: 'Success' }] }];
121
+ }
122
+
123
+ function rewriteDnsmasqConfig(): void {
124
+ const dnsServers = dhcpPool.DNSServers || '192.168.0.1';
125
+ const domainName = dhcpPool.DomainName || '';
126
+
127
+ const lines = [
128
+ '# Auto-generated by greenwave simulator',
129
+ 'port=0',
130
+ 'interface=eth0',
131
+ 'bind-interfaces',
132
+ 'dhcp-range=192.168.0.200,192.168.0.220,255.255.255.0,60s',
133
+ 'dhcp-option=option:router,192.168.0.1',
134
+ `dhcp-option=option:dns-server,${dnsServers}`,
135
+ 'dhcp-lease-max=20',
136
+ 'log-dhcp',
137
+ ];
138
+
139
+ if (domainName) {
140
+ lines.push(`dhcp-option=option:domain-name,${domainName}`);
141
+ }
142
+
143
+ try {
144
+ writeFileSync('/etc/dnsmasq.d/e2e.conf', `${lines.join('\n')}\n`);
145
+ // dnsmasq requires full restart to re-read config files (SIGHUP only reloads /etc/hosts)
146
+ try {
147
+ execSync('pkill -9 dnsmasq; sleep 0.5; dnsmasq --conf-dir=/etc/dnsmasq.d --log-facility=/dev/stderr', {
148
+ timeout: 5000,
149
+ shell: '/bin/bash',
150
+ });
151
+ } catch {}
152
+ console.log(`[greenwave] dnsmasq restarted: DNS=${dnsServers}, domain=${domainName}`);
153
+ } catch (err) {
154
+ console.error('[greenwave] Failed to update dnsmasq:', err);
155
+ }
156
+ }
157
+
158
+ function applyIptablesRule(mapping: PortMapping): void {
159
+ const proto = mapping.protocol === 'TCP/UDP' ? 'tcp' : mapping.protocol.toLowerCase();
160
+ const extPort = mapping.externalPort;
161
+ const intIp = mapping.internalClient;
162
+ const intPort = mapping.internalPort;
163
+
164
+ try {
165
+ // DNAT incoming traffic on external interface destined for our public IP
166
+ execSync(
167
+ `iptables -t nat -A PREROUTING -i ${EXTERNAL_INTERFACE} -d ${PUBLIC_IP} -p ${proto} --dport ${extPort} -j DNAT --to-destination ${intIp}:${intPort}`,
168
+ { timeout: 5000 },
169
+ );
170
+ // Allow forwarded traffic
171
+ execSync(
172
+ `iptables -A FORWARD -p ${proto} -d ${intIp} --dport ${intPort} -j ACCEPT`,
173
+ { timeout: 5000 },
174
+ );
175
+
176
+ // If protocol is TCP/UDP, also add UDP rules
177
+ if (mapping.protocol === 'TCP/UDP') {
178
+ execSync(
179
+ `iptables -t nat -A PREROUTING -i ${EXTERNAL_INTERFACE} -d ${PUBLIC_IP} -p udp --dport ${extPort} -j DNAT --to-destination ${intIp}:${intPort}`,
180
+ { timeout: 5000 },
181
+ );
182
+ execSync(
183
+ `iptables -A FORWARD -p udp -d ${intIp} --dport ${intPort} -j ACCEPT`,
184
+ { timeout: 5000 },
185
+ );
186
+ }
187
+ } catch (err) {
188
+ console.error(`[greenwave] Failed to apply iptables rule:`, err);
189
+ }
190
+ }
191
+
192
+ function mappingToParams(pm: PortMapping): CgiParam[] {
193
+ return [
194
+ { ParamName: 'ExternalPort', ParamValue: pm.externalPort },
195
+ { ParamName: 'ExternalPortEndRange', ParamValue: pm.externalPortEndRange },
196
+ { ParamName: 'InternalPort', ParamValue: pm.internalPort },
197
+ { ParamName: 'InternalClient', ParamValue: pm.internalClient },
198
+ { ParamName: 'Enable', ParamValue: pm.enable },
199
+ { ParamName: 'Description', ParamValue: pm.description },
200
+ { ParamName: 'Protocol', ParamValue: pm.protocol },
201
+ { ParamName: 'X_GWS_Via', ParamValue: pm.via },
202
+ { ParamName: 'RemoteHost', ParamValue: pm.remoteHost },
203
+ { ParamName: 'X_LANTIQ_COM_INTERFACE', ParamValue: 'wan' },
204
+ { ParamName: 'AllInterfaces', ParamValue: '0' },
205
+ ];
206
+ }
@@ -0,0 +1,31 @@
1
+ export interface PortMapping {
2
+ objName: string;
3
+ externalPort: string;
4
+ externalPortEndRange: string;
5
+ internalPort: string;
6
+ internalClient: string;
7
+ enable: string;
8
+ description: string;
9
+ protocol: string;
10
+ via: string;
11
+ remoteHost: string;
12
+ }
13
+
14
+ export interface Session {
15
+ username: string;
16
+ createdAt: number;
17
+ }
18
+
19
+ export interface CgiParam {
20
+ ParamName: string;
21
+ ParamValue: string;
22
+ }
23
+
24
+ export interface CgiObject {
25
+ ObjName: string;
26
+ Param: CgiParam[];
27
+ }
28
+
29
+ export interface CgiResponse {
30
+ Objects: CgiObject[];
31
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Namecheap Dynamic DNS Simulator
3
+ *
4
+ * Mimics the real Namecheap DDNS endpoint:
5
+ * GET /update?host=<host>&domain=<domain>&password=<password>&ip=<ip>
6
+ *
7
+ * On success, updates the Knot zone file and reloads the zone.
8
+ * Returns XML matching the real Namecheap response format.
9
+ */
10
+
11
+ import { updateZone } from './zone-updater';
12
+ import type { DdnsRecord } from './types';
13
+
14
+ const VALID_PASSWORD = process.env.DDNS_PASSWORD || 'test123';
15
+ const PORT = Number(process.env.DDNS_PORT) || 8080;
16
+
17
+ // Track registered records for debugging
18
+ const records: Map<string, DdnsRecord> = new Map();
19
+
20
+ function xmlResponse(errCount: number, ip: string, errors: string[]): Response {
21
+ const errorElements = errors
22
+ .map((e, i) => `<Err${i + 1}>${e}</Err${i + 1}>`)
23
+ .join('\n ');
24
+
25
+ const xml = `<?xml version="1.0" encoding="utf-16"?>
26
+ <interface-response>
27
+ <Command>SETDNSHOST</Command>
28
+ <Language>eng</Language>
29
+ <IP>${ip}</IP>
30
+ <ErrCount>${errCount}</ErrCount>
31
+ ${errorElements}
32
+ <ResponseCount>0</ResponseCount>
33
+ <Done>true</Done>
34
+ <debug><![CDATA[]]></debug>
35
+ </interface-response>`;
36
+
37
+ return new Response(xml, {
38
+ status: 200,
39
+ headers: { 'Content-Type': 'application/xml' },
40
+ });
41
+ }
42
+
43
+ function getSourceIp(req: Request): string {
44
+ // In Docker, the source IP comes through headers or we fall back to a default
45
+ const forwarded = req.headers.get('x-forwarded-for');
46
+ if (forwarded) return forwarded.split(',')[0].trim();
47
+
48
+ // Bun's server provides the remote address via the second argument to fetch
49
+ // but it's not easily accessible here. Default to returning a placeholder
50
+ // that callers should override via the ip= parameter.
51
+ return '0.0.0.0';
52
+ }
53
+
54
+ const server = Bun.serve({
55
+ port: PORT,
56
+ fetch(req) {
57
+ const url = new URL(req.url);
58
+
59
+ if (url.pathname === '/update') {
60
+ const host = url.searchParams.get('host');
61
+ const domain = url.searchParams.get('domain');
62
+ const password = url.searchParams.get('password');
63
+ const ip = url.searchParams.get('ip') || getSourceIp(req);
64
+
65
+ if (!host || !domain) {
66
+ return xmlResponse(1, '', ['Missing required parameters: host, domain']);
67
+ }
68
+
69
+ if (password !== VALID_PASSWORD) {
70
+ return xmlResponse(1, ip, ['Passwords do not match']);
71
+ }
72
+
73
+ try {
74
+ updateZone(domain, host, ip);
75
+ records.set(`${host}.${domain}`, { host, ip, updatedAt: new Date() });
76
+ console.log(`[ddns] Updated ${host}.${domain} -> ${ip}`);
77
+ return xmlResponse(0, ip, []);
78
+ } catch (err) {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ console.error(`[ddns] Failed to update zone: ${msg}`);
81
+ return xmlResponse(1, ip, [`Zone update failed: ${msg}`]);
82
+ }
83
+ }
84
+
85
+ // Health check / status endpoint
86
+ if (url.pathname === '/status') {
87
+ const status = Object.fromEntries(
88
+ [...records.entries()].map(([k, v]) => [k, { ip: v.ip, updatedAt: v.updatedAt }]),
89
+ );
90
+ return Response.json({ ok: true, records: status });
91
+ }
92
+
93
+ return new Response('Not Found', { status: 404 });
94
+ },
95
+ });
96
+
97
+ console.log(`[ddns] Namecheap DDNS simulator listening on port ${server.port}`);
@@ -0,0 +1,12 @@
1
+ export interface DdnsUpdateRequest {
2
+ host: string;
3
+ domain: string;
4
+ password: string;
5
+ ip: string | null;
6
+ }
7
+
8
+ export interface DdnsRecord {
9
+ host: string;
10
+ ip: string;
11
+ updatedAt: Date;
12
+ }
@@ -0,0 +1,49 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+
4
+ const ZONE_DIR = '/config';
5
+
6
+ /**
7
+ * Update an A record in a Knot zone file and reload.
8
+ *
9
+ * If the host already has an A record, it's replaced.
10
+ * If not, a new one is appended before the end of the file.
11
+ * The SOA serial is incremented on every update.
12
+ */
13
+ export function updateZone(domain: string, host: string, ip: string): void {
14
+ const zoneFile = `${ZONE_DIR}/${domain}.zone`;
15
+ let content = readFileSync(zoneFile, 'utf-8');
16
+
17
+ // Increment SOA serial (YYYYMMDDnn format — just increment the number)
18
+ content = content.replace(
19
+ /(\d{10})(\s+\d+\s+\d+\s+\d+\s+\d+\s*\))/,
20
+ (_match, serial, rest) => {
21
+ const newSerial = String(Number(serial) + 1);
22
+ return `${newSerial}${rest}`;
23
+ },
24
+ );
25
+
26
+ // Check if host already has an A record
27
+ const recordRegex = new RegExp(`^${escapeRegex(host)}\\s+IN\\s+A\\s+[\\d.]+`, 'm');
28
+
29
+ if (recordRegex.test(content)) {
30
+ // Replace existing record
31
+ content = content.replace(recordRegex, `${host} IN A ${ip}`);
32
+ } else {
33
+ // Append new record
34
+ content = content.trimEnd() + `\n${host} IN A ${ip}\n`;
35
+ }
36
+
37
+ writeFileSync(zoneFile, content);
38
+
39
+ // Reload the zone in Knot
40
+ try {
41
+ execSync(`knotc zone-reload ${domain}`, { timeout: 5000 });
42
+ } catch (err) {
43
+ console.error(`Failed to reload zone ${domain}:`, err);
44
+ }
45
+ }
46
+
47
+ function escapeRegex(str: string): string {
48
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
49
+ }