@serfab/cadre-host 0.7.1
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 +101 -0
- package/dist/auth/duration.d.ts +1 -0
- package/dist/auth/duration.js +32 -0
- package/dist/auth/duration.js.map +1 -0
- package/dist/auth/index.d.ts +9 -0
- package/dist/auth/index.js +8 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/trust-circle-store.d.ts +26 -0
- package/dist/auth/trust-circle-store.js +129 -0
- package/dist/auth/trust-circle-store.js.map +1 -0
- package/dist/auth/trust-circle.d.ts +91 -0
- package/dist/auth/trust-circle.js +230 -0
- package/dist/auth/trust-circle.js.map +1 -0
- package/dist/auth/types.d.ts +81 -0
- package/dist/auth/types.js +24 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/bin/host.d.ts +13 -0
- package/dist/bin/host.js +826 -0
- package/dist/bin/host.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/installer/browser.d.ts +14 -0
- package/dist/installer/browser.js +35 -0
- package/dist/installer/browser.js.map +1 -0
- package/dist/installer/config.d.ts +43 -0
- package/dist/installer/config.js +113 -0
- package/dist/installer/config.js.map +1 -0
- package/dist/installer/identity.d.ts +23 -0
- package/dist/installer/identity.js +36 -0
- package/dist/installer/identity.js.map +1 -0
- package/dist/installer/index.d.ts +83 -0
- package/dist/installer/index.js +204 -0
- package/dist/installer/index.js.map +1 -0
- package/dist/installer/paths.d.ts +33 -0
- package/dist/installer/paths.js +84 -0
- package/dist/installer/paths.js.map +1 -0
- package/dist/installer/platform.d.ts +9 -0
- package/dist/installer/platform.js +21 -0
- package/dist/installer/platform.js.map +1 -0
- package/dist/installer/service-host/index.d.ts +11 -0
- package/dist/installer/service-host/index.js +16 -0
- package/dist/installer/service-host/index.js.map +1 -0
- package/dist/installer/service-host/launchd.d.ts +16 -0
- package/dist/installer/service-host/launchd.js +79 -0
- package/dist/installer/service-host/launchd.js.map +1 -0
- package/dist/installer/service-host/nssm.d.ts +20 -0
- package/dist/installer/service-host/nssm.js +88 -0
- package/dist/installer/service-host/nssm.js.map +1 -0
- package/dist/installer/service-host/systemd.d.ts +16 -0
- package/dist/installer/service-host/systemd.js +78 -0
- package/dist/installer/service-host/systemd.js.map +1 -0
- package/dist/installer/service-host/template.d.ts +6 -0
- package/dist/installer/service-host/template.js +20 -0
- package/dist/installer/service-host/template.js.map +1 -0
- package/dist/installer/service-host/types.d.ts +40 -0
- package/dist/installer/service-host/types.js +6 -0
- package/dist/installer/service-host/types.js.map +1 -0
- package/dist/installer/wizard.d.ts +31 -0
- package/dist/installer/wizard.js +67 -0
- package/dist/installer/wizard.js.map +1 -0
- package/dist/nat/address-resolver.d.ts +22 -0
- package/dist/nat/address-resolver.js +31 -0
- package/dist/nat/address-resolver.js.map +1 -0
- package/dist/nat/ddns/duckdns.d.ts +12 -0
- package/dist/nat/ddns/duckdns.js +57 -0
- package/dist/nat/ddns/duckdns.js.map +1 -0
- package/dist/nat/ddns/index.d.ts +30 -0
- package/dist/nat/ddns/index.js +21 -0
- package/dist/nat/ddns/index.js.map +1 -0
- package/dist/nat/ddns/updater.d.ts +68 -0
- package/dist/nat/ddns/updater.js +194 -0
- package/dist/nat/ddns/updater.js.map +1 -0
- package/dist/nat/external-ip.d.ts +62 -0
- package/dist/nat/external-ip.js +114 -0
- package/dist/nat/external-ip.js.map +1 -0
- package/dist/nat/index.d.ts +22 -0
- package/dist/nat/index.js +16 -0
- package/dist/nat/index.js.map +1 -0
- package/dist/nat/nat-service.d.ts +101 -0
- package/dist/nat/nat-service.js +284 -0
- package/dist/nat/nat-service.js.map +1 -0
- package/dist/nat/nat-store.d.ts +19 -0
- package/dist/nat/nat-store.js +136 -0
- package/dist/nat/nat-store.js.map +1 -0
- package/dist/nat/port-mapper.d.ts +98 -0
- package/dist/nat/port-mapper.js +226 -0
- package/dist/nat/port-mapper.js.map +1 -0
- package/dist/nat/reachability.d.ts +19 -0
- package/dist/nat/reachability.js +29 -0
- package/dist/nat/reachability.js.map +1 -0
- package/dist/nat/secrets/file-store.d.ts +23 -0
- package/dist/nat/secrets/file-store.js +102 -0
- package/dist/nat/secrets/file-store.js.map +1 -0
- package/dist/nat/secrets/index.d.ts +36 -0
- package/dist/nat/secrets/index.js +56 -0
- package/dist/nat/secrets/index.js.map +1 -0
- package/dist/nat/secrets/keytar-store.d.ts +18 -0
- package/dist/nat/secrets/keytar-store.js +52 -0
- package/dist/nat/secrets/keytar-store.js.map +1 -0
- package/dist/nat/types.d.ts +120 -0
- package/dist/nat/types.js +24 -0
- package/dist/nat/types.js.map +1 -0
- package/dist/orchestrator/host-process-orchestrator.d.ts +87 -0
- package/dist/orchestrator/host-process-orchestrator.js +557 -0
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -0
- package/dist/orchestrator/index.d.ts +3 -0
- package/dist/orchestrator/index.js +2 -0
- package/dist/orchestrator/index.js.map +1 -0
- package/dist/orchestrator/log-rotator.d.ts +25 -0
- package/dist/orchestrator/log-rotator.js +56 -0
- package/dist/orchestrator/log-rotator.js.map +1 -0
- package/dist/orchestrator/pid-liveness.d.ts +11 -0
- package/dist/orchestrator/pid-liveness.js +26 -0
- package/dist/orchestrator/pid-liveness.js.map +1 -0
- package/dist/orchestrator/port-allocator.d.ts +16 -0
- package/dist/orchestrator/port-allocator.js +38 -0
- package/dist/orchestrator/port-allocator.js.map +1 -0
- package/dist/orchestrator/state-store.d.ts +32 -0
- package/dist/orchestrator/state-store.js +44 -0
- package/dist/orchestrator/state-store.js.map +1 -0
- package/dist/orchestrator/types.d.ts +85 -0
- package/dist/orchestrator/types.js +31 -0
- package/dist/orchestrator/types.js.map +1 -0
- package/dist/server/error-handler.d.ts +26 -0
- package/dist/server/error-handler.js +95 -0
- package/dist/server/error-handler.js.map +1 -0
- package/dist/server/events/bus.d.ts +17 -0
- package/dist/server/events/bus.js +36 -0
- package/dist/server/events/bus.js.map +1 -0
- package/dist/server/events/sse-route.d.ts +22 -0
- package/dist/server/events/sse-route.js +65 -0
- package/dist/server/events/sse-route.js.map +1 -0
- package/dist/server/events/types.d.ts +25 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/events/types.js.map +1 -0
- package/dist/server/index.d.ts +67 -0
- package/dist/server/index.js +149 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/origin-guard.d.ts +24 -0
- package/dist/server/origin-guard.js +72 -0
- package/dist/server/origin-guard.js.map +1 -0
- package/dist/server/routes/nat.d.ts +16 -0
- package/dist/server/routes/nat.js +42 -0
- package/dist/server/routes/nat.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +23 -0
- package/dist/server/routes/nodes.js +128 -0
- package/dist/server/routes/nodes.js.map +1 -0
- package/dist/server/routes/settings.d.ts +26 -0
- package/dist/server/routes/settings.js +97 -0
- package/dist/server/routes/settings.js.map +1 -0
- package/dist/server/routes/status.d.ts +42 -0
- package/dist/server/routes/status.js +76 -0
- package/dist/server/routes/status.js.map +1 -0
- package/dist/server/routes/trust-circle.d.ts +17 -0
- package/dist/server/routes/trust-circle.js +35 -0
- package/dist/server/routes/trust-circle.js.map +1 -0
- package/dist/server/routes/update.d.ts +16 -0
- package/dist/server/routes/update.js +29 -0
- package/dist/server/routes/update.js.map +1 -0
- package/dist/server/server.d.ts +40 -0
- package/dist/server/server.js +90 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/settings-store.d.ts +24 -0
- package/dist/server/settings-store.js +27 -0
- package/dist/server/settings-store.js.map +1 -0
- package/dist/server/static.d.ts +25 -0
- package/dist/server/static.js +177 -0
- package/dist/server/static.js.map +1 -0
- package/dist/ui/assets/index-BwU5mJGF.css +1 -0
- package/dist/ui/assets/index-C9uChk2P.js +14 -0
- package/dist/ui/assets/index-C9uChk2P.js.map +1 -0
- package/dist/ui/index.html +14 -0
- package/dist/update/apply.d.ts +37 -0
- package/dist/update/apply.js +102 -0
- package/dist/update/apply.js.map +1 -0
- package/dist/update/index.d.ts +101 -0
- package/dist/update/index.js +228 -0
- package/dist/update/index.js.map +1 -0
- package/dist/update/manifest.d.ts +48 -0
- package/dist/update/manifest.js +152 -0
- package/dist/update/manifest.js.map +1 -0
- package/dist/update/release-key.d.ts +24 -0
- package/dist/update/release-key.js +52 -0
- package/dist/update/release-key.js.map +1 -0
- package/dist/update/store.d.ts +22 -0
- package/dist/update/store.js +81 -0
- package/dist/update/store.js.map +1 -0
- package/dist/update/types.d.ts +90 -0
- package/dist/update/types.js +22 -0
- package/dist/update/types.js.map +1 -0
- package/dist/update/version.d.ts +18 -0
- package/dist/update/version.js +76 -0
- package/dist/update/version.js.map +1 -0
- package/package.json +80 -0
- package/service/README.md +86 -0
- package/service/cadre-host.service.tmpl +16 -0
- package/service/com.serfab.cadre-host.plist.tmpl +35 -0
- package/service/install-service.ps1 +98 -0
- package/service/uninstall-service.ps1 +19 -0
- package/src/__tests__/cli-invite.smoke.test.ts +187 -0
- package/src/__tests__/cli-nat.smoke.test.ts +207 -0
- package/src/__tests__/cli.smoke.test.ts +41 -0
- package/src/__tests__/orchestrator.test.ts +570 -0
- package/src/auth/__tests__/duration.test.ts +28 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +128 -0
- package/src/auth/__tests__/trust-circle-store.test.ts +115 -0
- package/src/auth/__tests__/trust-circle.test.ts +334 -0
- package/src/auth/duration.ts +33 -0
- package/src/auth/index.ts +19 -0
- package/src/auth/trust-circle-store.ts +155 -0
- package/src/auth/trust-circle.ts +304 -0
- package/src/auth/types.ts +96 -0
- package/src/bin/host.ts +905 -0
- package/src/index.ts +167 -0
- package/src/installer/__tests__/config.test.ts +86 -0
- package/src/installer/__tests__/identity.test.ts +34 -0
- package/src/installer/__tests__/installer.smoke.test.ts +124 -0
- package/src/installer/__tests__/paths.test.ts +47 -0
- package/src/installer/__tests__/service-host.test.ts +62 -0
- package/src/installer/__tests__/wizard.test.ts +60 -0
- package/src/installer/browser.ts +42 -0
- package/src/installer/config.ts +153 -0
- package/src/installer/identity.ts +49 -0
- package/src/installer/index.ts +303 -0
- package/src/installer/paths.ts +109 -0
- package/src/installer/platform.ts +27 -0
- package/src/installer/service-host/index.ts +21 -0
- package/src/installer/service-host/launchd.ts +90 -0
- package/src/installer/service-host/nssm.ts +100 -0
- package/src/installer/service-host/systemd.ts +88 -0
- package/src/installer/service-host/template.ts +21 -0
- package/src/installer/service-host/types.ts +48 -0
- package/src/installer/wizard.ts +92 -0
- package/src/nat/__tests__/address-resolver.test.ts +82 -0
- package/src/nat/__tests__/ddns-duckdns.test.ts +83 -0
- package/src/nat/__tests__/ddns-updater.test.ts +253 -0
- package/src/nat/__tests__/external-ip.test.ts +124 -0
- package/src/nat/__tests__/nat-service.test.ts +305 -0
- package/src/nat/__tests__/nat-store.test.ts +93 -0
- package/src/nat/__tests__/port-mapper.test.ts +191 -0
- package/src/nat/__tests__/reachability.test.ts +24 -0
- package/src/nat/__tests__/secrets.test.ts +156 -0
- package/src/nat/address-resolver.ts +51 -0
- package/src/nat/ddns/duckdns.ts +76 -0
- package/src/nat/ddns/index.ts +55 -0
- package/src/nat/ddns/updater.ts +243 -0
- package/src/nat/external-ip.ts +154 -0
- package/src/nat/index.ts +71 -0
- package/src/nat/nat-service.ts +375 -0
- package/src/nat/nat-store.ts +154 -0
- package/src/nat/port-mapper.ts +329 -0
- package/src/nat/reachability.ts +34 -0
- package/src/nat/secrets/file-store.ts +130 -0
- package/src/nat/secrets/index.ts +82 -0
- package/src/nat/secrets/keytar-store.ts +67 -0
- package/src/nat/types.ts +157 -0
- package/src/orchestrator/host-process-orchestrator.ts +626 -0
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/log-rotator.ts +53 -0
- package/src/orchestrator/pid-liveness.ts +24 -0
- package/src/orchestrator/port-allocator.ts +43 -0
- package/src/orchestrator/state-store.ts +72 -0
- package/src/orchestrator/types.ts +95 -0
- package/src/server/__tests__/bus.test.ts +38 -0
- package/src/server/__tests__/error-handler.test.ts +96 -0
- package/src/server/__tests__/nodes-route.test.ts +179 -0
- package/src/server/__tests__/origin-guard.test.ts +72 -0
- package/src/server/__tests__/publishers.test.ts +216 -0
- package/src/server/__tests__/server.smoke.test.ts +178 -0
- package/src/server/__tests__/settings-route.test.ts +177 -0
- package/src/server/__tests__/sse-route.test.ts +119 -0
- package/src/server/__tests__/status-route.test.ts +113 -0
- package/src/server/error-handler.ts +108 -0
- package/src/server/events/bus.ts +42 -0
- package/src/server/events/sse-route.ts +81 -0
- package/src/server/events/types.ts +22 -0
- package/src/server/index.ts +209 -0
- package/src/server/origin-guard.ts +84 -0
- package/src/server/routes/nat.ts +63 -0
- package/src/server/routes/nodes.ts +147 -0
- package/src/server/routes/settings.ts +115 -0
- package/src/server/routes/status.ts +108 -0
- package/src/server/routes/trust-circle.ts +49 -0
- package/src/server/routes/update.ts +43 -0
- package/src/server/server.ts +122 -0
- package/src/server/settings-store.ts +37 -0
- package/src/server/static.ts +191 -0
- package/src/update/__tests__/apply.test.ts +101 -0
- package/src/update/__tests__/manifest.test.ts +93 -0
- package/src/update/__tests__/store.test.ts +81 -0
- package/src/update/__tests__/update-service.integration.test.ts +221 -0
- package/src/update/__tests__/version.test.ts +31 -0
- package/src/update/apply.ts +148 -0
- package/src/update/index.ts +303 -0
- package/src/update/manifest.ts +172 -0
- package/src/update/release-key.ts +55 -0
- package/src/update/store.ts +101 -0
- package/src/update/types.ts +109 -0
- package/src/update/version.ts +80 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launchd LaunchAgent registration.
|
|
3
|
+
*
|
|
4
|
+
* Renders the .plist into `~/Library/LaunchAgents/com.serfab.cadre-host.plist`,
|
|
5
|
+
* `launchctl bootstrap`s it into the gui session, then `kickstart -k` to
|
|
6
|
+
* start (or restart) it. Uninstall uses `bootout` (the modern unload).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { dirname } from 'node:path';
|
|
11
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
12
|
+
import debug from 'debug';
|
|
13
|
+
|
|
14
|
+
import { renderTemplate } from './template.js';
|
|
15
|
+
import { logsDir, serviceInstallPath } from '../paths.js';
|
|
16
|
+
import type { ServiceHost, ServiceHostContext, ServiceHostStatus } from './types.js';
|
|
17
|
+
|
|
18
|
+
const log = debug('cadre:host:installer:launchd');
|
|
19
|
+
|
|
20
|
+
const SERVICE_NAME = 'cadre-host';
|
|
21
|
+
const LABEL = 'com.serfab.cadre-host';
|
|
22
|
+
|
|
23
|
+
export class LaunchdServiceHost implements ServiceHost {
|
|
24
|
+
readonly name = SERVICE_NAME;
|
|
25
|
+
|
|
26
|
+
renderUnit(ctx: ServiceHostContext): string {
|
|
27
|
+
const tmpl = readFileSync(`${ctx.serviceDir}/com.serfab.cadre-host.plist.tmpl`, 'utf8');
|
|
28
|
+
return renderTemplate(tmpl, {
|
|
29
|
+
NODE_PATH: ctx.nodePath,
|
|
30
|
+
HOST_JS: ctx.hostJs,
|
|
31
|
+
DATA_DIR: ctx.dataDir,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async install(ctx: ServiceHostContext): Promise<void> {
|
|
36
|
+
const unitPath = serviceInstallPath('darwin');
|
|
37
|
+
const unit = this.renderUnit(ctx);
|
|
38
|
+
|
|
39
|
+
mkdirSync(logsDir(ctx.dataDir), { recursive: true });
|
|
40
|
+
mkdirSync(dirname(unitPath), { recursive: true });
|
|
41
|
+
writeFileSync(unitPath, unit, { mode: 0o644 });
|
|
42
|
+
log('wrote %s', unitPath);
|
|
43
|
+
|
|
44
|
+
const target = guiTarget();
|
|
45
|
+
// bootout first to make install idempotent. May fail if it wasn't
|
|
46
|
+
// loaded — that's fine.
|
|
47
|
+
spawnSync('launchctl', ['bootout', target, unitPath], { stdio: 'ignore' });
|
|
48
|
+
run('launchctl', ['bootstrap', target, unitPath]);
|
|
49
|
+
run('launchctl', ['kickstart', '-k', `${target}/${LABEL}`]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async uninstall(ctx: ServiceHostContext): Promise<void> {
|
|
53
|
+
const unitPath = serviceInstallPath('darwin');
|
|
54
|
+
if (existsSync(unitPath)) {
|
|
55
|
+
const target = guiTarget();
|
|
56
|
+
spawnSync('launchctl', ['bootout', target, unitPath], { stdio: 'inherit' });
|
|
57
|
+
rmSync(unitPath, { force: true });
|
|
58
|
+
log('removed %s', unitPath);
|
|
59
|
+
}
|
|
60
|
+
void ctx;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async restart(ctx: ServiceHostContext): Promise<void> {
|
|
64
|
+
void ctx;
|
|
65
|
+
const target = guiTarget();
|
|
66
|
+
run('launchctl', ['kickstart', '-k', `${target}/${LABEL}`]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async status(ctx: ServiceHostContext): Promise<ServiceHostStatus> {
|
|
70
|
+
void ctx;
|
|
71
|
+
const unitPath = serviceInstallPath('darwin');
|
|
72
|
+
const installed = existsSync(unitPath);
|
|
73
|
+
const target = guiTarget();
|
|
74
|
+
const result = spawnSync('launchctl', ['print', `${target}/${LABEL}`], { encoding: 'utf8' });
|
|
75
|
+
const running = result.status === 0 && /state\s*=\s*running/i.test(result.stdout);
|
|
76
|
+
return { installed, running };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function guiTarget(): string {
|
|
81
|
+
return `gui/${process.getuid?.() ?? ''}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function run(cmd: string, args: string[]): void {
|
|
85
|
+
try {
|
|
86
|
+
execFileSync(cmd, args, { stdio: 'inherit' });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw new Error(`${cmd} ${args.join(' ')} failed: ${(err as Error).message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NSSM-backed Windows service.
|
|
3
|
+
*
|
|
4
|
+
* Delegates to the bundled PowerShell scripts (`install-service.ps1` /
|
|
5
|
+
* `uninstall-service.ps1`) which encapsulate the NSSM CLI dance and the
|
|
6
|
+
* stdout/stderr log redirection.
|
|
7
|
+
*
|
|
8
|
+
* Bundling NSSM itself with the npm package is out of scope for v1 — the
|
|
9
|
+
* standalone-binary distribution will ship it (see backlog).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import debug from 'debug';
|
|
16
|
+
|
|
17
|
+
import type { ServiceHost, ServiceHostContext, ServiceHostStatus } from './types.js';
|
|
18
|
+
|
|
19
|
+
const log = debug('cadre:host:installer:nssm');
|
|
20
|
+
|
|
21
|
+
const SERVICE_NAME = 'CadreHost';
|
|
22
|
+
|
|
23
|
+
export class NssmServiceHost implements ServiceHost {
|
|
24
|
+
readonly name = SERVICE_NAME;
|
|
25
|
+
|
|
26
|
+
/** No template to render — NSSM stores its config in the registry. */
|
|
27
|
+
renderUnit(ctx: ServiceHostContext): string | null {
|
|
28
|
+
void ctx;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async install(ctx: ServiceHostContext): Promise<void> {
|
|
33
|
+
requireNssmAvailable();
|
|
34
|
+
const script = join(ctx.serviceDir, 'install-service.ps1');
|
|
35
|
+
if (!existsSync(script)) {
|
|
36
|
+
throw new Error(`install-service.ps1 not found at ${script}`);
|
|
37
|
+
}
|
|
38
|
+
runPwsh([
|
|
39
|
+
'-ExecutionPolicy', 'Bypass',
|
|
40
|
+
'-NoProfile',
|
|
41
|
+
'-File', script,
|
|
42
|
+
'-NodePath', ctx.nodePath,
|
|
43
|
+
'-HostJs', ctx.hostJs,
|
|
44
|
+
'-DataDir', ctx.dataDir,
|
|
45
|
+
]);
|
|
46
|
+
// Start the service after install — install-service.ps1 sets
|
|
47
|
+
// SERVICE_AUTO_START but doesn't run it immediately.
|
|
48
|
+
runPwsh(['-ExecutionPolicy', 'Bypass', '-NoProfile', '-Command', `nssm.exe start ${SERVICE_NAME}`]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async uninstall(ctx: ServiceHostContext): Promise<void> {
|
|
52
|
+
requireNssmAvailable();
|
|
53
|
+
const script = join(ctx.serviceDir, 'uninstall-service.ps1');
|
|
54
|
+
if (!existsSync(script)) {
|
|
55
|
+
// Fall back to a direct nssm remove if the script isn't there.
|
|
56
|
+
runPwsh(['-ExecutionPolicy', 'Bypass', '-NoProfile', '-Command', `nssm.exe stop ${SERVICE_NAME} confirm; nssm.exe remove ${SERVICE_NAME} confirm`]);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
runPwsh(['-ExecutionPolicy', 'Bypass', '-NoProfile', '-File', script]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async restart(ctx: ServiceHostContext): Promise<void> {
|
|
63
|
+
void ctx;
|
|
64
|
+
requireNssmAvailable();
|
|
65
|
+
runPwsh(['-ExecutionPolicy', 'Bypass', '-NoProfile', '-Command', `nssm.exe restart ${SERVICE_NAME}`]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async status(ctx: ServiceHostContext): Promise<ServiceHostStatus> {
|
|
69
|
+
void ctx;
|
|
70
|
+
const statusResult = spawnSync('nssm.exe', ['status', SERVICE_NAME], { encoding: 'utf8' });
|
|
71
|
+
if (statusResult.error || statusResult.status !== 0) {
|
|
72
|
+
return { installed: false, running: false };
|
|
73
|
+
}
|
|
74
|
+
const stateLine = statusResult.stdout.trim();
|
|
75
|
+
return { installed: true, running: /SERVICE_RUNNING/i.test(stateLine) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function requireNssmAvailable(): void {
|
|
80
|
+
const probe = spawnSync('nssm.exe', ['--version'], { encoding: 'utf8' });
|
|
81
|
+
if (probe.error) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'nssm.exe not found on PATH. Install NSSM from https://nssm.cc/download ' +
|
|
84
|
+
'and add it to PATH, then re-run `cadre-host install`.',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function runPwsh(args: string[]): void {
|
|
90
|
+
// powershell.exe is the in-box Windows PowerShell 5.1 — present on every
|
|
91
|
+
// supported Windows version. pwsh.exe (PowerShell 7) is an optional install.
|
|
92
|
+
const result = spawnSync('powershell.exe', args, { stdio: 'inherit' });
|
|
93
|
+
if (result.error) {
|
|
94
|
+
throw new Error(`powershell.exe failed: ${result.error.message}`);
|
|
95
|
+
}
|
|
96
|
+
if (result.status !== 0) {
|
|
97
|
+
log('powershell.exe exit %s for args %j', result.status, args);
|
|
98
|
+
throw new Error(`powershell.exe exited with status ${result.status}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* systemd `--user` unit registration.
|
|
3
|
+
*
|
|
4
|
+
* Renders the template into `~/.config/systemd/user/cadre-host.service`,
|
|
5
|
+
* runs `systemctl --user daemon-reload`, `enable --now cadre-host`, and
|
|
6
|
+
* `loginctl enable-linger <user>` so the unit survives logout / reboot.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
10
|
+
import { dirname } from 'node:path';
|
|
11
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
12
|
+
import { userInfo } from 'node:os';
|
|
13
|
+
import debug from 'debug';
|
|
14
|
+
|
|
15
|
+
import { renderTemplate } from './template.js';
|
|
16
|
+
import { serviceInstallPath } from '../paths.js';
|
|
17
|
+
import type { ServiceHost, ServiceHostContext, ServiceHostStatus } from './types.js';
|
|
18
|
+
|
|
19
|
+
const log = debug('cadre:host:installer:systemd');
|
|
20
|
+
|
|
21
|
+
const SERVICE_NAME = 'cadre-host';
|
|
22
|
+
|
|
23
|
+
export class SystemdServiceHost implements ServiceHost {
|
|
24
|
+
readonly name = SERVICE_NAME;
|
|
25
|
+
|
|
26
|
+
renderUnit(ctx: ServiceHostContext): string {
|
|
27
|
+
const tmpl = readFileSync(`${ctx.serviceDir}/cadre-host.service.tmpl`, 'utf8');
|
|
28
|
+
return renderTemplate(tmpl, {
|
|
29
|
+
NODE_PATH: ctx.nodePath,
|
|
30
|
+
HOST_JS: ctx.hostJs,
|
|
31
|
+
DATA_DIR: ctx.dataDir,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async install(ctx: ServiceHostContext): Promise<void> {
|
|
36
|
+
const unitPath = serviceInstallPath('linux');
|
|
37
|
+
const unit = this.renderUnit(ctx);
|
|
38
|
+
|
|
39
|
+
mkdirSync(dirname(unitPath), { recursive: true });
|
|
40
|
+
writeFileSync(unitPath, unit, { mode: 0o644 });
|
|
41
|
+
log('wrote %s', unitPath);
|
|
42
|
+
|
|
43
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
44
|
+
run('systemctl', ['--user', 'enable', '--now', SERVICE_NAME]);
|
|
45
|
+
// enable-linger is opportunistic — it requires `loginctl` and may
|
|
46
|
+
// fail in containers / minimal images. Don't kill the install for it.
|
|
47
|
+
const user = userInfo().username;
|
|
48
|
+
const linger = spawnSync('loginctl', ['enable-linger', user], { stdio: 'inherit' });
|
|
49
|
+
if (linger.status !== 0) {
|
|
50
|
+
log('loginctl enable-linger %s failed (status=%s) — service may not run after logout', user, linger.status);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async uninstall(ctx: ServiceHostContext): Promise<void> {
|
|
55
|
+
const unitPath = serviceInstallPath('linux');
|
|
56
|
+
// Stop + disable even if the unit file is missing — covers manual cleanup.
|
|
57
|
+
spawnSync('systemctl', ['--user', 'stop', SERVICE_NAME], { stdio: 'inherit' });
|
|
58
|
+
spawnSync('systemctl', ['--user', 'disable', SERVICE_NAME], { stdio: 'inherit' });
|
|
59
|
+
if (existsSync(unitPath)) {
|
|
60
|
+
rmSync(unitPath, { force: true });
|
|
61
|
+
log('removed %s', unitPath);
|
|
62
|
+
}
|
|
63
|
+
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
|
|
64
|
+
void ctx;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async restart(ctx: ServiceHostContext): Promise<void> {
|
|
68
|
+
void ctx;
|
|
69
|
+
run('systemctl', ['--user', 'restart', SERVICE_NAME]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async status(ctx: ServiceHostContext): Promise<ServiceHostStatus> {
|
|
73
|
+
void ctx;
|
|
74
|
+
const unitPath = serviceInstallPath('linux');
|
|
75
|
+
const installed = existsSync(unitPath);
|
|
76
|
+
const result = spawnSync('systemctl', ['--user', 'is-active', SERVICE_NAME], { encoding: 'utf8' });
|
|
77
|
+
const running = result.status === 0 && result.stdout.trim() === 'active';
|
|
78
|
+
return { installed, running };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function run(cmd: string, args: string[]): void {
|
|
83
|
+
try {
|
|
84
|
+
execFileSync(cmd, args, { stdio: 'inherit' });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw new Error(`${cmd} ${args.join(' ')} failed: ${(err as Error).message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny `@TOKEN@` substitution. Used to render systemd / launchd unit
|
|
3
|
+
* files. Throws if any tokens are left after substitution to catch
|
|
4
|
+
* typos at install time rather than at service-startup time.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const TOKEN_RE = /@([A-Z][A-Z0-9_]*)@/g;
|
|
8
|
+
|
|
9
|
+
export function renderTemplate(template: string, tokens: Readonly<Record<string, string>>): string {
|
|
10
|
+
const rendered = template.replace(TOKEN_RE, (full, key: string) => {
|
|
11
|
+
if (!Object.prototype.hasOwnProperty.call(tokens, key)) {
|
|
12
|
+
throw new Error(`renderTemplate: missing value for token ${full}`);
|
|
13
|
+
}
|
|
14
|
+
return tokens[key];
|
|
15
|
+
});
|
|
16
|
+
const leftover = rendered.match(TOKEN_RE);
|
|
17
|
+
if (leftover) {
|
|
18
|
+
throw new Error(`renderTemplate: unresolved tokens: ${leftover.join(', ')}`);
|
|
19
|
+
}
|
|
20
|
+
return rendered;
|
|
21
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service-host abstraction. The wizard picks a concrete impl by platform
|
|
3
|
+
* (systemd / launchd / NSSM) and drives it through this common interface.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ServiceHostContext {
|
|
7
|
+
/** Absolute path to node.exe / node. */
|
|
8
|
+
nodePath: string;
|
|
9
|
+
/** Absolute path to the cadre-host CLI entrypoint (dist/bin/host.js). */
|
|
10
|
+
hostJs: string;
|
|
11
|
+
/** Absolute path to the cadre-host data dir. */
|
|
12
|
+
dataDir: string;
|
|
13
|
+
/** Directory containing template files / PowerShell scripts. */
|
|
14
|
+
serviceDir: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ServiceHostStatus {
|
|
18
|
+
installed: boolean;
|
|
19
|
+
running: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ServiceHost {
|
|
23
|
+
/** Stable service name (used for status / logging). */
|
|
24
|
+
readonly name: string;
|
|
25
|
+
|
|
26
|
+
/** Render unit file(s) and register with the OS service manager. */
|
|
27
|
+
install(ctx: ServiceHostContext): Promise<void>;
|
|
28
|
+
|
|
29
|
+
/** Stop + deregister. Idempotent: no-op when not installed. */
|
|
30
|
+
uninstall(ctx: ServiceHostContext): Promise<void>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Restart the running service. Used by the update flow after `npm install -g`
|
|
34
|
+
* to pick up the new binary. Throws if the OS service manager reports a
|
|
35
|
+
* non-zero exit — callers are responsible for surfacing that as a warning
|
|
36
|
+
* (the binary swap already succeeded).
|
|
37
|
+
*/
|
|
38
|
+
restart(ctx: ServiceHostContext): Promise<void>;
|
|
39
|
+
|
|
40
|
+
/** Cheap status read. */
|
|
41
|
+
status(ctx: ServiceHostContext): Promise<ServiceHostStatus>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Render the unit-file text without touching the OS. Surfaced for unit
|
|
45
|
+
* tests; the install() path uses this internally too.
|
|
46
|
+
*/
|
|
47
|
+
renderUnit(ctx: ServiceHostContext): string | null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive install wizard.
|
|
3
|
+
*
|
|
4
|
+
* Pure readline — no extra deps. Each prompt is decomposed into a small
|
|
5
|
+
* `Prompt` so the test can drive the wizard with a scripted Iterable of
|
|
6
|
+
* answers. Production code wires the prompts to stdin.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createInterface, type Interface as ReadlineInterface } from 'node:readline';
|
|
10
|
+
|
|
11
|
+
import { defaultDataDir } from './paths.js';
|
|
12
|
+
import type { SupportedPlatform } from './platform.js';
|
|
13
|
+
|
|
14
|
+
export interface WizardDefaults {
|
|
15
|
+
dataDir: string;
|
|
16
|
+
uiPort: number;
|
|
17
|
+
libp2pPort: number;
|
|
18
|
+
upnpEnabled: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WizardAnswers {
|
|
22
|
+
dataDir: string;
|
|
23
|
+
uiPort: number;
|
|
24
|
+
libp2pPort: number;
|
|
25
|
+
upnpEnabled: boolean;
|
|
26
|
+
configureDdns: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_UI_PORT = 8765;
|
|
30
|
+
export const DEFAULT_LIBP2P_PORT = 4001;
|
|
31
|
+
|
|
32
|
+
export function defaultsForPlatform(platform: SupportedPlatform): WizardDefaults {
|
|
33
|
+
return {
|
|
34
|
+
dataDir: defaultDataDir(platform),
|
|
35
|
+
uiPort: DEFAULT_UI_PORT,
|
|
36
|
+
libp2pPort: DEFAULT_LIBP2P_PORT,
|
|
37
|
+
upnpEnabled: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Drive the wizard with a function that returns the next user answer for
|
|
43
|
+
* a prompt. Used both by the readline-backed `runWizard` and by tests.
|
|
44
|
+
*/
|
|
45
|
+
export async function runWizardWith(
|
|
46
|
+
defaults: WizardDefaults,
|
|
47
|
+
ask: (label: string, fallback: string) => Promise<string>,
|
|
48
|
+
): Promise<WizardAnswers> {
|
|
49
|
+
const dataDir = (await ask('Data directory', defaults.dataDir)).trim() || defaults.dataDir;
|
|
50
|
+
const uiPort = parsePort(await ask('UI port (localhost only)', String(defaults.uiPort)), defaults.uiPort);
|
|
51
|
+
const libp2pPort = parsePort(await ask('Cadre libp2p port', String(defaults.libp2pPort)), defaults.libp2pPort);
|
|
52
|
+
const upnpEnabled = parseBool(
|
|
53
|
+
await ask('Attempt UPnP port mapping on this network? [Y/n]', defaults.upnpEnabled ? 'Y' : 'n'),
|
|
54
|
+
defaults.upnpEnabled,
|
|
55
|
+
);
|
|
56
|
+
const configureDdns = parseBool(await ask('Configure DDNS now? [y/N]', 'N'), false);
|
|
57
|
+
return { dataDir, uiPort, libp2pPort, upnpEnabled, configureDdns };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Production entrypoint — drives the wizard over stdin/stdout. */
|
|
61
|
+
export async function runWizard(defaults: WizardDefaults): Promise<WizardAnswers> {
|
|
62
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
63
|
+
try {
|
|
64
|
+
return await runWizardWith(defaults, async (label, fallback) => prompt(rl, label, fallback));
|
|
65
|
+
} finally {
|
|
66
|
+
rl.close();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function prompt(rl: ReadlineInterface, label: string, fallback: string): Promise<string> {
|
|
71
|
+
return new Promise<string>((resolve) => {
|
|
72
|
+
rl.question(`${label} [${fallback}]: `, (answer) => resolve(answer));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parsePort(input: string, fallback: number): number {
|
|
77
|
+
const trimmed = input.trim();
|
|
78
|
+
if (!trimmed) return fallback;
|
|
79
|
+
const n = Number(trimmed);
|
|
80
|
+
if (!Number.isInteger(n) || n <= 0 || n > 65535) {
|
|
81
|
+
throw new Error(`Invalid port: ${input}`);
|
|
82
|
+
}
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseBool(input: string, fallback: boolean): boolean {
|
|
87
|
+
const trimmed = input.trim().toLowerCase();
|
|
88
|
+
if (!trimmed) return fallback;
|
|
89
|
+
if (['y', 'yes', 'true', '1'].includes(trimmed)) return true;
|
|
90
|
+
if (['n', 'no', 'false', '0'].includes(trimmed)) return false;
|
|
91
|
+
throw new Error(`Invalid yes/no answer: ${input}`);
|
|
92
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { buildInviteAddresses } from '../address-resolver.js';
|
|
4
|
+
|
|
5
|
+
const PEER = '12D3KooWHost';
|
|
6
|
+
|
|
7
|
+
describe('buildInviteAddresses', () => {
|
|
8
|
+
it('returns dns4 when reachable and a DDNS hostname is configured', () => {
|
|
9
|
+
const addrs = buildInviteAddresses({
|
|
10
|
+
peerId: PEER,
|
|
11
|
+
externalPort: 4001,
|
|
12
|
+
ddnsHostname: 'foo.duckdns.org',
|
|
13
|
+
externalIp: '1.2.3.4',
|
|
14
|
+
reachable: true,
|
|
15
|
+
libp2pAddrs: ['/ip4/192.168.1.10/tcp/4001'],
|
|
16
|
+
});
|
|
17
|
+
expect(addrs).toEqual([`/dns4/foo.duckdns.org/tcp/4001/p2p/${PEER}`]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('falls back to ip4 when reachable but no DDNS hostname', () => {
|
|
21
|
+
const addrs = buildInviteAddresses({
|
|
22
|
+
peerId: PEER,
|
|
23
|
+
externalPort: 4001,
|
|
24
|
+
ddnsHostname: null,
|
|
25
|
+
externalIp: '203.0.113.5',
|
|
26
|
+
reachable: true,
|
|
27
|
+
libp2pAddrs: ['/ip4/192.168.1.10/tcp/4001'],
|
|
28
|
+
});
|
|
29
|
+
expect(addrs).toEqual([`/ip4/203.0.113.5/tcp/4001/p2p/${PEER}`]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('falls back to libp2p addrs when not reachable', () => {
|
|
33
|
+
const addrs = buildInviteAddresses({
|
|
34
|
+
peerId: PEER,
|
|
35
|
+
externalPort: 4001,
|
|
36
|
+
ddnsHostname: 'foo.duckdns.org',
|
|
37
|
+
externalIp: '1.2.3.4',
|
|
38
|
+
reachable: false,
|
|
39
|
+
libp2pAddrs: ['/ip4/192.168.1.10/tcp/4001', '/p2p-circuit'],
|
|
40
|
+
});
|
|
41
|
+
expect(addrs.length).toBeGreaterThan(0);
|
|
42
|
+
expect(addrs.some((a) => a.startsWith('/dns4/'))).toBe(false);
|
|
43
|
+
expect(addrs.some((a) => a.startsWith('/ip4/'))).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('appends peer suffix to libp2p addrs that lack one', () => {
|
|
47
|
+
const addrs = buildInviteAddresses({
|
|
48
|
+
peerId: PEER,
|
|
49
|
+
externalPort: 4001,
|
|
50
|
+
ddnsHostname: null,
|
|
51
|
+
externalIp: null,
|
|
52
|
+
reachable: false,
|
|
53
|
+
libp2pAddrs: ['/ip4/192.168.1.10/tcp/4001'],
|
|
54
|
+
});
|
|
55
|
+
expect(addrs).toEqual([`/ip4/192.168.1.10/tcp/4001/p2p/${PEER}`]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('preserves libp2p addrs that already have a /p2p/ suffix', () => {
|
|
59
|
+
const existing = `/ip4/192.168.1.10/tcp/4001/p2p/${PEER}`;
|
|
60
|
+
const addrs = buildInviteAddresses({
|
|
61
|
+
peerId: PEER,
|
|
62
|
+
externalPort: 4001,
|
|
63
|
+
ddnsHostname: null,
|
|
64
|
+
externalIp: null,
|
|
65
|
+
reachable: false,
|
|
66
|
+
libp2pAddrs: [existing],
|
|
67
|
+
});
|
|
68
|
+
expect(addrs).toEqual([existing]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('does not emit /ip4/ for IPv6 external IP — falls back instead', () => {
|
|
72
|
+
const addrs = buildInviteAddresses({
|
|
73
|
+
peerId: PEER,
|
|
74
|
+
externalPort: 4001,
|
|
75
|
+
ddnsHostname: null,
|
|
76
|
+
externalIp: '2001:db8::1',
|
|
77
|
+
reachable: true,
|
|
78
|
+
libp2pAddrs: ['/ip4/192.168.1.10/tcp/4001'],
|
|
79
|
+
});
|
|
80
|
+
expect(addrs.some((a) => a.startsWith('/ip4/192.168.1.10'))).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { duckDnsProvider } from '../ddns/duckdns.js';
|
|
4
|
+
import { NatError } from '../types.js';
|
|
5
|
+
|
|
6
|
+
function makeFetch(responses: Array<{ ok: boolean; status?: number; body: string }>): {
|
|
7
|
+
fetch: typeof fetch;
|
|
8
|
+
calls: string[];
|
|
9
|
+
} {
|
|
10
|
+
const calls: string[] = [];
|
|
11
|
+
let i = 0;
|
|
12
|
+
const fetchImpl = (async (input: RequestInfo | URL) => {
|
|
13
|
+
calls.push(String(input));
|
|
14
|
+
const r = responses[i++] ?? { ok: false, body: '' };
|
|
15
|
+
return {
|
|
16
|
+
ok: r.ok,
|
|
17
|
+
status: r.status ?? (r.ok ? 200 : 500),
|
|
18
|
+
statusText: r.ok ? 'OK' : 'Server Error',
|
|
19
|
+
async text() { return r.body; },
|
|
20
|
+
} as unknown as Response;
|
|
21
|
+
}) as typeof fetch;
|
|
22
|
+
return { fetch: fetchImpl, calls };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('duckDnsProvider', () => {
|
|
26
|
+
it('exposes metadata', () => {
|
|
27
|
+
expect(duckDnsProvider.id).toBe('duckdns');
|
|
28
|
+
expect(duckDnsProvider.displayName).toBe('DuckDNS');
|
|
29
|
+
expect(duckDnsProvider.configFields).toHaveLength(1);
|
|
30
|
+
expect(duckDnsProvider.configFields[0]).toMatchObject({ key: 'token', secret: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('OK response → resolves', async () => {
|
|
34
|
+
const f = makeFetch([{ ok: true, body: 'OK' }]);
|
|
35
|
+
await expect(
|
|
36
|
+
duckDnsProvider.update({ token: 'T' }, 'foo.duckdns.org', '1.2.3.4', { fetch: f.fetch }),
|
|
37
|
+
).resolves.toBeUndefined();
|
|
38
|
+
expect(f.calls[0]).toContain('domains=foo');
|
|
39
|
+
expect(f.calls[0]).toContain('token=T');
|
|
40
|
+
expect(f.calls[0]).toContain('ip=1.2.3.4');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('KO response → throws ddns_update_failed', async () => {
|
|
44
|
+
const f = makeFetch([{ ok: true, body: 'KO' }]);
|
|
45
|
+
await expect(
|
|
46
|
+
duckDnsProvider.update({ token: 'T' }, 'foo', '1.2.3.4', { fetch: f.fetch }),
|
|
47
|
+
).rejects.toMatchObject({ code: 'ddns_update_failed' });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('HTTP error → throws ddns_update_failed', async () => {
|
|
51
|
+
const f = makeFetch([{ ok: false, status: 503, body: '' }]);
|
|
52
|
+
await expect(
|
|
53
|
+
duckDnsProvider.update({ token: 'T' }, 'foo', '1.2.3.4', { fetch: f.fetch }),
|
|
54
|
+
).rejects.toMatchObject({ code: 'ddns_update_failed' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('strips trailing .duckdns.org from hostname', async () => {
|
|
58
|
+
const f = makeFetch([{ ok: true, body: 'OK' }]);
|
|
59
|
+
await duckDnsProvider.update({ token: 'T' }, 'MyHost.DuckDNS.ORG', '1.2.3.4', { fetch: f.fetch });
|
|
60
|
+
expect(f.calls[0]).toContain('domains=myhost');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('missing token → ddns_credentials_missing', async () => {
|
|
64
|
+
const f = makeFetch([]);
|
|
65
|
+
await expect(
|
|
66
|
+
duckDnsProvider.update({}, 'foo', '1.2.3.4', { fetch: f.fetch }),
|
|
67
|
+
).rejects.toMatchObject({ code: 'ddns_credentials_missing' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('empty hostname → invalid_config', async () => {
|
|
71
|
+
const f = makeFetch([]);
|
|
72
|
+
await expect(
|
|
73
|
+
duckDnsProvider.update({ token: 'T' }, ' ', '1.2.3.4', { fetch: f.fetch }),
|
|
74
|
+
).rejects.toBeInstanceOf(NatError);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('network error → ddns_update_failed', async () => {
|
|
78
|
+
const f = (async () => { throw new Error('ENETUNREACH'); }) as typeof fetch;
|
|
79
|
+
await expect(
|
|
80
|
+
duckDnsProvider.update({ token: 'T' }, 'foo', '1.2.3.4', { fetch: f }),
|
|
81
|
+
).rejects.toMatchObject({ code: 'ddns_update_failed' });
|
|
82
|
+
});
|
|
83
|
+
});
|