@serfab/cadre-host 0.7.1 → 0.8.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 +296 -101
- package/dist/auth/trust-circle.d.ts +44 -6
- package/dist/auth/trust-circle.js +115 -58
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +4 -1
- package/dist/auth/types.js.map +1 -1
- package/dist/authority/authority-node-client.d.ts +77 -0
- package/dist/authority/authority-node-client.js +143 -0
- package/dist/authority/authority-node-client.js.map +1 -0
- package/dist/authority/index.d.ts +11 -0
- package/dist/authority/index.js +11 -0
- package/dist/authority/index.js.map +1 -0
- package/dist/bin/host.js +210 -97
- package/dist/bin/host.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/installer/browser.js +16 -17
- package/dist/installer/browser.js.map +1 -1
- package/dist/installer/config.d.ts +25 -0
- package/dist/installer/config.js +1 -1
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +5 -2
- package/dist/installer/index.js +14 -10
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/service-host/launchd.js +1 -1
- package/dist/installer/service-host/launchd.js.map +1 -1
- package/dist/installer/service-host/systemd.js +1 -1
- package/dist/installer/service-host/systemd.js.map +1 -1
- package/dist/nat/index.d.ts +1 -1
- package/dist/nat/nat-service.d.ts +45 -3
- package/dist/nat/nat-service.js +116 -3
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/secrets/index.d.ts +7 -0
- package/dist/nat/secrets/index.js +28 -2
- package/dist/nat/secrets/index.js.map +1 -1
- package/dist/nat/types.d.ts +3 -1
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +60 -1
- package/dist/orchestrator/host-process-orchestrator.js +246 -39
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -2
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +6 -5
- package/dist/orchestrator/state-store.js +10 -2
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +45 -10
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/push/index.d.ts +67 -0
- package/dist/push/index.js +101 -0
- package/dist/push/index.js.map +1 -0
- package/dist/server/error-handler.d.ts +2 -0
- package/dist/server/error-handler.js +4 -0
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/routes/nodes.d.ts +4 -3
- package/dist/server/routes/nodes.js +38 -24
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/static.d.ts +12 -0
- package/dist/server/static.js +24 -7
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-BlMXQPSw.js +14 -0
- package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/dist/update/apply.d.ts +23 -1
- package/dist/update/apply.js +46 -4
- package/dist/update/apply.js.map +1 -1
- package/dist/update/index.d.ts +14 -3
- package/dist/update/index.js +35 -2
- package/dist/update/index.js.map +1 -1
- package/dist/update/manifest.d.ts +28 -9
- package/dist/update/manifest.js +56 -29
- package/dist/update/manifest.js.map +1 -1
- package/dist/update/release-key.d.ts +18 -7
- package/dist/update/release-key.js +28 -15
- package/dist/update/release-key.js.map +1 -1
- package/dist/update/sign.d.ts +46 -0
- package/dist/update/sign.js +53 -0
- package/dist/update/sign.js.map +1 -0
- package/package.json +9 -4
- package/service/README.md +95 -86
- package/src/__tests__/cli-invite.smoke.test.ts +6 -1
- package/src/__tests__/cli-nat.smoke.test.ts +6 -1
- package/src/__tests__/cli.smoke.test.ts +6 -1
- package/src/__tests__/host-authority.smoke.test.ts +123 -0
- package/src/__tests__/orchestrator-authority.test.ts +198 -0
- package/src/__tests__/orchestrator-push.test.ts +145 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +40 -21
- package/src/auth/__tests__/trust-circle.test.ts +90 -23
- package/src/auth/trust-circle.ts +363 -304
- package/src/auth/types.ts +4 -1
- package/src/authority/__tests__/authority-node-client.test.ts +166 -0
- package/src/authority/authority-node-client.ts +199 -0
- package/src/authority/index.ts +12 -0
- package/src/bin/host.ts +1038 -905
- package/src/index.ts +34 -3
- package/src/installer/__tests__/installer.smoke.test.ts +64 -0
- package/src/installer/browser.ts +40 -42
- package/src/installer/config.ts +27 -1
- package/src/installer/index.ts +18 -12
- package/src/installer/service-host/launchd.ts +1 -1
- package/src/installer/service-host/systemd.ts +1 -1
- package/src/nat/__tests__/ddns-updater.test.ts +1 -1
- package/src/nat/__tests__/nat-service.test.ts +192 -0
- package/src/nat/__tests__/port-mapper.test.ts +1 -1
- package/src/nat/__tests__/secrets.test.ts +23 -1
- package/src/nat/index.ts +1 -1
- package/src/nat/nat-service.ts +131 -5
- package/src/nat/secrets/index.ts +32 -2
- package/src/nat/types.ts +3 -1
- package/src/orchestrator/host-process-orchestrator.ts +294 -54
- package/src/orchestrator/index.ts +9 -2
- package/src/orchestrator/state-store.ts +21 -3
- package/src/orchestrator/types.ts +48 -2
- package/src/push/__tests__/push.test.ts +90 -0
- package/src/push/index.ts +146 -0
- package/src/server/__tests__/error-handler.test.ts +4 -4
- package/src/server/__tests__/nodes-route.test.ts +90 -10
- package/src/server/__tests__/publishers.test.ts +1 -1
- package/src/server/__tests__/static.test.ts +145 -0
- package/src/server/__tests__/status-route.test.ts +2 -2
- package/src/server/error-handler.ts +4 -0
- package/src/server/routes/nodes.ts +48 -26
- package/src/server/static.ts +24 -8
- package/src/update/__tests__/apply.test.ts +58 -1
- package/src/update/__tests__/manifest.test.ts +41 -6
- package/src/update/__tests__/release-key.test.ts +29 -0
- package/src/update/__tests__/sign.test.ts +78 -0
- package/src/update/__tests__/update-service.integration.test.ts +44 -9
- package/src/update/apply.ts +46 -4
- package/src/update/index.ts +41 -3
- package/src/update/manifest.ts +214 -172
- package/src/update/release-key.ts +30 -16
- package/src/update/sign.ts +74 -0
- package/dist/ui/assets/index-C9uChk2P.js +0 -14
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { SecretsStore } from '../../nat/secrets/index.js';
|
|
4
|
+
import type { PushSettings } from '../../installer/config.js';
|
|
5
|
+
import {
|
|
6
|
+
resolvePushCredentials,
|
|
7
|
+
setFcmSecret,
|
|
8
|
+
setApnsSecret,
|
|
9
|
+
clearPushSecret,
|
|
10
|
+
pushAccount,
|
|
11
|
+
pushStatus,
|
|
12
|
+
} from '../index.js';
|
|
13
|
+
|
|
14
|
+
/** In-memory SecretsStore for tests. */
|
|
15
|
+
function fakeStore(): SecretsStore {
|
|
16
|
+
const map = new Map<string, string>();
|
|
17
|
+
return {
|
|
18
|
+
async set(account, value) { map.set(account, value); },
|
|
19
|
+
async get(account) { return map.get(account) ?? null; },
|
|
20
|
+
async delete(account) { return map.delete(account); },
|
|
21
|
+
async list() { return [...map.keys()]; },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const FCM = { projectId: 'proj', clientEmail: 'svc@proj.iam', privateKey: 'FCM-PEM' };
|
|
26
|
+
const APNS = { keyId: 'KID', teamId: 'TEAM', privateKey: 'P8-PEM' };
|
|
27
|
+
|
|
28
|
+
describe('resolvePushCredentials', () => {
|
|
29
|
+
it('returns undefined when no platform is configured (opt-in default)', async () => {
|
|
30
|
+
const store = fakeStore();
|
|
31
|
+
expect(await resolvePushCredentials(store, undefined)).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('resolves an FCM-only bundle from the secret store', async () => {
|
|
35
|
+
const store = fakeStore();
|
|
36
|
+
await setFcmSecret(store, FCM);
|
|
37
|
+
const push = await resolvePushCredentials(store, undefined);
|
|
38
|
+
expect(push).toEqual({ fcm: FCM });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('merges the APNs secret with non-secret bundleId/production from settings', async () => {
|
|
42
|
+
const store = fakeStore();
|
|
43
|
+
await setApnsSecret(store, APNS);
|
|
44
|
+
const settings: PushSettings = { apns: { bundleId: 'com.example.app', production: true }, cooldownMs: 1000, debounceMs: 50 };
|
|
45
|
+
const push = await resolvePushCredentials(store, settings);
|
|
46
|
+
expect(push).toEqual({
|
|
47
|
+
apns: { keyId: 'KID', teamId: 'TEAM', bundleId: 'com.example.app', privateKey: 'P8-PEM', production: true },
|
|
48
|
+
cooldownMs: 1000,
|
|
49
|
+
debounceMs: 50,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('combines both platforms when both secrets are present', async () => {
|
|
54
|
+
const store = fakeStore();
|
|
55
|
+
await setFcmSecret(store, FCM);
|
|
56
|
+
await setApnsSecret(store, APNS);
|
|
57
|
+
const push = await resolvePushCredentials(store, { apns: { bundleId: 'com.example.app' } });
|
|
58
|
+
expect(push?.fcm).toEqual(FCM);
|
|
59
|
+
expect(push?.apns?.bundleId).toBe('com.example.app');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('throws on a partial APNs config (secret present but bundleId unset)', async () => {
|
|
63
|
+
const store = fakeStore();
|
|
64
|
+
await setApnsSecret(store, APNS);
|
|
65
|
+
// No settings.apns.bundleId → bundleId resolves to '' → validation rejects.
|
|
66
|
+
await expect(resolvePushCredentials(store, undefined)).rejects.toThrow(/push\.apns\.bundleId/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('keeps the FCM private key out of the secret-store account name', async () => {
|
|
70
|
+
const store = fakeStore();
|
|
71
|
+
await setFcmSecret(store, FCM);
|
|
72
|
+
const accounts = await store.list();
|
|
73
|
+
expect(accounts).toContain(pushAccount('fcm'));
|
|
74
|
+
expect(accounts.join()).not.toContain('FCM-PEM');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('clearPushSecret / pushStatus', () => {
|
|
79
|
+
it('reports configured platforms and clears them', async () => {
|
|
80
|
+
const store = fakeStore();
|
|
81
|
+
await setFcmSecret(store, FCM);
|
|
82
|
+
await setApnsSecret(store, APNS);
|
|
83
|
+
expect(await pushStatus(store)).toEqual({ fcm: true, apns: true });
|
|
84
|
+
|
|
85
|
+
expect(await clearPushSecret(store, 'fcm')).toBe(true);
|
|
86
|
+
expect(await pushStatus(store)).toEqual({ fcm: false, apns: true });
|
|
87
|
+
|
|
88
|
+
expect(await clearPushSecret(store, 'fcm')).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push (FCM/APNs) credential storage + resolution for cadre-host.
|
|
3
|
+
*
|
|
4
|
+
* The platform-push secrets a self-hosted deployment provisions live in the host
|
|
5
|
+
* secret store (keytar with file-store fallback — the same store DDNS tokens use);
|
|
6
|
+
* the non-secret app-config bits (APNs bundle id / sandbox toggle, cooldown /
|
|
7
|
+
* debounce) live in `host.config.json` (`PushSettings`). At node-spawn time the
|
|
8
|
+
* orchestrator's `pushResolver` calls {@link resolvePushCredentials}, which reads
|
|
9
|
+
* BOTH and merges them into the `PushCredentials` block written into the spawned
|
|
10
|
+
* node's `cadre.json`.
|
|
11
|
+
*
|
|
12
|
+
* Secret hygiene: the FCM service-account fields and the APNs `.p8` key are stored
|
|
13
|
+
* ONLY in the secret store, never in `host.config.json` and never in `state.json`.
|
|
14
|
+
* They are re-read on every spawn so a restart picks up rotated keys, and the
|
|
15
|
+
* resolver redacts on any log line. A partial credential set (a present platform
|
|
16
|
+
* missing required fields) is rejected — fail fast, not at first push.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import debug from 'debug';
|
|
20
|
+
|
|
21
|
+
import type { PushCredentials, FcmCredentials, ApnsCredentials } from '@serfab/cadre-core';
|
|
22
|
+
import { validatePushCredentials } from '@serfab/cadre-core';
|
|
23
|
+
|
|
24
|
+
import type { SecretsStore } from '../nat/secrets/index.js';
|
|
25
|
+
import type { PushSettings } from '../installer/config.js';
|
|
26
|
+
|
|
27
|
+
const log = debug('cadre:host:push');
|
|
28
|
+
|
|
29
|
+
/** Push platform whose secret bundle lives in the secret store. */
|
|
30
|
+
export type PushSecretPlatform = 'fcm' | 'apns';
|
|
31
|
+
|
|
32
|
+
/** Build the secret-store account key for a platform's credential bundle. */
|
|
33
|
+
export function pushAccount(platform: PushSecretPlatform): string {
|
|
34
|
+
return `push:${platform}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** FCM secret bundle persisted in the secret store (the whole set is sensitive). */
|
|
38
|
+
export interface FcmSecret {
|
|
39
|
+
projectId: string;
|
|
40
|
+
clientEmail: string;
|
|
41
|
+
/** Service-account RSA private key, PEM. */
|
|
42
|
+
privateKey: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* APNs secret bundle persisted in the secret store. `bundleId` / `production` are
|
|
47
|
+
* NOT here — they are non-secret app-config in {@link PushSettings}.
|
|
48
|
+
*/
|
|
49
|
+
export interface ApnsSecret {
|
|
50
|
+
keyId: string;
|
|
51
|
+
teamId: string;
|
|
52
|
+
/** `.p8` ES256 private key, PEM. */
|
|
53
|
+
privateKey: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Store (or replace) the FCM service-account secret bundle. */
|
|
57
|
+
export async function setFcmSecret(secrets: SecretsStore, secret: FcmSecret): Promise<void> {
|
|
58
|
+
await secrets.set(pushAccount('fcm'), JSON.stringify(secret));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Store (or replace) the APNs auth-key secret bundle. */
|
|
62
|
+
export async function setApnsSecret(secrets: SecretsStore, secret: ApnsSecret): Promise<void> {
|
|
63
|
+
await secrets.set(pushAccount('apns'), JSON.stringify(secret));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Remove a platform's secret bundle. Returns true if one was present. */
|
|
67
|
+
export async function clearPushSecret(secrets: SecretsStore, platform: PushSecretPlatform): Promise<boolean> {
|
|
68
|
+
return secrets.delete(pushAccount(platform));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read a platform's stored secret bundle, or null when unset. */
|
|
72
|
+
async function readSecret<T>(secrets: SecretsStore, platform: PushSecretPlatform): Promise<T | null> {
|
|
73
|
+
const raw = await secrets.get(pushAccount(platform));
|
|
74
|
+
if (raw === null) return null;
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(raw) as T;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
throw new Error(`Stored ${platform} push secret is not valid JSON: ${(err as Error).message}`, { cause: err });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the full {@link PushCredentials} block to inject into a spawned node,
|
|
84
|
+
* merging the secret-store bundles with the non-secret {@link PushSettings}.
|
|
85
|
+
*
|
|
86
|
+
* Returns `undefined` when NO platform is configured (push is opt-in — the node
|
|
87
|
+
* then constructs no fan-out). Throws when a configured platform is incomplete
|
|
88
|
+
* (e.g. APNs secret present but `bundleId` unset), so a misconfig surfaces at the
|
|
89
|
+
* spawn boundary rather than silently at first push.
|
|
90
|
+
*/
|
|
91
|
+
export async function resolvePushCredentials(
|
|
92
|
+
secrets: SecretsStore,
|
|
93
|
+
settings: PushSettings | undefined,
|
|
94
|
+
): Promise<PushCredentials | undefined> {
|
|
95
|
+
const fcmSecret = await readSecret<FcmSecret>(secrets, 'fcm');
|
|
96
|
+
const apnsSecret = await readSecret<ApnsSecret>(secrets, 'apns');
|
|
97
|
+
|
|
98
|
+
if (!fcmSecret && !apnsSecret) return undefined;
|
|
99
|
+
|
|
100
|
+
const push: PushCredentials = {};
|
|
101
|
+
if (fcmSecret) {
|
|
102
|
+
const fcm: FcmCredentials = {
|
|
103
|
+
projectId: fcmSecret.projectId,
|
|
104
|
+
clientEmail: fcmSecret.clientEmail,
|
|
105
|
+
privateKey: fcmSecret.privateKey,
|
|
106
|
+
};
|
|
107
|
+
push.fcm = fcm;
|
|
108
|
+
}
|
|
109
|
+
if (apnsSecret) {
|
|
110
|
+
const apns: ApnsCredentials = {
|
|
111
|
+
keyId: apnsSecret.keyId,
|
|
112
|
+
teamId: apnsSecret.teamId,
|
|
113
|
+
bundleId: settings?.apns?.bundleId ?? '',
|
|
114
|
+
privateKey: apnsSecret.privateKey,
|
|
115
|
+
...(settings?.apns?.production !== undefined ? { production: settings.apns.production } : {}),
|
|
116
|
+
};
|
|
117
|
+
push.apns = apns;
|
|
118
|
+
}
|
|
119
|
+
if (settings?.cooldownMs !== undefined) push.cooldownMs = settings.cooldownMs;
|
|
120
|
+
if (settings?.debounceMs !== undefined) push.debounceMs = settings.debounceMs;
|
|
121
|
+
|
|
122
|
+
const errors = validatePushCredentials(push);
|
|
123
|
+
if (errors.length > 0) {
|
|
124
|
+
throw new Error(`Invalid push credentials: ${errors.join('; ')}`);
|
|
125
|
+
}
|
|
126
|
+
log('resolved push credentials (fcm=%s apns=%s)', !!push.fcm, !!push.apns);
|
|
127
|
+
return push;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A description of which push platforms are configured, for the `push status` CLI
|
|
132
|
+
* and any UI — derived without exposing any secret material.
|
|
133
|
+
*/
|
|
134
|
+
export interface PushStatus {
|
|
135
|
+
fcm: boolean;
|
|
136
|
+
apns: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Report which platforms have a stored secret bundle (no secret material). */
|
|
140
|
+
export async function pushStatus(secrets: SecretsStore): Promise<PushStatus> {
|
|
141
|
+
const accounts = await secrets.list();
|
|
142
|
+
return {
|
|
143
|
+
fcm: accounts.includes(pushAccount('fcm')),
|
|
144
|
+
apns: accounts.includes(pushAccount('apns')),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import Fastify from 'fastify';
|
|
1
|
+
import Fastify, { type FastifyRequest } from 'fastify';
|
|
2
2
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
3
3
|
|
|
4
4
|
import { TrustCircleError } from '../../auth/types.js';
|
|
@@ -12,15 +12,15 @@ describe('error handler', () => {
|
|
|
12
12
|
beforeEach(async () => {
|
|
13
13
|
app = Fastify();
|
|
14
14
|
registerErrorHandler(app);
|
|
15
|
-
app.get('/trust/:code', async (req) => {
|
|
15
|
+
app.get('/trust/:code', async (req: FastifyRequest) => {
|
|
16
16
|
const { code } = req.params as { code: string };
|
|
17
17
|
throw new TrustCircleError(code as never, `trust err: ${code}`);
|
|
18
18
|
});
|
|
19
|
-
app.get('/nat/:code', async (req) => {
|
|
19
|
+
app.get('/nat/:code', async (req: FastifyRequest) => {
|
|
20
20
|
const { code } = req.params as { code: string };
|
|
21
21
|
throw new NatError(code as never, `nat err: ${code}`);
|
|
22
22
|
});
|
|
23
|
-
app.get('/update/:code', async (req) => {
|
|
23
|
+
app.get('/update/:code', async (req: FastifyRequest) => {
|
|
24
24
|
const { code } = req.params as { code: string };
|
|
25
25
|
throw new UpdateErrorException(code as never, `update err: ${code}`);
|
|
26
26
|
});
|
|
@@ -15,25 +15,48 @@ interface FakeOrchestrator extends HostProcessOrchestrator {
|
|
|
15
15
|
__nodes: Map<string, ManagedNodeInfo>;
|
|
16
16
|
__listeners: Set<NodeStateListener>;
|
|
17
17
|
__stoppedDockerIds: string[];
|
|
18
|
+
__ensured: number;
|
|
19
|
+
__restarted: number;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
function fakeOrchestrator(
|
|
22
|
+
function fakeOrchestrator(
|
|
23
|
+
initial: ManagedNodeInfo[] = [],
|
|
24
|
+
opts: { authorityId?: string; hasAuthorityConfig?: boolean } = {},
|
|
25
|
+
): FakeOrchestrator {
|
|
21
26
|
const nodes = new Map(initial.map((n) => [n.dockerId, n]));
|
|
22
27
|
const listeners = new Set<NodeStateListener>();
|
|
23
28
|
const stopped: string[] = [];
|
|
29
|
+
const counters = { ensured: 0, restarted: 0 };
|
|
30
|
+
const findById = (id: string): ManagedNodeInfo | undefined => {
|
|
31
|
+
const direct = nodes.get(id);
|
|
32
|
+
if (direct) return direct;
|
|
33
|
+
for (const n of nodes.values()) if (n.id === id) return n;
|
|
34
|
+
return undefined;
|
|
35
|
+
};
|
|
24
36
|
const inst = {
|
|
25
37
|
listNodes: () => [...nodes.values()],
|
|
26
|
-
getNode: (id: string) =>
|
|
27
|
-
const direct = nodes.get(id);
|
|
28
|
-
if (direct) return direct;
|
|
29
|
-
for (const n of nodes.values()) if (n.id === id) return n;
|
|
30
|
-
return undefined;
|
|
31
|
-
},
|
|
38
|
+
getNode: (id: string) => findById(id),
|
|
32
39
|
resolveDockerId: (id: string) => {
|
|
33
40
|
if (nodes.has(id)) return id;
|
|
34
41
|
for (const n of nodes.values()) if (n.id === id) return n.dockerId;
|
|
35
42
|
return undefined;
|
|
36
43
|
},
|
|
44
|
+
isAuthorityNode: (id: string) => {
|
|
45
|
+
if (!opts.authorityId) return false;
|
|
46
|
+
const node = findById(id);
|
|
47
|
+
return id === opts.authorityId || node?.id === opts.authorityId;
|
|
48
|
+
},
|
|
49
|
+
hasAuthorityConfig: () => opts.hasAuthorityConfig ?? false,
|
|
50
|
+
ensureAuthorityNode: async () => {
|
|
51
|
+
counters.ensured++;
|
|
52
|
+
const node = opts.authorityId ? findById(opts.authorityId) : undefined;
|
|
53
|
+
return node ?? { id: opts.authorityId, status: 'running' } as unknown as ManagedNodeInfo;
|
|
54
|
+
},
|
|
55
|
+
restartAuthorityNode: async () => {
|
|
56
|
+
counters.restarted++;
|
|
57
|
+
const node = opts.authorityId ? findById(opts.authorityId) : undefined;
|
|
58
|
+
return node ?? { id: opts.authorityId, status: 'running' } as unknown as ManagedNodeInfo;
|
|
59
|
+
},
|
|
37
60
|
onStateChange: (l: NodeStateListener) => { listeners.add(l); return () => { listeners.delete(l); }; },
|
|
38
61
|
stopContainer: async (dockerId: string) => {
|
|
39
62
|
stopped.push(dockerId);
|
|
@@ -48,6 +71,8 @@ function fakeOrchestrator(initial: ManagedNodeInfo[] = []): FakeOrchestrator {
|
|
|
48
71
|
__nodes: nodes,
|
|
49
72
|
__listeners: listeners,
|
|
50
73
|
__stoppedDockerIds: stopped,
|
|
74
|
+
get __ensured() { return counters.ensured; },
|
|
75
|
+
get __restarted() { return counters.restarted; },
|
|
51
76
|
} as unknown as FakeOrchestrator;
|
|
52
77
|
return inst;
|
|
53
78
|
}
|
|
@@ -60,7 +85,7 @@ const SAMPLE_NODE: ManagedNodeInfo = {
|
|
|
60
85
|
status: 'running',
|
|
61
86
|
spawnedAt: '2025-01-01T00:00:00Z',
|
|
62
87
|
workdir: '',
|
|
63
|
-
ports: { health: 11, metrics: 12, p2p: 13 },
|
|
88
|
+
ports: { health: 11, metrics: 12, p2p: 13, admin: 14 },
|
|
64
89
|
};
|
|
65
90
|
|
|
66
91
|
describe('/api/nodes routes', () => {
|
|
@@ -146,17 +171,72 @@ describe('/api/nodes routes', () => {
|
|
|
146
171
|
]);
|
|
147
172
|
});
|
|
148
173
|
|
|
149
|
-
it('POST /api/nodes/:id/start returns 501 not_implemented', async () => {
|
|
174
|
+
it('POST /api/nodes/:id/start on a non-authority node returns 501 not_implemented', async () => {
|
|
150
175
|
const res = await app.inject({ method: 'POST', url: '/api/nodes/alice/start' });
|
|
151
176
|
expect(res.statusCode).toBe(501);
|
|
152
177
|
const body = res.json() as { error: { code: string } };
|
|
153
178
|
expect(body.error.code).toBe('not_implemented');
|
|
154
179
|
});
|
|
155
180
|
|
|
156
|
-
it('POST /api/nodes/:id/restart returns 501 not_implemented', async () => {
|
|
181
|
+
it('POST /api/nodes/:id/restart on a non-authority node returns 501 not_implemented', async () => {
|
|
157
182
|
const res = await app.inject({ method: 'POST', url: '/api/nodes/alice/restart' });
|
|
158
183
|
expect(res.statusCode).toBe(501);
|
|
159
184
|
});
|
|
185
|
+
|
|
186
|
+
it('POST /api/nodes/:id/start on an unknown node returns 404', async () => {
|
|
187
|
+
const res = await app.inject({ method: 'POST', url: '/api/nodes/nobody/start' });
|
|
188
|
+
expect(res.statusCode).toBe(404);
|
|
189
|
+
const body = res.json() as { error: { code: string } };
|
|
190
|
+
expect(body.error.code).toBe('not_found');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('/api/nodes — authority node start/restart', () => {
|
|
195
|
+
const AUTHORITY_NODE: ManagedNodeInfo = {
|
|
196
|
+
id: 'authority',
|
|
197
|
+
dockerId: '999:tok',
|
|
198
|
+
partyId: 'install-id',
|
|
199
|
+
profile: 'storage',
|
|
200
|
+
status: 'running',
|
|
201
|
+
spawnedAt: '2025-01-01T00:00:00Z',
|
|
202
|
+
workdir: '',
|
|
203
|
+
ports: { health: 1, metrics: 2, p2p: 4555, admin: 3 },
|
|
204
|
+
authority: true,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
let app: ReturnType<typeof Fastify>;
|
|
208
|
+
let orchestrator: FakeOrchestrator;
|
|
209
|
+
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
app = Fastify();
|
|
212
|
+
registerErrorHandler(app);
|
|
213
|
+
orchestrator = fakeOrchestrator([AUTHORITY_NODE], { authorityId: 'authority', hasAuthorityConfig: true });
|
|
214
|
+
registerNodesRoutes(app, { orchestrator });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
afterEach(async () => { await app.close(); });
|
|
218
|
+
|
|
219
|
+
it('start ensures the authority node', async () => {
|
|
220
|
+
const res = await app.inject({ method: 'POST', url: '/api/nodes/authority/start' });
|
|
221
|
+
expect(res.statusCode).toBe(200);
|
|
222
|
+
expect(orchestrator.__ensured).toBe(1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('restart re-spawns the authority node', async () => {
|
|
226
|
+
const res = await app.inject({ method: 'POST', url: '/api/nodes/authority/restart' });
|
|
227
|
+
expect(res.statusCode).toBe(200);
|
|
228
|
+
expect(orchestrator.__restarted).toBe(1);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('start returns 501 when there is no saved authority config', async () => {
|
|
232
|
+
const app2 = Fastify();
|
|
233
|
+
registerErrorHandler(app2);
|
|
234
|
+
const orch2 = fakeOrchestrator([AUTHORITY_NODE], { authorityId: 'authority', hasAuthorityConfig: false });
|
|
235
|
+
registerNodesRoutes(app2, { orchestrator: orch2 });
|
|
236
|
+
const res = await app2.inject({ method: 'POST', url: '/api/nodes/authority/start' });
|
|
237
|
+
expect(res.statusCode).toBe(501);
|
|
238
|
+
await app2.close();
|
|
239
|
+
});
|
|
160
240
|
});
|
|
161
241
|
|
|
162
242
|
describe('tailLogFile', () => {
|
|
@@ -131,7 +131,7 @@ describe('publisher wiring', () => {
|
|
|
131
131
|
status: 'stopped',
|
|
132
132
|
spawnedAt: 'x',
|
|
133
133
|
workdir: '/tmp',
|
|
134
|
-
ports: { health: 1, metrics: 2, p2p: 3 },
|
|
134
|
+
ports: { health: 1, metrics: 2, p2p: 3, admin: 4 },
|
|
135
135
|
});
|
|
136
136
|
expect(received).toContainEqual({
|
|
137
137
|
type: 'node-state-changed',
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join, normalize, relative, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import Fastify from 'fastify';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { registerStaticMount, resolveSafe } from '../static.js';
|
|
9
|
+
|
|
10
|
+
describe('resolveSafe', () => {
|
|
11
|
+
// POSIX-style fixtures; node:path normalizes separators per-platform, so the
|
|
12
|
+
// containment logic is exercised identically on Windows and POSIX.
|
|
13
|
+
const root = '/srv/app/dist/ui';
|
|
14
|
+
|
|
15
|
+
it('resolves a normal in-root asset', () => {
|
|
16
|
+
expect(resolveSafe(root, '/assets/app.js')).toBe(normalize(join(root, 'assets/app.js')));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('resolves a bare / to the root directory itself', () => {
|
|
20
|
+
expect(resolveSafe(root, '/')).toBe(normalize(root));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('resolves /index.html to the root index', () => {
|
|
24
|
+
expect(resolveSafe(root, '/index.html')).toBe(normalize(join(root, 'index.html')));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('rejects the sibling-directory prefix bypass', () => {
|
|
28
|
+
// `ui-private` shares `ui`'s basename as a prefix — the bare startsWith
|
|
29
|
+
// guard accepted this; the relative-based guard must reject it.
|
|
30
|
+
expect(resolveSafe(root, '/../ui-private/secret')).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('accepts an in-root asset whose name merely begins with ".."', () => {
|
|
34
|
+
// `..foo` is a legitimate descendant — the containment guard must not reject
|
|
35
|
+
// it via a loose '..' prefix match (the precise check keys on '..' + sep).
|
|
36
|
+
expect(resolveSafe(root, '/..foo.js')).toBe(normalize(join(root, '..foo.js')));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects ordinary out-of-tree traversal', () => {
|
|
40
|
+
expect(resolveSafe(root, '/../../etc/passwd')).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects an encoded out-of-tree traversal', () => {
|
|
44
|
+
expect(resolveSafe(root, '/%2e%2e/%2e%2e/etc/passwd')).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects a NUL-byte path', () => {
|
|
48
|
+
expect(resolveSafe(root, '/app%00.js')).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects malformed percent-encoding', () => {
|
|
52
|
+
expect(resolveSafe(root, '/%')).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('never returns a path that escapes the root', () => {
|
|
56
|
+
const escapes = [
|
|
57
|
+
'/../ui-private/secret',
|
|
58
|
+
'/../../etc/passwd',
|
|
59
|
+
'/../ui/../ui-private/x',
|
|
60
|
+
'/..',
|
|
61
|
+
];
|
|
62
|
+
for (const p of escapes) {
|
|
63
|
+
const resolved = resolveSafe(root, p);
|
|
64
|
+
if (resolved !== null) {
|
|
65
|
+
const rel = relative(normalize(root), resolved);
|
|
66
|
+
expect(rel === '..' || rel.startsWith('..' + sep)).toBe(false);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('serveStatic (via registerStaticMount)', () => {
|
|
73
|
+
let app: ReturnType<typeof Fastify>;
|
|
74
|
+
let tmpRoot: string;
|
|
75
|
+
let rootDir: string;
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-static-'));
|
|
79
|
+
rootDir = join(tmpRoot, 'ui');
|
|
80
|
+
mkdirSync(join(rootDir, 'assets'), { recursive: true });
|
|
81
|
+
writeFileSync(join(rootDir, 'index.html'), '<!doctype html><title>spa</title>');
|
|
82
|
+
writeFileSync(join(rootDir, 'assets', 'app.js'), 'console.log("hi");');
|
|
83
|
+
|
|
84
|
+
// Sibling directory whose name is a prefix superset of the root basename.
|
|
85
|
+
const sibling = join(tmpRoot, 'ui-private');
|
|
86
|
+
mkdirSync(sibling, { recursive: true });
|
|
87
|
+
writeFileSync(join(sibling, 'secret.txt'), 'top secret');
|
|
88
|
+
|
|
89
|
+
app = Fastify();
|
|
90
|
+
registerStaticMount(app, { rootDir });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(async () => {
|
|
94
|
+
await app.close();
|
|
95
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('serves an in-root asset', async () => {
|
|
99
|
+
const res = await app.inject({ method: 'GET', url: '/assets/app.js' });
|
|
100
|
+
expect(res.statusCode).toBe(200);
|
|
101
|
+
expect(res.headers['content-type']).toContain('application/javascript');
|
|
102
|
+
expect(res.body).toBe('console.log("hi");');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('serves index.html for the bare root', async () => {
|
|
106
|
+
const res = await app.inject({ method: 'GET', url: '/' });
|
|
107
|
+
expect(res.statusCode).toBe(200);
|
|
108
|
+
expect(res.headers['content-type']).toContain('text/html');
|
|
109
|
+
expect(res.body).toContain('<title>spa</title>');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('serves index.html as SPA fallback for unknown HTML routes', async () => {
|
|
113
|
+
const res = await app.inject({
|
|
114
|
+
method: 'GET',
|
|
115
|
+
url: '/some/client/route',
|
|
116
|
+
headers: { accept: 'text/html' },
|
|
117
|
+
});
|
|
118
|
+
expect(res.statusCode).toBe(200);
|
|
119
|
+
expect(res.body).toContain('<title>spa</title>');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// HTTP-level regression for the sibling-prefix bypass. Two vector classes:
|
|
123
|
+
// - literal / `%2e%2e`-with-literal-slash: Fastify's router collapses the
|
|
124
|
+
// `../` to a within-root path before the handler, so these never reach
|
|
125
|
+
// `resolveSafe` in escaping form — they pass regardless of the fix.
|
|
126
|
+
// - encoded-SEPARATOR (`%2f`/`%5c`): Fastify does NOT collapse these; the
|
|
127
|
+
// handler sees the still-encoded url, `decodeSafe` then reconstitutes
|
|
128
|
+
// `../`, and the escaping path DOES reach `resolveSafe`. Under the old
|
|
129
|
+
// `startsWith` guard this served the sibling secret over HTTP — a genuine
|
|
130
|
+
// exploit. These cases fail without the `relative`-based guard.
|
|
131
|
+
// Either way the asserted invariant is the same: sibling content never leaks.
|
|
132
|
+
it('never serves sibling-prefix directory content over HTTP', async () => {
|
|
133
|
+
const vectors = [
|
|
134
|
+
'/../ui-private/secret.txt',
|
|
135
|
+
'/%2e%2e/ui-private/secret.txt',
|
|
136
|
+
'/..%2fui-private%2fsecret.txt',
|
|
137
|
+
'/%2e%2e%2fui-private%2fsecret.txt',
|
|
138
|
+
'/..%5cui-private%5csecret.txt',
|
|
139
|
+
];
|
|
140
|
+
for (const url of vectors) {
|
|
141
|
+
const res = await app.inject({ method: 'GET', url });
|
|
142
|
+
expect(res.body).not.toContain('top secret');
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Fastify from 'fastify';
|
|
2
|
-
import { describe, it, expect,
|
|
2
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
3
3
|
|
|
4
4
|
import { registerErrorHandler } from '../error-handler.js';
|
|
5
5
|
import { registerStatusRoute } from '../routes/status.js';
|
|
@@ -64,7 +64,7 @@ describe('GET /api/status', () => {
|
|
|
64
64
|
status: 'running',
|
|
65
65
|
spawnedAt: '2025-01-01T00:00:00Z',
|
|
66
66
|
workdir: '/tmp/alice',
|
|
67
|
-
ports: { health: 1, metrics: 2, p2p: 3 },
|
|
67
|
+
ports: { health: 1, metrics: 2, p2p: 3, admin: 4 },
|
|
68
68
|
},
|
|
69
69
|
]),
|
|
70
70
|
trustCircle: fakeTrustCircle({
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* invalid_label, invalid_token → 400
|
|
8
8
|
* not_found, already_redeemed → 404
|
|
9
9
|
* expired → 410
|
|
10
|
+
* node_unavailable → 503
|
|
10
11
|
* storage_error → 500
|
|
11
12
|
* - NatError
|
|
12
13
|
* invalid_config, ddns_provider_unknown,
|
|
@@ -14,6 +15,7 @@
|
|
|
14
15
|
* secrets_unavailable, storage_error,
|
|
15
16
|
* mapping_failed, router_unreachable,
|
|
16
17
|
* ip_detection_failed, ddns_update_failed → 500
|
|
18
|
+
* node_unavailable → 503
|
|
17
19
|
* - UpdateErrorException
|
|
18
20
|
* manifest_invalid, no_update_available,
|
|
19
21
|
* min_previous_version → 400
|
|
@@ -39,6 +41,7 @@ const TRUST_STATUS: Record<TrustCircleErrorCode, number> = {
|
|
|
39
41
|
already_redeemed: 404,
|
|
40
42
|
expired: 410,
|
|
41
43
|
storage_error: 500,
|
|
44
|
+
node_unavailable: 503,
|
|
42
45
|
};
|
|
43
46
|
|
|
44
47
|
const NAT_STATUS: Record<NatErrorCode, number> = {
|
|
@@ -51,6 +54,7 @@ const NAT_STATUS: Record<NatErrorCode, number> = {
|
|
|
51
54
|
router_unreachable: 500,
|
|
52
55
|
ip_detection_failed: 500,
|
|
53
56
|
ddns_update_failed: 500,
|
|
57
|
+
node_unavailable: 503,
|
|
54
58
|
};
|
|
55
59
|
|
|
56
60
|
const UPDATE_STATUS: Record<UpdateErrorCode, number> = {
|