@serfab/cadre-host 0.10.0 → 0.11.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/dist/server/static.js +38 -38
- package/dist/ui/assets/index-C-iks41M.js.map +1 -1
- package/dist/ui/index.html +12 -12
- package/package.json +4 -4
- package/service/cadre-host.service.tmpl +16 -16
- package/service/com.serfab.cadre-host.plist.tmpl +35 -35
- package/service/install-service.ps1 +98 -98
- package/service/uninstall-service.ps1 +19 -19
- package/src/__tests__/build-targets.test.ts +26 -26
- package/src/__tests__/cli-invite.smoke.test.ts +192 -192
- package/src/__tests__/cli-nat.smoke.test.ts +212 -212
- package/src/__tests__/cli.smoke.test.ts +46 -46
- package/src/__tests__/global-setup.ts +51 -51
- package/src/__tests__/host-owner.smoke.test.ts +123 -123
- package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
- package/src/__tests__/orchestrator-node-identity.test.ts +204 -204
- package/src/__tests__/orchestrator-owner.test.ts +198 -198
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
- package/src/__tests__/orchestrator-push.test.ts +145 -145
- package/src/__tests__/orchestrator.test.ts +951 -951
- package/src/auth/__tests__/duration.test.ts +28 -28
- package/src/auth/__tests__/self-label.test.ts +85 -85
- package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
- package/src/auth/__tests__/trust-circle.test.ts +445 -445
- package/src/auth/duration.ts +33 -33
- package/src/auth/index.ts +21 -21
- package/src/auth/self-label.ts +74 -74
- package/src/auth/trust-circle-store.ts +155 -155
- package/src/auth/types.ts +99 -99
- package/src/donation/__tests__/donation-service.test.ts +1068 -1068
- package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
- package/src/donation/__tests__/fake-orchestrator.ts +225 -225
- package/src/donation/__tests__/grant-service.test.ts +213 -213
- package/src/donation/__tests__/grant-store.test.ts +113 -113
- package/src/donation/donation-service.ts +962 -962
- package/src/donation/donation-store.ts +171 -171
- package/src/donation/donation-supervisor.ts +388 -388
- package/src/donation/grant-service.ts +200 -200
- package/src/donation/grant-store.ts +152 -152
- package/src/donation/index.ts +64 -64
- package/src/donation/types.ts +243 -243
- package/src/installer/__tests__/identity.test.ts +34 -34
- package/src/installer/__tests__/paths.test.ts +47 -47
- package/src/installer/__tests__/service-host.test.ts +62 -62
- package/src/installer/__tests__/wizard.test.ts +64 -64
- package/src/installer/identity.ts +49 -49
- package/src/installer/paths.ts +109 -109
- package/src/installer/platform.ts +27 -27
- package/src/installer/service-host/index.ts +21 -21
- package/src/installer/service-host/template.ts +21 -21
- package/src/installer/wizard.ts +103 -103
- package/src/nat/__tests__/address-resolver.test.ts +82 -82
- package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
- package/src/nat/__tests__/ddns-updater.test.ts +253 -253
- package/src/nat/__tests__/external-ip.test.ts +124 -124
- package/src/nat/__tests__/nat-service.test.ts +497 -497
- package/src/nat/__tests__/nat-store.test.ts +93 -93
- package/src/nat/__tests__/port-mapper.test.ts +191 -191
- package/src/nat/__tests__/reachability.test.ts +24 -24
- package/src/nat/__tests__/secrets.test.ts +178 -178
- package/src/nat/address-resolver.ts +51 -51
- package/src/nat/ddns/duckdns.ts +76 -76
- package/src/nat/ddns/index.ts +55 -55
- package/src/nat/ddns/updater.ts +243 -243
- package/src/nat/external-ip.ts +154 -154
- package/src/nat/index.ts +71 -71
- package/src/nat/nat-service.ts +501 -501
- package/src/nat/nat-store.ts +154 -154
- package/src/nat/port-mapper.ts +329 -329
- package/src/nat/reachability.ts +34 -34
- package/src/nat/secrets/file-store.ts +130 -130
- package/src/nat/secrets/index.ts +112 -112
- package/src/nat/secrets/keytar-store.ts +67 -67
- package/src/nat/types.ts +159 -159
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
- package/src/orchestrator/host-process-orchestrator.ts +1133 -1133
- package/src/orchestrator/index.ts +10 -10
- package/src/orchestrator/log-rotator.ts +53 -53
- package/src/orchestrator/node-identity.ts +40 -40
- package/src/orchestrator/pid-liveness.ts +24 -24
- package/src/orchestrator/port-allocator.ts +91 -91
- package/src/orchestrator/state-store.ts +90 -90
- package/src/orchestrator/types.ts +141 -141
- package/src/owner/__tests__/owner-node-client.test.ts +194 -194
- package/src/owner/index.ts +12 -12
- package/src/owner/owner-node-client.ts +259 -259
- package/src/push/__tests__/push.test.ts +90 -90
- package/src/push/index.ts +146 -146
- package/src/server/__tests__/bus.test.ts +38 -38
- package/src/server/__tests__/error-handler.test.ts +118 -118
- package/src/server/__tests__/grants-admin-route.test.ts +73 -73
- package/src/server/__tests__/grants-route.test.ts +263 -263
- package/src/server/__tests__/nodes-route.test.ts +259 -259
- package/src/server/__tests__/origin-guard.test.ts +72 -72
- package/src/server/__tests__/publishers.test.ts +216 -216
- package/src/server/__tests__/server.smoke.test.ts +253 -253
- package/src/server/__tests__/settings-route.test.ts +194 -194
- package/src/server/__tests__/sse-route.test.ts +119 -119
- package/src/server/__tests__/static.test.ts +145 -145
- package/src/server/__tests__/status-route.test.ts +134 -134
- package/src/server/__tests__/strands-route.test.ts +233 -233
- package/src/server/error-handler.ts +178 -178
- package/src/server/events/bus.ts +42 -42
- package/src/server/events/sse-route.ts +81 -81
- package/src/server/events/types.ts +23 -23
- package/src/server/index.ts +267 -267
- package/src/server/origin-guard.ts +84 -84
- package/src/server/routes/grants-admin.ts +41 -41
- package/src/server/routes/grants.ts +210 -210
- package/src/server/routes/nat.ts +63 -63
- package/src/server/routes/nodes.ts +170 -170
- package/src/server/routes/settings.ts +123 -123
- package/src/server/routes/status.ts +118 -118
- package/src/server/routes/strands.ts +54 -54
- package/src/server/routes/trust-circle.ts +49 -49
- package/src/server/routes/update.ts +43 -43
- package/src/server/server.ts +122 -122
- package/src/server/settings-store.ts +37 -37
- package/src/server/static.ts +207 -207
- package/src/strands/__tests__/strand-service.test.ts +165 -165
- package/src/strands/index.ts +14 -14
- package/src/strands/strand-service.ts +135 -135
- package/src/strands/types.ts +102 -102
- package/src/update/__tests__/apply.test.ts +158 -158
- package/src/update/__tests__/manifest.test.ts +128 -128
- package/src/update/__tests__/release-key.test.ts +42 -42
- package/src/update/__tests__/sign.test.ts +78 -78
- package/src/update/__tests__/store.test.ts +81 -81
- package/src/update/__tests__/update-service.integration.test.ts +256 -256
- package/src/update/__tests__/version.test.ts +31 -31
- package/src/update/apply.ts +190 -190
- package/src/update/index.ts +341 -341
- package/src/update/release-key.ts +71 -71
- package/src/update/sign.ts +74 -74
- package/src/update/store.ts +101 -101
- package/src/update/types.ts +109 -109
- package/src/update/version.ts +80 -80
|
@@ -1,253 +1,253 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { DdnsUpdater } from '../ddns/updater.js';
|
|
4
|
-
import type { DdnsProvider } from '../ddns/index.js';
|
|
5
|
-
import type { SecretsStore } from '../secrets/index.js';
|
|
6
|
-
import { ddnsAccount } from '../secrets/index.js';
|
|
7
|
-
import { NatError, type NatDdnsSettings } from '../types.js';
|
|
8
|
-
|
|
9
|
-
function makeSecrets(seed: Record<string, string> = {}): SecretsStore {
|
|
10
|
-
const map = new Map(Object.entries(seed));
|
|
11
|
-
return {
|
|
12
|
-
async set(a, v) { map.set(a, v); },
|
|
13
|
-
async get(a) { return map.has(a) ? map.get(a)! : null; },
|
|
14
|
-
async delete(a) { return map.delete(a); },
|
|
15
|
-
async list() { return Array.from(map.keys()); },
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function makeProvider(overrides: Partial<DdnsProvider> = {}): DdnsProvider & {
|
|
20
|
-
calls: Array<{ hostname: string; ip: string; config: Record<string, string> }>;
|
|
21
|
-
shouldThrow: Error | null;
|
|
22
|
-
} {
|
|
23
|
-
const calls: Array<{ hostname: string; ip: string; config: Record<string, string> }> = [];
|
|
24
|
-
let shouldThrow: Error | null = null;
|
|
25
|
-
return {
|
|
26
|
-
id: 'duckdns',
|
|
27
|
-
displayName: 'DuckDNS',
|
|
28
|
-
configFields: [{ key: 'token', label: 'DuckDNS token', secret: true }],
|
|
29
|
-
async update(config, hostname, ip) {
|
|
30
|
-
calls.push({ hostname, ip, config });
|
|
31
|
-
if (shouldThrow) throw shouldThrow;
|
|
32
|
-
},
|
|
33
|
-
...overrides,
|
|
34
|
-
get calls() { return calls; },
|
|
35
|
-
get shouldThrow() { return shouldThrow; },
|
|
36
|
-
set shouldThrow(e: Error | null) { shouldThrow = e; },
|
|
37
|
-
} as DdnsProvider & {
|
|
38
|
-
calls: Array<{ hostname: string; ip: string; config: Record<string, string> }>;
|
|
39
|
-
shouldThrow: Error | null;
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function fakeIntervalTimers(): {
|
|
44
|
-
setInterval: (fn: () => void, ms: number) => NodeJS.Timeout;
|
|
45
|
-
clearInterval: (h: NodeJS.Timeout) => void;
|
|
46
|
-
fire(): Promise<void>;
|
|
47
|
-
cleared: number;
|
|
48
|
-
} {
|
|
49
|
-
let cb: (() => void) | null = null;
|
|
50
|
-
let cleared = 0;
|
|
51
|
-
return {
|
|
52
|
-
setInterval: (fn) => { cb = fn; return { _id: 1 } as unknown as NodeJS.Timeout; },
|
|
53
|
-
clearInterval: () => { cb = null; cleared++; },
|
|
54
|
-
async fire() { const f = cb; if (f) { f(); await new Promise((r) => setTimeout(r, 0)); } },
|
|
55
|
-
get cleared() { return cleared; },
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const baseSettings: NatDdnsSettings = {
|
|
60
|
-
providerId: 'duckdns',
|
|
61
|
-
hostname: 'foo.duckdns.org',
|
|
62
|
-
externallyManaged: false,
|
|
63
|
-
intervalMs: 60_000,
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
describe('DdnsUpdater', () => {
|
|
67
|
-
it('no-op when externallyManaged', async () => {
|
|
68
|
-
const provider = makeProvider();
|
|
69
|
-
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
70
|
-
const ip: string | null = '1.2.3.4';
|
|
71
|
-
const u = new DdnsUpdater({
|
|
72
|
-
settings: { ...baseSettings, externallyManaged: true },
|
|
73
|
-
secrets,
|
|
74
|
-
getExternalIp: () => ip,
|
|
75
|
-
resolveProvider: () => provider,
|
|
76
|
-
...fakeIntervalTimers(),
|
|
77
|
-
});
|
|
78
|
-
await u.start();
|
|
79
|
-
expect(provider.calls).toHaveLength(0);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('no-op when providerId is null', async () => {
|
|
83
|
-
const provider = makeProvider();
|
|
84
|
-
const u = new DdnsUpdater({
|
|
85
|
-
settings: { ...baseSettings, providerId: null },
|
|
86
|
-
secrets: makeSecrets(),
|
|
87
|
-
getExternalIp: () => '1.2.3.4',
|
|
88
|
-
resolveProvider: () => provider,
|
|
89
|
-
...fakeIntervalTimers(),
|
|
90
|
-
});
|
|
91
|
-
await u.start();
|
|
92
|
-
expect(provider.calls).toHaveLength(0);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('pushes the IP on start, records lastUpdateOk=true', async () => {
|
|
96
|
-
const provider = makeProvider();
|
|
97
|
-
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
98
|
-
const u = new DdnsUpdater({
|
|
99
|
-
settings: baseSettings,
|
|
100
|
-
secrets,
|
|
101
|
-
getExternalIp: () => '1.2.3.4',
|
|
102
|
-
resolveProvider: () => provider,
|
|
103
|
-
...fakeIntervalTimers(),
|
|
104
|
-
});
|
|
105
|
-
const outcome = await u.start();
|
|
106
|
-
expect(outcome.ok).toBe(true);
|
|
107
|
-
expect(outcome.ip).toBe('1.2.3.4');
|
|
108
|
-
expect(provider.calls).toEqual([
|
|
109
|
-
{ hostname: 'foo.duckdns.org', ip: '1.2.3.4', config: { token: 'T' } },
|
|
110
|
-
]);
|
|
111
|
-
const status = u.getStatus();
|
|
112
|
-
expect(status.lastUpdateOk).toBe(true);
|
|
113
|
-
expect(status.lastError).toBeNull();
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('does not re-push when IP is unchanged', async () => {
|
|
117
|
-
const provider = makeProvider();
|
|
118
|
-
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
119
|
-
const timers = fakeIntervalTimers();
|
|
120
|
-
let ip = '1.2.3.4';
|
|
121
|
-
const u = new DdnsUpdater({
|
|
122
|
-
settings: baseSettings,
|
|
123
|
-
secrets,
|
|
124
|
-
getExternalIp: () => ip,
|
|
125
|
-
resolveProvider: () => provider,
|
|
126
|
-
setInterval: timers.setInterval,
|
|
127
|
-
clearInterval: timers.clearInterval,
|
|
128
|
-
});
|
|
129
|
-
await u.start();
|
|
130
|
-
expect(provider.calls).toHaveLength(1);
|
|
131
|
-
|
|
132
|
-
await timers.fire();
|
|
133
|
-
expect(provider.calls).toHaveLength(1);
|
|
134
|
-
|
|
135
|
-
// Change the IP — next tick should call.
|
|
136
|
-
ip = '5.6.7.8';
|
|
137
|
-
await timers.fire();
|
|
138
|
-
expect(provider.calls).toHaveLength(2);
|
|
139
|
-
expect(provider.calls[1]?.ip).toBe('5.6.7.8');
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('records error but keeps timer running on provider throw', async () => {
|
|
143
|
-
const provider = makeProvider();
|
|
144
|
-
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
145
|
-
const timers = fakeIntervalTimers();
|
|
146
|
-
let ip = '1.2.3.4';
|
|
147
|
-
|
|
148
|
-
provider.shouldThrow = new NatError('ddns_update_failed', 'boom');
|
|
149
|
-
|
|
150
|
-
const u = new DdnsUpdater({
|
|
151
|
-
settings: baseSettings,
|
|
152
|
-
secrets,
|
|
153
|
-
getExternalIp: () => ip,
|
|
154
|
-
resolveProvider: () => provider,
|
|
155
|
-
setInterval: timers.setInterval,
|
|
156
|
-
clearInterval: timers.clearInterval,
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
await u.start();
|
|
160
|
-
expect(u.getStatus().lastUpdateOk).toBe(false);
|
|
161
|
-
expect(u.getStatus().lastError).toContain('boom');
|
|
162
|
-
|
|
163
|
-
// Provider stops throwing — next change → success
|
|
164
|
-
provider.shouldThrow = null;
|
|
165
|
-
ip = '5.6.7.8';
|
|
166
|
-
await timers.fire();
|
|
167
|
-
|
|
168
|
-
expect(provider.calls.length).toBeGreaterThanOrEqual(2);
|
|
169
|
-
expect(u.getStatus().lastUpdateOk).toBe(true);
|
|
170
|
-
expect(u.getStatus().lastError).toBeNull();
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('reports ddns_credentials_missing when secrets are empty', async () => {
|
|
174
|
-
const provider = makeProvider();
|
|
175
|
-
const u = new DdnsUpdater({
|
|
176
|
-
settings: baseSettings,
|
|
177
|
-
secrets: makeSecrets(),
|
|
178
|
-
getExternalIp: () => '1.2.3.4',
|
|
179
|
-
resolveProvider: () => provider,
|
|
180
|
-
...fakeIntervalTimers(),
|
|
181
|
-
});
|
|
182
|
-
const outcome = await u.start();
|
|
183
|
-
expect(outcome.ok).toBe(false);
|
|
184
|
-
expect(outcome.error).toMatch(/credential/i);
|
|
185
|
-
expect(provider.calls).toHaveLength(0);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('forceUpdate ignores unchanged-IP shortcut', async () => {
|
|
189
|
-
const provider = makeProvider();
|
|
190
|
-
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
191
|
-
const u = new DdnsUpdater({
|
|
192
|
-
settings: baseSettings,
|
|
193
|
-
secrets,
|
|
194
|
-
getExternalIp: () => '1.2.3.4',
|
|
195
|
-
resolveProvider: () => provider,
|
|
196
|
-
...fakeIntervalTimers(),
|
|
197
|
-
});
|
|
198
|
-
await u.start();
|
|
199
|
-
await u.forceUpdate();
|
|
200
|
-
expect(provider.calls).toHaveLength(2);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it('stop() clears the timer; idempotent', async () => {
|
|
204
|
-
const provider = makeProvider();
|
|
205
|
-
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
206
|
-
const timers = fakeIntervalTimers();
|
|
207
|
-
const u = new DdnsUpdater({
|
|
208
|
-
settings: baseSettings,
|
|
209
|
-
secrets,
|
|
210
|
-
getExternalIp: () => '1.2.3.4',
|
|
211
|
-
resolveProvider: () => provider,
|
|
212
|
-
setInterval: timers.setInterval,
|
|
213
|
-
clearInterval: timers.clearInterval,
|
|
214
|
-
});
|
|
215
|
-
await u.start();
|
|
216
|
-
u.stop();
|
|
217
|
-
u.stop();
|
|
218
|
-
expect(timers.cleared).toBe(1);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it('updateSettings restarts with new config', async () => {
|
|
222
|
-
const provider = makeProvider();
|
|
223
|
-
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
224
|
-
const u = new DdnsUpdater({
|
|
225
|
-
settings: baseSettings,
|
|
226
|
-
secrets,
|
|
227
|
-
getExternalIp: () => '1.2.3.4',
|
|
228
|
-
resolveProvider: () => provider,
|
|
229
|
-
...fakeIntervalTimers(),
|
|
230
|
-
});
|
|
231
|
-
await u.start();
|
|
232
|
-
expect(provider.calls).toHaveLength(1);
|
|
233
|
-
|
|
234
|
-
await u.updateSettings({ ...baseSettings, hostname: 'bar.duckdns.org' });
|
|
235
|
-
expect(provider.calls).toHaveLength(2);
|
|
236
|
-
expect(provider.calls[1]?.hostname).toBe('bar.duckdns.org');
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it('records "external IP unknown" when getExternalIp returns null', async () => {
|
|
240
|
-
const provider = makeProvider();
|
|
241
|
-
const u = new DdnsUpdater({
|
|
242
|
-
settings: baseSettings,
|
|
243
|
-
secrets: makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' }),
|
|
244
|
-
getExternalIp: () => null,
|
|
245
|
-
resolveProvider: () => provider,
|
|
246
|
-
...fakeIntervalTimers(),
|
|
247
|
-
});
|
|
248
|
-
const outcome = await u.start();
|
|
249
|
-
expect(outcome.attempted).toBe(false);
|
|
250
|
-
expect(outcome.error).toMatch(/unknown/i);
|
|
251
|
-
expect(provider.calls).toHaveLength(0);
|
|
252
|
-
});
|
|
253
|
-
});
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { DdnsUpdater } from '../ddns/updater.js';
|
|
4
|
+
import type { DdnsProvider } from '../ddns/index.js';
|
|
5
|
+
import type { SecretsStore } from '../secrets/index.js';
|
|
6
|
+
import { ddnsAccount } from '../secrets/index.js';
|
|
7
|
+
import { NatError, type NatDdnsSettings } from '../types.js';
|
|
8
|
+
|
|
9
|
+
function makeSecrets(seed: Record<string, string> = {}): SecretsStore {
|
|
10
|
+
const map = new Map(Object.entries(seed));
|
|
11
|
+
return {
|
|
12
|
+
async set(a, v) { map.set(a, v); },
|
|
13
|
+
async get(a) { return map.has(a) ? map.get(a)! : null; },
|
|
14
|
+
async delete(a) { return map.delete(a); },
|
|
15
|
+
async list() { return Array.from(map.keys()); },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeProvider(overrides: Partial<DdnsProvider> = {}): DdnsProvider & {
|
|
20
|
+
calls: Array<{ hostname: string; ip: string; config: Record<string, string> }>;
|
|
21
|
+
shouldThrow: Error | null;
|
|
22
|
+
} {
|
|
23
|
+
const calls: Array<{ hostname: string; ip: string; config: Record<string, string> }> = [];
|
|
24
|
+
let shouldThrow: Error | null = null;
|
|
25
|
+
return {
|
|
26
|
+
id: 'duckdns',
|
|
27
|
+
displayName: 'DuckDNS',
|
|
28
|
+
configFields: [{ key: 'token', label: 'DuckDNS token', secret: true }],
|
|
29
|
+
async update(config, hostname, ip) {
|
|
30
|
+
calls.push({ hostname, ip, config });
|
|
31
|
+
if (shouldThrow) throw shouldThrow;
|
|
32
|
+
},
|
|
33
|
+
...overrides,
|
|
34
|
+
get calls() { return calls; },
|
|
35
|
+
get shouldThrow() { return shouldThrow; },
|
|
36
|
+
set shouldThrow(e: Error | null) { shouldThrow = e; },
|
|
37
|
+
} as DdnsProvider & {
|
|
38
|
+
calls: Array<{ hostname: string; ip: string; config: Record<string, string> }>;
|
|
39
|
+
shouldThrow: Error | null;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fakeIntervalTimers(): {
|
|
44
|
+
setInterval: (fn: () => void, ms: number) => NodeJS.Timeout;
|
|
45
|
+
clearInterval: (h: NodeJS.Timeout) => void;
|
|
46
|
+
fire(): Promise<void>;
|
|
47
|
+
cleared: number;
|
|
48
|
+
} {
|
|
49
|
+
let cb: (() => void) | null = null;
|
|
50
|
+
let cleared = 0;
|
|
51
|
+
return {
|
|
52
|
+
setInterval: (fn) => { cb = fn; return { _id: 1 } as unknown as NodeJS.Timeout; },
|
|
53
|
+
clearInterval: () => { cb = null; cleared++; },
|
|
54
|
+
async fire() { const f = cb; if (f) { f(); await new Promise((r) => setTimeout(r, 0)); } },
|
|
55
|
+
get cleared() { return cleared; },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const baseSettings: NatDdnsSettings = {
|
|
60
|
+
providerId: 'duckdns',
|
|
61
|
+
hostname: 'foo.duckdns.org',
|
|
62
|
+
externallyManaged: false,
|
|
63
|
+
intervalMs: 60_000,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
describe('DdnsUpdater', () => {
|
|
67
|
+
it('no-op when externallyManaged', async () => {
|
|
68
|
+
const provider = makeProvider();
|
|
69
|
+
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
70
|
+
const ip: string | null = '1.2.3.4';
|
|
71
|
+
const u = new DdnsUpdater({
|
|
72
|
+
settings: { ...baseSettings, externallyManaged: true },
|
|
73
|
+
secrets,
|
|
74
|
+
getExternalIp: () => ip,
|
|
75
|
+
resolveProvider: () => provider,
|
|
76
|
+
...fakeIntervalTimers(),
|
|
77
|
+
});
|
|
78
|
+
await u.start();
|
|
79
|
+
expect(provider.calls).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('no-op when providerId is null', async () => {
|
|
83
|
+
const provider = makeProvider();
|
|
84
|
+
const u = new DdnsUpdater({
|
|
85
|
+
settings: { ...baseSettings, providerId: null },
|
|
86
|
+
secrets: makeSecrets(),
|
|
87
|
+
getExternalIp: () => '1.2.3.4',
|
|
88
|
+
resolveProvider: () => provider,
|
|
89
|
+
...fakeIntervalTimers(),
|
|
90
|
+
});
|
|
91
|
+
await u.start();
|
|
92
|
+
expect(provider.calls).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('pushes the IP on start, records lastUpdateOk=true', async () => {
|
|
96
|
+
const provider = makeProvider();
|
|
97
|
+
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
98
|
+
const u = new DdnsUpdater({
|
|
99
|
+
settings: baseSettings,
|
|
100
|
+
secrets,
|
|
101
|
+
getExternalIp: () => '1.2.3.4',
|
|
102
|
+
resolveProvider: () => provider,
|
|
103
|
+
...fakeIntervalTimers(),
|
|
104
|
+
});
|
|
105
|
+
const outcome = await u.start();
|
|
106
|
+
expect(outcome.ok).toBe(true);
|
|
107
|
+
expect(outcome.ip).toBe('1.2.3.4');
|
|
108
|
+
expect(provider.calls).toEqual([
|
|
109
|
+
{ hostname: 'foo.duckdns.org', ip: '1.2.3.4', config: { token: 'T' } },
|
|
110
|
+
]);
|
|
111
|
+
const status = u.getStatus();
|
|
112
|
+
expect(status.lastUpdateOk).toBe(true);
|
|
113
|
+
expect(status.lastError).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('does not re-push when IP is unchanged', async () => {
|
|
117
|
+
const provider = makeProvider();
|
|
118
|
+
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
119
|
+
const timers = fakeIntervalTimers();
|
|
120
|
+
let ip = '1.2.3.4';
|
|
121
|
+
const u = new DdnsUpdater({
|
|
122
|
+
settings: baseSettings,
|
|
123
|
+
secrets,
|
|
124
|
+
getExternalIp: () => ip,
|
|
125
|
+
resolveProvider: () => provider,
|
|
126
|
+
setInterval: timers.setInterval,
|
|
127
|
+
clearInterval: timers.clearInterval,
|
|
128
|
+
});
|
|
129
|
+
await u.start();
|
|
130
|
+
expect(provider.calls).toHaveLength(1);
|
|
131
|
+
|
|
132
|
+
await timers.fire();
|
|
133
|
+
expect(provider.calls).toHaveLength(1);
|
|
134
|
+
|
|
135
|
+
// Change the IP — next tick should call.
|
|
136
|
+
ip = '5.6.7.8';
|
|
137
|
+
await timers.fire();
|
|
138
|
+
expect(provider.calls).toHaveLength(2);
|
|
139
|
+
expect(provider.calls[1]?.ip).toBe('5.6.7.8');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('records error but keeps timer running on provider throw', async () => {
|
|
143
|
+
const provider = makeProvider();
|
|
144
|
+
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
145
|
+
const timers = fakeIntervalTimers();
|
|
146
|
+
let ip = '1.2.3.4';
|
|
147
|
+
|
|
148
|
+
provider.shouldThrow = new NatError('ddns_update_failed', 'boom');
|
|
149
|
+
|
|
150
|
+
const u = new DdnsUpdater({
|
|
151
|
+
settings: baseSettings,
|
|
152
|
+
secrets,
|
|
153
|
+
getExternalIp: () => ip,
|
|
154
|
+
resolveProvider: () => provider,
|
|
155
|
+
setInterval: timers.setInterval,
|
|
156
|
+
clearInterval: timers.clearInterval,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await u.start();
|
|
160
|
+
expect(u.getStatus().lastUpdateOk).toBe(false);
|
|
161
|
+
expect(u.getStatus().lastError).toContain('boom');
|
|
162
|
+
|
|
163
|
+
// Provider stops throwing — next change → success
|
|
164
|
+
provider.shouldThrow = null;
|
|
165
|
+
ip = '5.6.7.8';
|
|
166
|
+
await timers.fire();
|
|
167
|
+
|
|
168
|
+
expect(provider.calls.length).toBeGreaterThanOrEqual(2);
|
|
169
|
+
expect(u.getStatus().lastUpdateOk).toBe(true);
|
|
170
|
+
expect(u.getStatus().lastError).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('reports ddns_credentials_missing when secrets are empty', async () => {
|
|
174
|
+
const provider = makeProvider();
|
|
175
|
+
const u = new DdnsUpdater({
|
|
176
|
+
settings: baseSettings,
|
|
177
|
+
secrets: makeSecrets(),
|
|
178
|
+
getExternalIp: () => '1.2.3.4',
|
|
179
|
+
resolveProvider: () => provider,
|
|
180
|
+
...fakeIntervalTimers(),
|
|
181
|
+
});
|
|
182
|
+
const outcome = await u.start();
|
|
183
|
+
expect(outcome.ok).toBe(false);
|
|
184
|
+
expect(outcome.error).toMatch(/credential/i);
|
|
185
|
+
expect(provider.calls).toHaveLength(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('forceUpdate ignores unchanged-IP shortcut', async () => {
|
|
189
|
+
const provider = makeProvider();
|
|
190
|
+
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
191
|
+
const u = new DdnsUpdater({
|
|
192
|
+
settings: baseSettings,
|
|
193
|
+
secrets,
|
|
194
|
+
getExternalIp: () => '1.2.3.4',
|
|
195
|
+
resolveProvider: () => provider,
|
|
196
|
+
...fakeIntervalTimers(),
|
|
197
|
+
});
|
|
198
|
+
await u.start();
|
|
199
|
+
await u.forceUpdate();
|
|
200
|
+
expect(provider.calls).toHaveLength(2);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('stop() clears the timer; idempotent', async () => {
|
|
204
|
+
const provider = makeProvider();
|
|
205
|
+
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
206
|
+
const timers = fakeIntervalTimers();
|
|
207
|
+
const u = new DdnsUpdater({
|
|
208
|
+
settings: baseSettings,
|
|
209
|
+
secrets,
|
|
210
|
+
getExternalIp: () => '1.2.3.4',
|
|
211
|
+
resolveProvider: () => provider,
|
|
212
|
+
setInterval: timers.setInterval,
|
|
213
|
+
clearInterval: timers.clearInterval,
|
|
214
|
+
});
|
|
215
|
+
await u.start();
|
|
216
|
+
u.stop();
|
|
217
|
+
u.stop();
|
|
218
|
+
expect(timers.cleared).toBe(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('updateSettings restarts with new config', async () => {
|
|
222
|
+
const provider = makeProvider();
|
|
223
|
+
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
224
|
+
const u = new DdnsUpdater({
|
|
225
|
+
settings: baseSettings,
|
|
226
|
+
secrets,
|
|
227
|
+
getExternalIp: () => '1.2.3.4',
|
|
228
|
+
resolveProvider: () => provider,
|
|
229
|
+
...fakeIntervalTimers(),
|
|
230
|
+
});
|
|
231
|
+
await u.start();
|
|
232
|
+
expect(provider.calls).toHaveLength(1);
|
|
233
|
+
|
|
234
|
+
await u.updateSettings({ ...baseSettings, hostname: 'bar.duckdns.org' });
|
|
235
|
+
expect(provider.calls).toHaveLength(2);
|
|
236
|
+
expect(provider.calls[1]?.hostname).toBe('bar.duckdns.org');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('records "external IP unknown" when getExternalIp returns null', async () => {
|
|
240
|
+
const provider = makeProvider();
|
|
241
|
+
const u = new DdnsUpdater({
|
|
242
|
+
settings: baseSettings,
|
|
243
|
+
secrets: makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' }),
|
|
244
|
+
getExternalIp: () => null,
|
|
245
|
+
resolveProvider: () => provider,
|
|
246
|
+
...fakeIntervalTimers(),
|
|
247
|
+
});
|
|
248
|
+
const outcome = await u.start();
|
|
249
|
+
expect(outcome.attempted).toBe(false);
|
|
250
|
+
expect(outcome.error).toMatch(/unknown/i);
|
|
251
|
+
expect(provider.calls).toHaveLength(0);
|
|
252
|
+
});
|
|
253
|
+
});
|