@serfab/cadre-host 0.10.0 → 0.12.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/README.md +406 -296
- package/dist/bin/host.d.ts +11 -7
- package/dist/bin/host.js +40 -12
- package/dist/bin/host.js.map +1 -1
- package/dist/installer/config.d.ts +4 -4
- package/dist/installer/config.js +5 -39
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/identity.d.ts +8 -1
- package/dist/installer/identity.js +8 -1
- package/dist/installer/identity.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +2 -2
- package/dist/orchestrator/host-process-orchestrator.js +9 -4
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/server/settings-store.d.ts +1 -1
- package/dist/server/settings-store.js +5 -1
- package/dist/server/settings-store.js.map +1 -1
- 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 +51 -46
- package/src/__tests__/global-setup.ts +56 -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 +5 -5
- 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/bin/__tests__/cli-reference.test.ts +167 -0
- package/src/bin/host.ts +1326 -1299
- 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__/config.test.ts +134 -112
- 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/config.ts +5 -43
- package/src/installer/identity.ts +56 -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 +18 -0
- 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 +9 -4
- 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 +195 -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 +5 -1
- 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
package/src/nat/nat-service.ts
CHANGED
|
@@ -1,501 +1,501 @@
|
|
|
1
|
-
import debug from 'debug';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
NatError,
|
|
5
|
-
type DdnsProviderInfo,
|
|
6
|
-
type NatDdnsStatus,
|
|
7
|
-
type NatHandlers,
|
|
8
|
-
type NatSettingsFile,
|
|
9
|
-
type NatStatusSnapshot,
|
|
10
|
-
} from './types.js';
|
|
11
|
-
import { NatStore } from './nat-store.js';
|
|
12
|
-
import { ExternalIpDetector, type ExternalIpResult } from './external-ip.js';
|
|
13
|
-
import {
|
|
14
|
-
PortMapperService,
|
|
15
|
-
createDefaultPortMapper,
|
|
16
|
-
type PortMapper,
|
|
17
|
-
type PortMapperState,
|
|
18
|
-
} from './port-mapper.js';
|
|
19
|
-
import { evaluateReachability } from './reachability.js';
|
|
20
|
-
import { DdnsUpdater } from './ddns/updater.js';
|
|
21
|
-
import { getProvider, listProviders } from './ddns/index.js';
|
|
22
|
-
import {
|
|
23
|
-
buildInviteAddresses,
|
|
24
|
-
type BuildInviteAddressesInput,
|
|
25
|
-
} from './address-resolver.js';
|
|
26
|
-
import { createSecretsStore, ddnsAccount, type SecretsStore } from './secrets/index.js';
|
|
27
|
-
import { OwnerNodeUnavailableError } from '../owner/owner-node-client.js';
|
|
28
|
-
|
|
29
|
-
const log = debug('cadre:host:nat-service');
|
|
30
|
-
|
|
31
|
-
/** Sleep helper for the initial-push retry loop; unref'd so it never holds the process open. */
|
|
32
|
-
function sleep(ms: number): Promise<void> {
|
|
33
|
-
return new Promise((res) => {
|
|
34
|
-
const t = setTimeout(res, ms);
|
|
35
|
-
if (typeof t.unref === 'function') t.unref();
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Minimal slice of CadreNode that NatService needs — defined as an interface
|
|
41
|
-
* so tests can inject a mock without a real libp2p stack. Mirrors
|
|
42
|
-
* `CadreNodeLike` in `auth/trust-circle.ts`.
|
|
43
|
-
*
|
|
44
|
-
* Both methods are async: in production they round-trip over the owner
|
|
45
|
-
* node's loopback admin channel (`GET /admin/identity`, `GET /admin/multiaddrs`).
|
|
46
|
-
* Synchronous test mocks can return a value directly (it's awaited).
|
|
47
|
-
*/
|
|
48
|
-
export interface CadreNodeLike {
|
|
49
|
-
/** Returns this node's peer ID (libp2p) as a string. */
|
|
50
|
-
getPeerId(): Promise<string> | string;
|
|
51
|
-
/** Returns the current observed multiaddrs from libp2p. */
|
|
52
|
-
getMultiaddrs(): Promise<string[]> | string[];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Listener notified when the invite addresses may have changed. */
|
|
56
|
-
export type AddressesChangedListener = (addresses: string[]) => void | Promise<void>;
|
|
57
|
-
|
|
58
|
-
export interface NatServiceOptions {
|
|
59
|
-
/** Cadre-host root directory (same one orchestrator + trust-circle use). */
|
|
60
|
-
rootDir: string;
|
|
61
|
-
/** libp2p node — used for peer ID and the libp2p multiaddrs fallback. */
|
|
62
|
-
cadreNode: CadreNodeLike;
|
|
63
|
-
/** Clock override for tests. */
|
|
64
|
-
now?: () => Date;
|
|
65
|
-
/** Fetch override for tests. */
|
|
66
|
-
fetch?: typeof fetch;
|
|
67
|
-
/** Test-only: stub the keytar/file-store. Default: createSecretsStore. */
|
|
68
|
-
secretsStore?: SecretsStore;
|
|
69
|
-
/** Test-only: stub the port mapper. Default: createDefaultPortMapper(). */
|
|
70
|
-
portMapper?: PortMapper;
|
|
71
|
-
/** Test-only: stub the external-IP detector. */
|
|
72
|
-
externalIpDetector?: ExternalIpDetector;
|
|
73
|
-
/** Optional: re-use an existing NatStore (mostly for tests). */
|
|
74
|
-
store?: NatStore;
|
|
75
|
-
/** Initial-push retry: poll interval while the node is not-yet-ready. Default 250ms. */
|
|
76
|
-
initialPushRetryMs?: number;
|
|
77
|
-
/** Initial-push retry: total budget before giving up (best-effort). Default 15000ms. */
|
|
78
|
-
initialPushTimeoutMs?: number;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* NatService — orchestrates port mapping, external-IP detection, DDNS updates,
|
|
83
|
-
* and address resolution for invites.
|
|
84
|
-
*
|
|
85
|
-
* Lifecycle:
|
|
86
|
-
* - `start()`: load settings, install port mapping (if upnpEnabled), detect
|
|
87
|
-
* external IP, kick off DDNS loop.
|
|
88
|
-
* - `stop()`: release port mapping, stop DDNS timer.
|
|
89
|
-
* - `getStatus()`: build a fresh status snapshot from internal state (cheap).
|
|
90
|
-
* - `testReachability()`: re-run the IP + verify cycle and the heuristic.
|
|
91
|
-
* - `getInviteAddresses()`: build the multiaddrs to embed in invites.
|
|
92
|
-
*
|
|
93
|
-
* NatService is library + lifecycle only. The long-running process that owns
|
|
94
|
-
* it is `cadre-host-local-ui` (see docs/cadre-host.md and the ticket header).
|
|
95
|
-
*/
|
|
96
|
-
export class NatService {
|
|
97
|
-
private readonly cadreNode: CadreNodeLike;
|
|
98
|
-
private readonly store: NatStore;
|
|
99
|
-
private readonly nowFn: () => Date;
|
|
100
|
-
private readonly fetchImpl: typeof fetch;
|
|
101
|
-
|
|
102
|
-
// Injected or lazily-created.
|
|
103
|
-
private secretsStore: SecretsStore | null;
|
|
104
|
-
private portMapper: PortMapper | null;
|
|
105
|
-
/** Caller-supplied detector — used as-is when present (skips the auto router-probe stitch). */
|
|
106
|
-
private readonly injectedDetector: ExternalIpDetector | null;
|
|
107
|
-
|
|
108
|
-
// Built once start() completes.
|
|
109
|
-
private portService: PortMapperService | null = null;
|
|
110
|
-
private ddnsUpdater: DdnsUpdater | null = null;
|
|
111
|
-
|
|
112
|
-
// Runtime state.
|
|
113
|
-
private currentSettings: NatSettingsFile;
|
|
114
|
-
private latestIp: ExternalIpResult | null = null;
|
|
115
|
-
private lastTestedAt: Date | null = null;
|
|
116
|
-
private started = false;
|
|
117
|
-
/** Listeners notified when the invite addresses may have changed. */
|
|
118
|
-
private readonly addressListeners = new Set<AddressesChangedListener>();
|
|
119
|
-
|
|
120
|
-
// For createSecretsStore async init.
|
|
121
|
-
private readonly secretsRootDir: string;
|
|
122
|
-
|
|
123
|
-
// Initial invite-address push retry tunables (injectable so tests run fast).
|
|
124
|
-
private readonly initialPushRetryMs: number;
|
|
125
|
-
private readonly initialPushTimeoutMs: number;
|
|
126
|
-
|
|
127
|
-
constructor(opts: NatServiceOptions) {
|
|
128
|
-
this.cadreNode = opts.cadreNode;
|
|
129
|
-
this.store = opts.store ?? new NatStore(opts.rootDir);
|
|
130
|
-
this.nowFn = opts.now ?? (() => new Date());
|
|
131
|
-
this.fetchImpl = opts.fetch ?? fetch;
|
|
132
|
-
this.secretsStore = opts.secretsStore ?? null;
|
|
133
|
-
this.portMapper = opts.portMapper ?? null;
|
|
134
|
-
this.injectedDetector = opts.externalIpDetector ?? null;
|
|
135
|
-
this.secretsRootDir = opts.rootDir;
|
|
136
|
-
this.initialPushRetryMs = opts.initialPushRetryMs ?? 250;
|
|
137
|
-
this.initialPushTimeoutMs = opts.initialPushTimeoutMs ?? 15_000;
|
|
138
|
-
this.currentSettings = this.store.load();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Initial start: port mapping → IP detect → DDNS. Idempotent. */
|
|
142
|
-
async start(): Promise<void> {
|
|
143
|
-
if (this.started) return;
|
|
144
|
-
this.started = true;
|
|
145
|
-
|
|
146
|
-
// 1. Secrets store (lazy if not injected).
|
|
147
|
-
if (!this.secretsStore) {
|
|
148
|
-
this.secretsStore = await createSecretsStore(this.secretsRootDir);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// 2. Port mapping.
|
|
152
|
-
await this.startPortService();
|
|
153
|
-
|
|
154
|
-
// 3. External IP detection.
|
|
155
|
-
await this.detectIp();
|
|
156
|
-
|
|
157
|
-
// 4. DDNS updater.
|
|
158
|
-
this.ddnsUpdater = new DdnsUpdater({
|
|
159
|
-
settings: this.currentSettings.ddns,
|
|
160
|
-
secrets: this.secretsStore,
|
|
161
|
-
getExternalIp: () => this.latestIp?.publicIp ?? this.latestIp?.routerIp ?? null,
|
|
162
|
-
fetch: this.fetchImpl,
|
|
163
|
-
now: this.nowFn,
|
|
164
|
-
});
|
|
165
|
-
await this.ddnsUpdater.start();
|
|
166
|
-
|
|
167
|
-
// Initial invite-address push. Retries on `node_unavailable` until the
|
|
168
|
-
// freshly spawned owner node accepts it (bounded; best-effort after).
|
|
169
|
-
await this.pushInitialAddresses();
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** Release the port mapping and clear timers. Idempotent. */
|
|
173
|
-
async stop(): Promise<void> {
|
|
174
|
-
if (!this.started) return;
|
|
175
|
-
this.started = false;
|
|
176
|
-
try {
|
|
177
|
-
this.ddnsUpdater?.stop();
|
|
178
|
-
} catch (err) {
|
|
179
|
-
log('ddns stop failed: %s', (err as Error).message);
|
|
180
|
-
}
|
|
181
|
-
this.ddnsUpdater = null;
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
await this.portService?.stop();
|
|
185
|
-
} catch (err) {
|
|
186
|
-
log('port-service stop failed: %s', (err as Error).message);
|
|
187
|
-
}
|
|
188
|
-
this.portService = null;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Cheap read of the current snapshot. */
|
|
192
|
-
getStatus(): NatStatusSnapshot {
|
|
193
|
-
const portState = this.portService?.getState() ?? this.disabledPortState();
|
|
194
|
-
const externalIp = this.latestIp?.publicIp ?? this.latestIp?.routerIp ?? null;
|
|
195
|
-
const ddns: NatDdnsStatus = this.ddnsUpdater?.getStatus() ?? {
|
|
196
|
-
providerId: this.currentSettings.ddns.providerId,
|
|
197
|
-
hostname: this.currentSettings.ddns.hostname,
|
|
198
|
-
externallyManaged: this.currentSettings.ddns.externallyManaged,
|
|
199
|
-
lastUpdateAt: null,
|
|
200
|
-
lastUpdateOk: null,
|
|
201
|
-
lastError: null,
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
return {
|
|
205
|
-
portMode: this.currentSettings.upnpEnabled ? portState.mode : 'disabled',
|
|
206
|
-
externalPort: portState.externalPort,
|
|
207
|
-
internalPort: portState.internalPort,
|
|
208
|
-
routerExternalIp: portState.routerExternalIp,
|
|
209
|
-
mappingLeaseExpiresAt: portState.leaseExpiresAt?.toISOString() ?? null,
|
|
210
|
-
externalIp,
|
|
211
|
-
externalIpDetectedAt: this.latestIp?.detectedAt.toISOString() ?? null,
|
|
212
|
-
cgnatDetected: this.latestIp?.cgnat ?? false,
|
|
213
|
-
directReachability: evaluateReachability({
|
|
214
|
-
portMode: portState.mode,
|
|
215
|
-
cgnatDetected: this.latestIp?.cgnat ?? false,
|
|
216
|
-
}),
|
|
217
|
-
lastTestedAt: this.lastTestedAt?.toISOString() ?? null,
|
|
218
|
-
ddns,
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Re-run the IP probe + port-mapper verify, then return a fresh snapshot. */
|
|
223
|
-
async testReachability(): Promise<NatStatusSnapshot> {
|
|
224
|
-
if (this.portService) {
|
|
225
|
-
await this.portService.refresh().catch((err) => log('refresh err: %s', (err as Error).message));
|
|
226
|
-
}
|
|
227
|
-
await this.detectIp();
|
|
228
|
-
this.lastTestedAt = this.nowFn();
|
|
229
|
-
// Best-effort DDNS push when the IP just changed.
|
|
230
|
-
if (this.ddnsUpdater) {
|
|
231
|
-
await this.ddnsUpdater.forceUpdate().catch((err) => log('ddns forceUpdate err: %s', (err as Error).message));
|
|
232
|
-
}
|
|
233
|
-
// Reachability/IP may have changed → invite addresses may have changed.
|
|
234
|
-
await this.fireAddressesChanged();
|
|
235
|
-
return this.getStatus();
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Build the multiaddrs to embed in invites. The peer ID and libp2p
|
|
240
|
-
* fallback addresses are fetched over the owner node's admin channel; a
|
|
241
|
-
* node-unavailable failure surfaces as `NatError('node_unavailable')`.
|
|
242
|
-
*/
|
|
243
|
-
async getInviteAddresses(): Promise<string[]> {
|
|
244
|
-
let peerId: string;
|
|
245
|
-
let libp2pAddrs: string[];
|
|
246
|
-
try {
|
|
247
|
-
peerId = await this.cadreNode.getPeerId();
|
|
248
|
-
libp2pAddrs = await this.cadreNode.getMultiaddrs();
|
|
249
|
-
} catch (err) {
|
|
250
|
-
if (err instanceof OwnerNodeUnavailableError) {
|
|
251
|
-
throw new NatError('node_unavailable', `Owner node unavailable: ${err.message}`);
|
|
252
|
-
}
|
|
253
|
-
throw err;
|
|
254
|
-
}
|
|
255
|
-
// A reachable node that hasn't yet established its libp2p peer ID reports
|
|
256
|
-
// an empty peerId. Building addresses now would mint malformed `…/p2p/`
|
|
257
|
-
// suffixes (and push them to the node), so treat it as not-ready.
|
|
258
|
-
if (!peerId) {
|
|
259
|
-
throw new NatError('node_unavailable', 'Owner node has no peer ID yet (not ready)');
|
|
260
|
-
}
|
|
261
|
-
const status = this.getStatus();
|
|
262
|
-
const input: BuildInviteAddressesInput = {
|
|
263
|
-
peerId,
|
|
264
|
-
externalPort: status.externalPort,
|
|
265
|
-
ddnsHostname: status.ddns.hostname,
|
|
266
|
-
externalIp: status.externalIp,
|
|
267
|
-
reachable: status.directReachability === 'reachable',
|
|
268
|
-
libp2pAddrs,
|
|
269
|
-
};
|
|
270
|
-
return buildInviteAddresses(input);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Register a listener fired when the invite addresses may have changed
|
|
275
|
-
* (after `start`, `putSettings`, and `testReachability`). `cadre-host start`
|
|
276
|
-
* wires this to `OwnerNodeClient.pushInviteAddresses`. Returns an
|
|
277
|
-
* unsubscribe fn.
|
|
278
|
-
*/
|
|
279
|
-
onAddressesChanged(listener: AddressesChangedListener): () => void {
|
|
280
|
-
this.addressListeners.add(listener);
|
|
281
|
-
return () => { this.addressListeners.delete(listener); };
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Recompute invite addresses and notify listeners. Best-effort: when the
|
|
286
|
-
* owner node is unreachable (or the build fails), the notification is
|
|
287
|
-
* skipped — the push is retried on the next NAT event.
|
|
288
|
-
*/
|
|
289
|
-
private async fireAddressesChanged(): Promise<void> {
|
|
290
|
-
if (this.addressListeners.size === 0) return;
|
|
291
|
-
let addresses: string[];
|
|
292
|
-
try {
|
|
293
|
-
addresses = await this.getInviteAddresses();
|
|
294
|
-
} catch (err) {
|
|
295
|
-
log('onAddressesChanged skipped: %s', (err as Error).message);
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
await this.notifyAddressListeners(addresses);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Deliver an address set to every registered listener (errors are logged, not thrown). */
|
|
302
|
-
private async notifyAddressListeners(addresses: string[]): Promise<void> {
|
|
303
|
-
for (const listener of this.addressListeners) {
|
|
304
|
-
try {
|
|
305
|
-
await listener(addresses);
|
|
306
|
-
} catch (err) {
|
|
307
|
-
log('address listener threw: %s', (err as Error).message);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Initial invite-address push. The owner node's admin channel is a freshly
|
|
314
|
-
* spawned detached child that may not be bound yet (and may not have a libp2p
|
|
315
|
-
* peer ID yet), so a single attempt races readiness and silently drops the first
|
|
316
|
-
* NAT-resolved address set. Retry on `node_unavailable` until the node accepts it
|
|
317
|
-
* or the bounded budget elapses (best-effort thereafter). Awaited by start() so
|
|
318
|
-
* the management API — the invite-minting path — does not come up first.
|
|
319
|
-
*/
|
|
320
|
-
private async pushInitialAddresses(): Promise<void> {
|
|
321
|
-
if (this.addressListeners.size === 0) return;
|
|
322
|
-
const deadline = this.nowFn().getTime() + this.initialPushTimeoutMs;
|
|
323
|
-
for (;;) {
|
|
324
|
-
if (!this.started) return; // stop() ran during a retry
|
|
325
|
-
try {
|
|
326
|
-
const addresses = await this.getInviteAddresses();
|
|
327
|
-
await this.notifyAddressListeners(addresses);
|
|
328
|
-
return;
|
|
329
|
-
} catch (err) {
|
|
330
|
-
const code = err instanceof NatError ? err.code : undefined;
|
|
331
|
-
// Only retry transient not-ready; surface anything unexpected via log + give up.
|
|
332
|
-
if (code !== 'node_unavailable' || this.nowFn().getTime() >= deadline) {
|
|
333
|
-
log('initial invite-address push not delivered: %s', (err as Error).message);
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
await sleep(this.initialPushRetryMs);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** Current settings (cheap read; for handlers to surface to UI). */
|
|
342
|
-
getSettings(): NatSettingsFile {
|
|
343
|
-
return this.currentSettings;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** Replace settings; persists to disk and reconfigures the runtime. */
|
|
347
|
-
async putSettings(patch: Partial<Omit<NatSettingsFile, 'version'>>): Promise<NatStatusSnapshot> {
|
|
348
|
-
const next = this.store.update(patch);
|
|
349
|
-
const upnpChanged = next.upnpEnabled !== this.currentSettings.upnpEnabled;
|
|
350
|
-
const portChanged =
|
|
351
|
-
next.externalPort !== this.currentSettings.externalPort ||
|
|
352
|
-
next.internalPort !== this.currentSettings.internalPort;
|
|
353
|
-
const ddnsChanged =
|
|
354
|
-
next.ddns.providerId !== this.currentSettings.ddns.providerId ||
|
|
355
|
-
next.ddns.hostname !== this.currentSettings.ddns.hostname ||
|
|
356
|
-
next.ddns.externallyManaged !== this.currentSettings.ddns.externallyManaged ||
|
|
357
|
-
next.ddns.intervalMs !== this.currentSettings.ddns.intervalMs;
|
|
358
|
-
this.currentSettings = next;
|
|
359
|
-
|
|
360
|
-
if (this.started) {
|
|
361
|
-
if (upnpChanged || portChanged) {
|
|
362
|
-
try { await this.portService?.stop(); } catch { /* ignore */ }
|
|
363
|
-
this.portService = null;
|
|
364
|
-
await this.startPortService();
|
|
365
|
-
}
|
|
366
|
-
if (ddnsChanged && this.ddnsUpdater) {
|
|
367
|
-
await this.ddnsUpdater.updateSettings(next.ddns);
|
|
368
|
-
}
|
|
369
|
-
if (upnpChanged || portChanged || ddnsChanged) {
|
|
370
|
-
// Port / DDNS changes alter the advertised invite addresses.
|
|
371
|
-
await this.fireAddressesChanged();
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
return this.getStatus();
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Set DDNS configuration and persist secret values into the SecretsStore.
|
|
379
|
-
* Throws if the provider is unknown or required secrets are missing.
|
|
380
|
-
*/
|
|
381
|
-
async putDdns(body: {
|
|
382
|
-
providerId: string;
|
|
383
|
-
hostname: string;
|
|
384
|
-
config: Record<string, string>;
|
|
385
|
-
externallyManaged?: boolean;
|
|
386
|
-
}): Promise<NatStatusSnapshot> {
|
|
387
|
-
if (!body || typeof body.providerId !== 'string' || typeof body.hostname !== 'string') {
|
|
388
|
-
throw new NatError('invalid_config', 'providerId and hostname are required');
|
|
389
|
-
}
|
|
390
|
-
const provider = getProvider(body.providerId);
|
|
391
|
-
if (!this.secretsStore) {
|
|
392
|
-
throw new NatError('secrets_unavailable', 'secrets store not initialised; call start() first');
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Persist secrets.
|
|
396
|
-
for (const field of provider.configFields) {
|
|
397
|
-
if (!field.secret) continue;
|
|
398
|
-
const value = body.config[field.key];
|
|
399
|
-
if (typeof value !== 'string' || value.length === 0) {
|
|
400
|
-
throw new NatError(
|
|
401
|
-
'ddns_credentials_missing',
|
|
402
|
-
`Missing value for required secret field "${field.key}" of provider "${provider.id}"`,
|
|
403
|
-
);
|
|
404
|
-
}
|
|
405
|
-
await this.secretsStore.set(ddnsAccount(provider.id, field.key), value);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// Update nat.json.
|
|
409
|
-
const externallyManaged = body.externallyManaged ?? false;
|
|
410
|
-
return await this.putSettings({
|
|
411
|
-
ddns: {
|
|
412
|
-
providerId: provider.id,
|
|
413
|
-
hostname: body.hostname,
|
|
414
|
-
externallyManaged,
|
|
415
|
-
intervalMs: this.currentSettings.ddns.intervalMs,
|
|
416
|
-
},
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** List available DDNS providers (UI listing for configuration). */
|
|
421
|
-
listDdnsProviders(): DdnsProviderInfo[] {
|
|
422
|
-
return listProviders().map((p) => ({
|
|
423
|
-
id: p.id,
|
|
424
|
-
displayName: p.displayName,
|
|
425
|
-
configFields: p.configFields,
|
|
426
|
-
}));
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// --- private ---
|
|
430
|
-
|
|
431
|
-
private async startPortService(): Promise<void> {
|
|
432
|
-
if (!this.currentSettings.upnpEnabled) {
|
|
433
|
-
log('UPnP disabled by settings; skipping port mapping');
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
let mapper = this.portMapper;
|
|
437
|
-
if (!mapper) {
|
|
438
|
-
try {
|
|
439
|
-
mapper = await createDefaultPortMapper();
|
|
440
|
-
this.portMapper = mapper;
|
|
441
|
-
} catch (err) {
|
|
442
|
-
log('default port mapper unavailable: %s', (err as Error).message);
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
this.portService = new PortMapperService({
|
|
447
|
-
mapper,
|
|
448
|
-
externalPort: this.currentSettings.externalPort,
|
|
449
|
-
internalPort: this.currentSettings.internalPort,
|
|
450
|
-
now: this.nowFn,
|
|
451
|
-
});
|
|
452
|
-
await this.portService.start();
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
private async detectIp(): Promise<void> {
|
|
456
|
-
// If the caller injected a detector (typically a test stub), use it as-is —
|
|
457
|
-
// don't second-guess them by stitching in the port mapper's router probe.
|
|
458
|
-
const detector = this.injectedDetector ?? (
|
|
459
|
-
this.portMapper
|
|
460
|
-
? new ExternalIpDetector({
|
|
461
|
-
fetch: this.fetchImpl,
|
|
462
|
-
now: this.nowFn,
|
|
463
|
-
routerProbe: async () => this.portMapper!.externalIp(),
|
|
464
|
-
})
|
|
465
|
-
: new ExternalIpDetector({
|
|
466
|
-
fetch: this.fetchImpl,
|
|
467
|
-
now: this.nowFn,
|
|
468
|
-
})
|
|
469
|
-
);
|
|
470
|
-
try {
|
|
471
|
-
this.latestIp = await detector.detect();
|
|
472
|
-
} catch (err) {
|
|
473
|
-
log('detectIp failed: %s', (err as Error).message);
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
private disabledPortState(): PortMapperState {
|
|
478
|
-
return {
|
|
479
|
-
mode: this.currentSettings.upnpEnabled ? 'failed' : 'disabled',
|
|
480
|
-
externalPort: this.currentSettings.externalPort,
|
|
481
|
-
internalPort: this.currentSettings.internalPort,
|
|
482
|
-
routerExternalIp: null,
|
|
483
|
-
leaseExpiresAt: null,
|
|
484
|
-
lastError: null,
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* Wrap a NatService into the typed handler shape consumed by
|
|
491
|
-
* `cadre-host-local-ui`. Mirrors `createTrustCircleHandlers`.
|
|
492
|
-
*/
|
|
493
|
-
export function createNatHandlers(service: NatService): NatHandlers {
|
|
494
|
-
return {
|
|
495
|
-
async getStatus() { return service.getStatus(); },
|
|
496
|
-
async testReachability() { return await service.testReachability(); },
|
|
497
|
-
async listDdnsProviders() { return service.listDdnsProviders(); },
|
|
498
|
-
async putDdns(body) { return await service.putDdns(body); },
|
|
499
|
-
async putSettings(body) { return await service.putSettings(body); },
|
|
500
|
-
};
|
|
501
|
-
}
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
NatError,
|
|
5
|
+
type DdnsProviderInfo,
|
|
6
|
+
type NatDdnsStatus,
|
|
7
|
+
type NatHandlers,
|
|
8
|
+
type NatSettingsFile,
|
|
9
|
+
type NatStatusSnapshot,
|
|
10
|
+
} from './types.js';
|
|
11
|
+
import { NatStore } from './nat-store.js';
|
|
12
|
+
import { ExternalIpDetector, type ExternalIpResult } from './external-ip.js';
|
|
13
|
+
import {
|
|
14
|
+
PortMapperService,
|
|
15
|
+
createDefaultPortMapper,
|
|
16
|
+
type PortMapper,
|
|
17
|
+
type PortMapperState,
|
|
18
|
+
} from './port-mapper.js';
|
|
19
|
+
import { evaluateReachability } from './reachability.js';
|
|
20
|
+
import { DdnsUpdater } from './ddns/updater.js';
|
|
21
|
+
import { getProvider, listProviders } from './ddns/index.js';
|
|
22
|
+
import {
|
|
23
|
+
buildInviteAddresses,
|
|
24
|
+
type BuildInviteAddressesInput,
|
|
25
|
+
} from './address-resolver.js';
|
|
26
|
+
import { createSecretsStore, ddnsAccount, type SecretsStore } from './secrets/index.js';
|
|
27
|
+
import { OwnerNodeUnavailableError } from '../owner/owner-node-client.js';
|
|
28
|
+
|
|
29
|
+
const log = debug('cadre:host:nat-service');
|
|
30
|
+
|
|
31
|
+
/** Sleep helper for the initial-push retry loop; unref'd so it never holds the process open. */
|
|
32
|
+
function sleep(ms: number): Promise<void> {
|
|
33
|
+
return new Promise((res) => {
|
|
34
|
+
const t = setTimeout(res, ms);
|
|
35
|
+
if (typeof t.unref === 'function') t.unref();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Minimal slice of CadreNode that NatService needs — defined as an interface
|
|
41
|
+
* so tests can inject a mock without a real libp2p stack. Mirrors
|
|
42
|
+
* `CadreNodeLike` in `auth/trust-circle.ts`.
|
|
43
|
+
*
|
|
44
|
+
* Both methods are async: in production they round-trip over the owner
|
|
45
|
+
* node's loopback admin channel (`GET /admin/identity`, `GET /admin/multiaddrs`).
|
|
46
|
+
* Synchronous test mocks can return a value directly (it's awaited).
|
|
47
|
+
*/
|
|
48
|
+
export interface CadreNodeLike {
|
|
49
|
+
/** Returns this node's peer ID (libp2p) as a string. */
|
|
50
|
+
getPeerId(): Promise<string> | string;
|
|
51
|
+
/** Returns the current observed multiaddrs from libp2p. */
|
|
52
|
+
getMultiaddrs(): Promise<string[]> | string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Listener notified when the invite addresses may have changed. */
|
|
56
|
+
export type AddressesChangedListener = (addresses: string[]) => void | Promise<void>;
|
|
57
|
+
|
|
58
|
+
export interface NatServiceOptions {
|
|
59
|
+
/** Cadre-host root directory (same one orchestrator + trust-circle use). */
|
|
60
|
+
rootDir: string;
|
|
61
|
+
/** libp2p node — used for peer ID and the libp2p multiaddrs fallback. */
|
|
62
|
+
cadreNode: CadreNodeLike;
|
|
63
|
+
/** Clock override for tests. */
|
|
64
|
+
now?: () => Date;
|
|
65
|
+
/** Fetch override for tests. */
|
|
66
|
+
fetch?: typeof fetch;
|
|
67
|
+
/** Test-only: stub the keytar/file-store. Default: createSecretsStore. */
|
|
68
|
+
secretsStore?: SecretsStore;
|
|
69
|
+
/** Test-only: stub the port mapper. Default: createDefaultPortMapper(). */
|
|
70
|
+
portMapper?: PortMapper;
|
|
71
|
+
/** Test-only: stub the external-IP detector. */
|
|
72
|
+
externalIpDetector?: ExternalIpDetector;
|
|
73
|
+
/** Optional: re-use an existing NatStore (mostly for tests). */
|
|
74
|
+
store?: NatStore;
|
|
75
|
+
/** Initial-push retry: poll interval while the node is not-yet-ready. Default 250ms. */
|
|
76
|
+
initialPushRetryMs?: number;
|
|
77
|
+
/** Initial-push retry: total budget before giving up (best-effort). Default 15000ms. */
|
|
78
|
+
initialPushTimeoutMs?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* NatService — orchestrates port mapping, external-IP detection, DDNS updates,
|
|
83
|
+
* and address resolution for invites.
|
|
84
|
+
*
|
|
85
|
+
* Lifecycle:
|
|
86
|
+
* - `start()`: load settings, install port mapping (if upnpEnabled), detect
|
|
87
|
+
* external IP, kick off DDNS loop.
|
|
88
|
+
* - `stop()`: release port mapping, stop DDNS timer.
|
|
89
|
+
* - `getStatus()`: build a fresh status snapshot from internal state (cheap).
|
|
90
|
+
* - `testReachability()`: re-run the IP + verify cycle and the heuristic.
|
|
91
|
+
* - `getInviteAddresses()`: build the multiaddrs to embed in invites.
|
|
92
|
+
*
|
|
93
|
+
* NatService is library + lifecycle only. The long-running process that owns
|
|
94
|
+
* it is `cadre-host-local-ui` (see docs/cadre-host.md and the ticket header).
|
|
95
|
+
*/
|
|
96
|
+
export class NatService {
|
|
97
|
+
private readonly cadreNode: CadreNodeLike;
|
|
98
|
+
private readonly store: NatStore;
|
|
99
|
+
private readonly nowFn: () => Date;
|
|
100
|
+
private readonly fetchImpl: typeof fetch;
|
|
101
|
+
|
|
102
|
+
// Injected or lazily-created.
|
|
103
|
+
private secretsStore: SecretsStore | null;
|
|
104
|
+
private portMapper: PortMapper | null;
|
|
105
|
+
/** Caller-supplied detector — used as-is when present (skips the auto router-probe stitch). */
|
|
106
|
+
private readonly injectedDetector: ExternalIpDetector | null;
|
|
107
|
+
|
|
108
|
+
// Built once start() completes.
|
|
109
|
+
private portService: PortMapperService | null = null;
|
|
110
|
+
private ddnsUpdater: DdnsUpdater | null = null;
|
|
111
|
+
|
|
112
|
+
// Runtime state.
|
|
113
|
+
private currentSettings: NatSettingsFile;
|
|
114
|
+
private latestIp: ExternalIpResult | null = null;
|
|
115
|
+
private lastTestedAt: Date | null = null;
|
|
116
|
+
private started = false;
|
|
117
|
+
/** Listeners notified when the invite addresses may have changed. */
|
|
118
|
+
private readonly addressListeners = new Set<AddressesChangedListener>();
|
|
119
|
+
|
|
120
|
+
// For createSecretsStore async init.
|
|
121
|
+
private readonly secretsRootDir: string;
|
|
122
|
+
|
|
123
|
+
// Initial invite-address push retry tunables (injectable so tests run fast).
|
|
124
|
+
private readonly initialPushRetryMs: number;
|
|
125
|
+
private readonly initialPushTimeoutMs: number;
|
|
126
|
+
|
|
127
|
+
constructor(opts: NatServiceOptions) {
|
|
128
|
+
this.cadreNode = opts.cadreNode;
|
|
129
|
+
this.store = opts.store ?? new NatStore(opts.rootDir);
|
|
130
|
+
this.nowFn = opts.now ?? (() => new Date());
|
|
131
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
132
|
+
this.secretsStore = opts.secretsStore ?? null;
|
|
133
|
+
this.portMapper = opts.portMapper ?? null;
|
|
134
|
+
this.injectedDetector = opts.externalIpDetector ?? null;
|
|
135
|
+
this.secretsRootDir = opts.rootDir;
|
|
136
|
+
this.initialPushRetryMs = opts.initialPushRetryMs ?? 250;
|
|
137
|
+
this.initialPushTimeoutMs = opts.initialPushTimeoutMs ?? 15_000;
|
|
138
|
+
this.currentSettings = this.store.load();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Initial start: port mapping → IP detect → DDNS. Idempotent. */
|
|
142
|
+
async start(): Promise<void> {
|
|
143
|
+
if (this.started) return;
|
|
144
|
+
this.started = true;
|
|
145
|
+
|
|
146
|
+
// 1. Secrets store (lazy if not injected).
|
|
147
|
+
if (!this.secretsStore) {
|
|
148
|
+
this.secretsStore = await createSecretsStore(this.secretsRootDir);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. Port mapping.
|
|
152
|
+
await this.startPortService();
|
|
153
|
+
|
|
154
|
+
// 3. External IP detection.
|
|
155
|
+
await this.detectIp();
|
|
156
|
+
|
|
157
|
+
// 4. DDNS updater.
|
|
158
|
+
this.ddnsUpdater = new DdnsUpdater({
|
|
159
|
+
settings: this.currentSettings.ddns,
|
|
160
|
+
secrets: this.secretsStore,
|
|
161
|
+
getExternalIp: () => this.latestIp?.publicIp ?? this.latestIp?.routerIp ?? null,
|
|
162
|
+
fetch: this.fetchImpl,
|
|
163
|
+
now: this.nowFn,
|
|
164
|
+
});
|
|
165
|
+
await this.ddnsUpdater.start();
|
|
166
|
+
|
|
167
|
+
// Initial invite-address push. Retries on `node_unavailable` until the
|
|
168
|
+
// freshly spawned owner node accepts it (bounded; best-effort after).
|
|
169
|
+
await this.pushInitialAddresses();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Release the port mapping and clear timers. Idempotent. */
|
|
173
|
+
async stop(): Promise<void> {
|
|
174
|
+
if (!this.started) return;
|
|
175
|
+
this.started = false;
|
|
176
|
+
try {
|
|
177
|
+
this.ddnsUpdater?.stop();
|
|
178
|
+
} catch (err) {
|
|
179
|
+
log('ddns stop failed: %s', (err as Error).message);
|
|
180
|
+
}
|
|
181
|
+
this.ddnsUpdater = null;
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
await this.portService?.stop();
|
|
185
|
+
} catch (err) {
|
|
186
|
+
log('port-service stop failed: %s', (err as Error).message);
|
|
187
|
+
}
|
|
188
|
+
this.portService = null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Cheap read of the current snapshot. */
|
|
192
|
+
getStatus(): NatStatusSnapshot {
|
|
193
|
+
const portState = this.portService?.getState() ?? this.disabledPortState();
|
|
194
|
+
const externalIp = this.latestIp?.publicIp ?? this.latestIp?.routerIp ?? null;
|
|
195
|
+
const ddns: NatDdnsStatus = this.ddnsUpdater?.getStatus() ?? {
|
|
196
|
+
providerId: this.currentSettings.ddns.providerId,
|
|
197
|
+
hostname: this.currentSettings.ddns.hostname,
|
|
198
|
+
externallyManaged: this.currentSettings.ddns.externallyManaged,
|
|
199
|
+
lastUpdateAt: null,
|
|
200
|
+
lastUpdateOk: null,
|
|
201
|
+
lastError: null,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
portMode: this.currentSettings.upnpEnabled ? portState.mode : 'disabled',
|
|
206
|
+
externalPort: portState.externalPort,
|
|
207
|
+
internalPort: portState.internalPort,
|
|
208
|
+
routerExternalIp: portState.routerExternalIp,
|
|
209
|
+
mappingLeaseExpiresAt: portState.leaseExpiresAt?.toISOString() ?? null,
|
|
210
|
+
externalIp,
|
|
211
|
+
externalIpDetectedAt: this.latestIp?.detectedAt.toISOString() ?? null,
|
|
212
|
+
cgnatDetected: this.latestIp?.cgnat ?? false,
|
|
213
|
+
directReachability: evaluateReachability({
|
|
214
|
+
portMode: portState.mode,
|
|
215
|
+
cgnatDetected: this.latestIp?.cgnat ?? false,
|
|
216
|
+
}),
|
|
217
|
+
lastTestedAt: this.lastTestedAt?.toISOString() ?? null,
|
|
218
|
+
ddns,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Re-run the IP probe + port-mapper verify, then return a fresh snapshot. */
|
|
223
|
+
async testReachability(): Promise<NatStatusSnapshot> {
|
|
224
|
+
if (this.portService) {
|
|
225
|
+
await this.portService.refresh().catch((err) => log('refresh err: %s', (err as Error).message));
|
|
226
|
+
}
|
|
227
|
+
await this.detectIp();
|
|
228
|
+
this.lastTestedAt = this.nowFn();
|
|
229
|
+
// Best-effort DDNS push when the IP just changed.
|
|
230
|
+
if (this.ddnsUpdater) {
|
|
231
|
+
await this.ddnsUpdater.forceUpdate().catch((err) => log('ddns forceUpdate err: %s', (err as Error).message));
|
|
232
|
+
}
|
|
233
|
+
// Reachability/IP may have changed → invite addresses may have changed.
|
|
234
|
+
await this.fireAddressesChanged();
|
|
235
|
+
return this.getStatus();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Build the multiaddrs to embed in invites. The peer ID and libp2p
|
|
240
|
+
* fallback addresses are fetched over the owner node's admin channel; a
|
|
241
|
+
* node-unavailable failure surfaces as `NatError('node_unavailable')`.
|
|
242
|
+
*/
|
|
243
|
+
async getInviteAddresses(): Promise<string[]> {
|
|
244
|
+
let peerId: string;
|
|
245
|
+
let libp2pAddrs: string[];
|
|
246
|
+
try {
|
|
247
|
+
peerId = await this.cadreNode.getPeerId();
|
|
248
|
+
libp2pAddrs = await this.cadreNode.getMultiaddrs();
|
|
249
|
+
} catch (err) {
|
|
250
|
+
if (err instanceof OwnerNodeUnavailableError) {
|
|
251
|
+
throw new NatError('node_unavailable', `Owner node unavailable: ${err.message}`);
|
|
252
|
+
}
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
// A reachable node that hasn't yet established its libp2p peer ID reports
|
|
256
|
+
// an empty peerId. Building addresses now would mint malformed `…/p2p/`
|
|
257
|
+
// suffixes (and push them to the node), so treat it as not-ready.
|
|
258
|
+
if (!peerId) {
|
|
259
|
+
throw new NatError('node_unavailable', 'Owner node has no peer ID yet (not ready)');
|
|
260
|
+
}
|
|
261
|
+
const status = this.getStatus();
|
|
262
|
+
const input: BuildInviteAddressesInput = {
|
|
263
|
+
peerId,
|
|
264
|
+
externalPort: status.externalPort,
|
|
265
|
+
ddnsHostname: status.ddns.hostname,
|
|
266
|
+
externalIp: status.externalIp,
|
|
267
|
+
reachable: status.directReachability === 'reachable',
|
|
268
|
+
libp2pAddrs,
|
|
269
|
+
};
|
|
270
|
+
return buildInviteAddresses(input);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Register a listener fired when the invite addresses may have changed
|
|
275
|
+
* (after `start`, `putSettings`, and `testReachability`). `cadre-host start`
|
|
276
|
+
* wires this to `OwnerNodeClient.pushInviteAddresses`. Returns an
|
|
277
|
+
* unsubscribe fn.
|
|
278
|
+
*/
|
|
279
|
+
onAddressesChanged(listener: AddressesChangedListener): () => void {
|
|
280
|
+
this.addressListeners.add(listener);
|
|
281
|
+
return () => { this.addressListeners.delete(listener); };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Recompute invite addresses and notify listeners. Best-effort: when the
|
|
286
|
+
* owner node is unreachable (or the build fails), the notification is
|
|
287
|
+
* skipped — the push is retried on the next NAT event.
|
|
288
|
+
*/
|
|
289
|
+
private async fireAddressesChanged(): Promise<void> {
|
|
290
|
+
if (this.addressListeners.size === 0) return;
|
|
291
|
+
let addresses: string[];
|
|
292
|
+
try {
|
|
293
|
+
addresses = await this.getInviteAddresses();
|
|
294
|
+
} catch (err) {
|
|
295
|
+
log('onAddressesChanged skipped: %s', (err as Error).message);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
await this.notifyAddressListeners(addresses);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Deliver an address set to every registered listener (errors are logged, not thrown). */
|
|
302
|
+
private async notifyAddressListeners(addresses: string[]): Promise<void> {
|
|
303
|
+
for (const listener of this.addressListeners) {
|
|
304
|
+
try {
|
|
305
|
+
await listener(addresses);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
log('address listener threw: %s', (err as Error).message);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Initial invite-address push. The owner node's admin channel is a freshly
|
|
314
|
+
* spawned detached child that may not be bound yet (and may not have a libp2p
|
|
315
|
+
* peer ID yet), so a single attempt races readiness and silently drops the first
|
|
316
|
+
* NAT-resolved address set. Retry on `node_unavailable` until the node accepts it
|
|
317
|
+
* or the bounded budget elapses (best-effort thereafter). Awaited by start() so
|
|
318
|
+
* the management API — the invite-minting path — does not come up first.
|
|
319
|
+
*/
|
|
320
|
+
private async pushInitialAddresses(): Promise<void> {
|
|
321
|
+
if (this.addressListeners.size === 0) return;
|
|
322
|
+
const deadline = this.nowFn().getTime() + this.initialPushTimeoutMs;
|
|
323
|
+
for (;;) {
|
|
324
|
+
if (!this.started) return; // stop() ran during a retry
|
|
325
|
+
try {
|
|
326
|
+
const addresses = await this.getInviteAddresses();
|
|
327
|
+
await this.notifyAddressListeners(addresses);
|
|
328
|
+
return;
|
|
329
|
+
} catch (err) {
|
|
330
|
+
const code = err instanceof NatError ? err.code : undefined;
|
|
331
|
+
// Only retry transient not-ready; surface anything unexpected via log + give up.
|
|
332
|
+
if (code !== 'node_unavailable' || this.nowFn().getTime() >= deadline) {
|
|
333
|
+
log('initial invite-address push not delivered: %s', (err as Error).message);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
await sleep(this.initialPushRetryMs);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Current settings (cheap read; for handlers to surface to UI). */
|
|
342
|
+
getSettings(): NatSettingsFile {
|
|
343
|
+
return this.currentSettings;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Replace settings; persists to disk and reconfigures the runtime. */
|
|
347
|
+
async putSettings(patch: Partial<Omit<NatSettingsFile, 'version'>>): Promise<NatStatusSnapshot> {
|
|
348
|
+
const next = this.store.update(patch);
|
|
349
|
+
const upnpChanged = next.upnpEnabled !== this.currentSettings.upnpEnabled;
|
|
350
|
+
const portChanged =
|
|
351
|
+
next.externalPort !== this.currentSettings.externalPort ||
|
|
352
|
+
next.internalPort !== this.currentSettings.internalPort;
|
|
353
|
+
const ddnsChanged =
|
|
354
|
+
next.ddns.providerId !== this.currentSettings.ddns.providerId ||
|
|
355
|
+
next.ddns.hostname !== this.currentSettings.ddns.hostname ||
|
|
356
|
+
next.ddns.externallyManaged !== this.currentSettings.ddns.externallyManaged ||
|
|
357
|
+
next.ddns.intervalMs !== this.currentSettings.ddns.intervalMs;
|
|
358
|
+
this.currentSettings = next;
|
|
359
|
+
|
|
360
|
+
if (this.started) {
|
|
361
|
+
if (upnpChanged || portChanged) {
|
|
362
|
+
try { await this.portService?.stop(); } catch { /* ignore */ }
|
|
363
|
+
this.portService = null;
|
|
364
|
+
await this.startPortService();
|
|
365
|
+
}
|
|
366
|
+
if (ddnsChanged && this.ddnsUpdater) {
|
|
367
|
+
await this.ddnsUpdater.updateSettings(next.ddns);
|
|
368
|
+
}
|
|
369
|
+
if (upnpChanged || portChanged || ddnsChanged) {
|
|
370
|
+
// Port / DDNS changes alter the advertised invite addresses.
|
|
371
|
+
await this.fireAddressesChanged();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return this.getStatus();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Set DDNS configuration and persist secret values into the SecretsStore.
|
|
379
|
+
* Throws if the provider is unknown or required secrets are missing.
|
|
380
|
+
*/
|
|
381
|
+
async putDdns(body: {
|
|
382
|
+
providerId: string;
|
|
383
|
+
hostname: string;
|
|
384
|
+
config: Record<string, string>;
|
|
385
|
+
externallyManaged?: boolean;
|
|
386
|
+
}): Promise<NatStatusSnapshot> {
|
|
387
|
+
if (!body || typeof body.providerId !== 'string' || typeof body.hostname !== 'string') {
|
|
388
|
+
throw new NatError('invalid_config', 'providerId and hostname are required');
|
|
389
|
+
}
|
|
390
|
+
const provider = getProvider(body.providerId);
|
|
391
|
+
if (!this.secretsStore) {
|
|
392
|
+
throw new NatError('secrets_unavailable', 'secrets store not initialised; call start() first');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Persist secrets.
|
|
396
|
+
for (const field of provider.configFields) {
|
|
397
|
+
if (!field.secret) continue;
|
|
398
|
+
const value = body.config[field.key];
|
|
399
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
400
|
+
throw new NatError(
|
|
401
|
+
'ddns_credentials_missing',
|
|
402
|
+
`Missing value for required secret field "${field.key}" of provider "${provider.id}"`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
await this.secretsStore.set(ddnsAccount(provider.id, field.key), value);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Update nat.json.
|
|
409
|
+
const externallyManaged = body.externallyManaged ?? false;
|
|
410
|
+
return await this.putSettings({
|
|
411
|
+
ddns: {
|
|
412
|
+
providerId: provider.id,
|
|
413
|
+
hostname: body.hostname,
|
|
414
|
+
externallyManaged,
|
|
415
|
+
intervalMs: this.currentSettings.ddns.intervalMs,
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** List available DDNS providers (UI listing for configuration). */
|
|
421
|
+
listDdnsProviders(): DdnsProviderInfo[] {
|
|
422
|
+
return listProviders().map((p) => ({
|
|
423
|
+
id: p.id,
|
|
424
|
+
displayName: p.displayName,
|
|
425
|
+
configFields: p.configFields,
|
|
426
|
+
}));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --- private ---
|
|
430
|
+
|
|
431
|
+
private async startPortService(): Promise<void> {
|
|
432
|
+
if (!this.currentSettings.upnpEnabled) {
|
|
433
|
+
log('UPnP disabled by settings; skipping port mapping');
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
let mapper = this.portMapper;
|
|
437
|
+
if (!mapper) {
|
|
438
|
+
try {
|
|
439
|
+
mapper = await createDefaultPortMapper();
|
|
440
|
+
this.portMapper = mapper;
|
|
441
|
+
} catch (err) {
|
|
442
|
+
log('default port mapper unavailable: %s', (err as Error).message);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
this.portService = new PortMapperService({
|
|
447
|
+
mapper,
|
|
448
|
+
externalPort: this.currentSettings.externalPort,
|
|
449
|
+
internalPort: this.currentSettings.internalPort,
|
|
450
|
+
now: this.nowFn,
|
|
451
|
+
});
|
|
452
|
+
await this.portService.start();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private async detectIp(): Promise<void> {
|
|
456
|
+
// If the caller injected a detector (typically a test stub), use it as-is —
|
|
457
|
+
// don't second-guess them by stitching in the port mapper's router probe.
|
|
458
|
+
const detector = this.injectedDetector ?? (
|
|
459
|
+
this.portMapper
|
|
460
|
+
? new ExternalIpDetector({
|
|
461
|
+
fetch: this.fetchImpl,
|
|
462
|
+
now: this.nowFn,
|
|
463
|
+
routerProbe: async () => this.portMapper!.externalIp(),
|
|
464
|
+
})
|
|
465
|
+
: new ExternalIpDetector({
|
|
466
|
+
fetch: this.fetchImpl,
|
|
467
|
+
now: this.nowFn,
|
|
468
|
+
})
|
|
469
|
+
);
|
|
470
|
+
try {
|
|
471
|
+
this.latestIp = await detector.detect();
|
|
472
|
+
} catch (err) {
|
|
473
|
+
log('detectIp failed: %s', (err as Error).message);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private disabledPortState(): PortMapperState {
|
|
478
|
+
return {
|
|
479
|
+
mode: this.currentSettings.upnpEnabled ? 'failed' : 'disabled',
|
|
480
|
+
externalPort: this.currentSettings.externalPort,
|
|
481
|
+
internalPort: this.currentSettings.internalPort,
|
|
482
|
+
routerExternalIp: null,
|
|
483
|
+
leaseExpiresAt: null,
|
|
484
|
+
lastError: null,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Wrap a NatService into the typed handler shape consumed by
|
|
491
|
+
* `cadre-host-local-ui`. Mirrors `createTrustCircleHandlers`.
|
|
492
|
+
*/
|
|
493
|
+
export function createNatHandlers(service: NatService): NatHandlers {
|
|
494
|
+
return {
|
|
495
|
+
async getStatus() { return service.getStatus(); },
|
|
496
|
+
async testReachability() { return await service.testReachability(); },
|
|
497
|
+
async listDdnsProviders() { return service.listDdnsProviders(); },
|
|
498
|
+
async putDdns(body) { return await service.putDdns(body); },
|
|
499
|
+
async putSettings(body) { return await service.putSettings(body); },
|
|
500
|
+
};
|
|
501
|
+
}
|