shadow-claw 1.24.0 → 1.25.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 +2 -1
- package/bin/cli.mjs +111 -2
- package/bin/commands/webrtc-listen.mjs +21 -1
- package/bin/utils/control-client.mjs +44 -30
- package/bin/utils/control-client.test.mjs +10 -0
- package/bin/utils/webrtc-control-client.mjs +23 -4
- package/dist/public/AGENTS.md +8 -1
- package/dist/public/README.md +2 -1
- package/dist/public/agent.worker.js +49 -49
- package/dist/public/backup-controller-CvzDOq4T.js +1 -0
- package/dist/public/{configurePeerJs-CxpZeDJJ.js → configurePeerJs-UfQ6HUlv.js} +1 -1
- package/dist/public/docs/README.md +1 -1
- package/dist/public/docs/example/article/index.html +1 -1
- package/dist/public/docs/guides/server-development-configuration.md +28 -0
- package/dist/public/docs/publishing/index.html +1 -1
- package/dist/public/docs/skill-creator/index.html +1 -1
- package/dist/public/docs/subsystems/cli.md +25 -19
- package/dist/public/docs/subsystems/control-plane.md +11 -0
- package/dist/public/{downloadGroupFile-By0GR6eV.js → downloadGroupFile-9niHBj9n.js} +1 -1
- package/dist/public/{e2e-bridge-IGWsRl4i.js → e2e-bridge-D28tOggc.js} +1 -1
- package/dist/public/{file-viewer-D0eflgt6.js → file-viewer-BVg3B_JH.js} +1 -1
- package/dist/public/index.html +1 -1
- package/dist/public/index.js +1 -1
- package/dist/public/initControlPlane-D5kHKcB5.js +1 -0
- package/dist/public/llms.txt +1 -1
- package/dist/public/main/index.html +1 -1
- package/dist/public/main/memory/index.html +1 -1
- package/dist/public/{markdown-BnpKxSxl.js → markdown-SiFq0PTy.js} +1 -1
- package/dist/public/{orchestrator-nJj59RqD.js → orchestrator-CnwkoBpe.js} +1 -1
- package/dist/public/{orchestrator-3h9xxHUm.js → orchestrator-tHWrqi_F.js} +5 -5
- package/dist/public/{peerjs-BP4laMXj.js → peerjs-CuSJUH-r.js} +1 -1
- package/dist/public/push-client-D0lkwrK0.js +1 -0
- package/dist/public/service-worker/fetch-proxy.js +1 -1
- package/dist/public/service-worker.js +1 -1
- package/dist/public/{shadow-claw-CbwETBWf.js → shadow-claw-CUbhMmcB.js} +2 -2
- package/dist/public/{shadow-claw-channels-BDWG5Qa3.js → shadow-claw-channels-mY-JMvib.js} +1 -1
- package/dist/public/{shadow-claw-chat-Crv0DTCY.js → shadow-claw-chat-BN85a1xk.js} +1 -1
- package/dist/public/{shadow-claw-control-plane-CX1IGbh9.js → shadow-claw-control-plane-C0lSxLpW.js} +2 -2
- package/dist/public/{shadow-claw-conversations-_9qAaRXk.js → shadow-claw-conversations-0Q-VC_7N.js} +1 -1
- package/dist/public/{shadow-claw-file-viewer-Blf6yGVD.js → shadow-claw-file-viewer-AZfG4D-u.js} +1 -1
- package/dist/public/{shadow-claw-files-CYySBDCl.js → shadow-claw-files-Bx4v0Ggp.js} +1 -1
- package/dist/public/{shadow-claw-git-v8S0JjuR.js → shadow-claw-git-DD7iQRxq.js} +2 -2
- package/dist/public/{shadow-claw-llm-GWgoVfhI.js → shadow-claw-llm-CfeDBZKB.js} +1 -1
- package/dist/public/{shadow-claw-networking-qHbP95z9.js → shadow-claw-networking-e0EarDVs.js} +1 -1
- package/dist/public/{shadow-claw-notifications-YyHOK2Fr.js → shadow-claw-notifications-BkxuDEQI.js} +3 -3
- package/dist/public/{shadow-claw-pages-DFZHhzFZ.js → shadow-claw-pages-BWSE91I1.js} +1 -1
- package/dist/public/{shadow-claw-settings-DhxrKIfA.js → shadow-claw-settings-BZWrAoQA.js} +2 -2
- package/dist/public/{shadow-claw-storage-BFynbP0j.js → shadow-claw-storage-Crs6LY15.js} +1 -1
- package/dist/public/{shadow-claw-task-server-3BIeb5oZ.js → shadow-claw-task-server-CKm_Ennl.js} +1 -1
- package/dist/public/{shadow-claw-tasks-Caxc9atL.js → shadow-claw-tasks-Czx6Ucf5.js} +1 -1
- package/dist/public/{shadow-claw-terminal-Beb5qF-U.js → shadow-claw-terminal-Dmtr9KY1.js} +1 -1
- package/dist/public/{shadow-claw-tools-BfJx3A9W.js → shadow-claw-tools-t3Qq4TDO.js} +1 -1
- package/dist/public/{shadow-claw-webvm-BCS8ykeP.js → shadow-claw-webvm-CIuSywD_.js} +1 -1
- package/dist/public/{syncWebMcpRegistration-B_M2fCjH.js → syncWebMcpRegistration-DzC80wVG.js} +1 -1
- package/dist/server.js +203 -203
- package/package.json +1 -1
- package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.test.ts +3 -0
- package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.ts +43 -9
- package/src/core/backup-controller.ts +33 -4
- package/src/core/control-plane-client.test.ts +9 -2
- package/src/core/control-plane-client.ts +18 -2
- package/src/core/orchestrator/utils/operations/task.ts +46 -15
- package/src/core/utils/initControlPlane.ts +2 -0
- package/src/server/app.ts +2 -0
- package/src/server/config.test.ts +49 -0
- package/src/server/config.ts +47 -0
- package/src/server/control-plane.test.ts +36 -0
- package/src/server/control-plane.ts +41 -14
- package/src/server/middleware/pna.test.ts +15 -3
- package/src/server/middleware/pna.ts +6 -2
- package/src/server/peer.ts +6 -2
- package/src/server/routes/backup.test.ts +14 -0
- package/src/server/routes/backup.ts +44 -15
- package/src/server/server.test.ts +118 -0
- package/src/server/server.ts +22 -5
- package/src/server/tls.test.ts +266 -0
- package/src/server/tls.ts +178 -0
- package/src/service-worker/fetch-proxy-rules.test.ts +41 -0
- package/src/service-worker/fetch-proxy-rules.ts +15 -8
- package/src/service-worker/fetch-proxy.ts +18 -2
- package/src/subsystems/notifications/push-client.test.ts +5 -1
- package/src/subsystems/notifications/push-client.ts +50 -12
- package/dist/public/backup-controller-C_s9in2t.js +0 -1
- package/dist/public/initControlPlane-C9jpnWev.js +0 -1
- package/dist/public/push-client-CrjEXK3U.js +0 -1
package/src/server/peer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
2
3
|
import { ExpressPeerServer } from "peer";
|
|
3
4
|
import type { Express } from "express";
|
|
4
5
|
|
|
@@ -10,10 +11,13 @@ import type { Express } from "express";
|
|
|
10
11
|
* We mount the middleware at "/" and set internal path to "/" so routes land
|
|
11
12
|
* at /peerjs/* as expected.
|
|
12
13
|
*
|
|
13
|
-
* @param httpServer - The underlying http.Server (needed for WebSocket upgrade).
|
|
14
|
+
* @param httpServer - The underlying http.Server or https.Server (needed for WebSocket upgrade).
|
|
14
15
|
* @param app - The Express app to mount the PeerJS REST routes on.
|
|
15
16
|
*/
|
|
16
|
-
export function attachPeerServer(
|
|
17
|
+
export function attachPeerServer(
|
|
18
|
+
httpServer: http.Server | https.Server,
|
|
19
|
+
app: Express,
|
|
20
|
+
): void {
|
|
17
21
|
const peerServer = ExpressPeerServer(httpServer, {
|
|
18
22
|
path: "/",
|
|
19
23
|
allow_discovery: false,
|
|
@@ -121,6 +121,20 @@ describe("backup routes", () => {
|
|
|
121
121
|
expect(res.data.backups).toBeDefined();
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
it("allows GitHub Pages origin requests without explicit token header", async () => {
|
|
125
|
+
const res = await makeHttpRequest({
|
|
126
|
+
port,
|
|
127
|
+
path: "/api/backup/list",
|
|
128
|
+
headers: {
|
|
129
|
+
host: `127.0.0.1:${port}`,
|
|
130
|
+
origin: "https://xt-ml.github.io",
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(res.status).toBe(200);
|
|
135
|
+
expect(res.data.backups).toBeDefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
124
138
|
it("uploads a file and verifies on disk", async () => {
|
|
125
139
|
const res = await makeHttpRequest({
|
|
126
140
|
port,
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
export interface BackupRoutesOptions {
|
|
21
21
|
backupsDir: string;
|
|
22
22
|
token?: string;
|
|
23
|
+
allowedOrigins?: Set<string>;
|
|
24
|
+
corsMode?: "localhost" | "private" | "all";
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
function isSafeRelativePath(relPath: string): boolean {
|
|
@@ -37,39 +39,66 @@ function isSafeRelativePath(relPath: string): boolean {
|
|
|
37
39
|
return true;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
function isSameOriginBrowser(
|
|
42
|
+
function isSameOriginBrowser(
|
|
43
|
+
req: Request,
|
|
44
|
+
options?: BackupRoutesOptions,
|
|
45
|
+
): boolean {
|
|
41
46
|
const host = req.headers.host || "127.0.0.1";
|
|
42
47
|
const origin = req.headers.origin;
|
|
43
48
|
const referer = req.headers.referer;
|
|
44
49
|
|
|
45
|
-
|
|
50
|
+
const isTrustedUrl = (urlStr: string): boolean => {
|
|
46
51
|
try {
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
52
|
+
const parsed = new URL(urlStr);
|
|
53
|
+
if (parsed.host === host) {
|
|
49
54
|
return true;
|
|
50
55
|
}
|
|
56
|
+
|
|
57
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
51
58
|
if (
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
hostname === "127.0.0.1" ||
|
|
60
|
+
hostname === "localhost" ||
|
|
61
|
+
hostname === "::1" ||
|
|
62
|
+
hostname === "[::1]"
|
|
54
63
|
) {
|
|
55
64
|
return true;
|
|
56
65
|
}
|
|
57
|
-
} catch (_) {}
|
|
58
|
-
}
|
|
59
66
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
if (
|
|
68
|
+
hostname === "github.com" ||
|
|
69
|
+
hostname.endsWith(".github.io") ||
|
|
70
|
+
hostname.endsWith(".pages.dev")
|
|
71
|
+
) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (options?.corsMode === "all") {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
options?.allowedOrigins &&
|
|
81
|
+
options.allowedOrigins.has(parsed.origin)
|
|
82
|
+
) {
|
|
64
83
|
return true;
|
|
65
84
|
}
|
|
85
|
+
|
|
66
86
|
if (
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
options?.corsMode === "private" &&
|
|
88
|
+
/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname)
|
|
69
89
|
) {
|
|
70
90
|
return true;
|
|
71
91
|
}
|
|
72
92
|
} catch (_) {}
|
|
93
|
+
return false;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (origin && isTrustedUrl(origin)) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (referer && isTrustedUrl(referer)) {
|
|
101
|
+
return true;
|
|
73
102
|
}
|
|
74
103
|
|
|
75
104
|
const secFetchSite = req.headers["sec-fetch-site"];
|
|
@@ -103,7 +132,7 @@ export function registerBackupRoutes(
|
|
|
103
132
|
|
|
104
133
|
const isAuthValid = providedToken
|
|
105
134
|
? providedToken === token
|
|
106
|
-
: isSameOriginBrowser(req);
|
|
135
|
+
: isSameOriginBrowser(req, options);
|
|
107
136
|
|
|
108
137
|
if (!isAuthValid) {
|
|
109
138
|
res.status(401).json({ error: "Unauthorized: Invalid control token" });
|
|
@@ -147,3 +147,121 @@ describe("src/server/server.ts proxy simulation", () => {
|
|
|
147
147
|
expect(headers.authorization).toBe("Bearer server-key");
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
|
+
|
|
151
|
+
describe("startServer HTTP / HTTPS selection", () => {
|
|
152
|
+
let httpMock: any;
|
|
153
|
+
let httpsMock: any;
|
|
154
|
+
let appMock: any;
|
|
155
|
+
let tlsMock: any;
|
|
156
|
+
let tcpPortUsedMock: any;
|
|
157
|
+
let controlPlaneMock: any;
|
|
158
|
+
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
jest.resetModules();
|
|
161
|
+
|
|
162
|
+
const fakeServer = {
|
|
163
|
+
listen: jest.fn((_port: number, _host: string, cb: any) => {
|
|
164
|
+
if (cb) cb();
|
|
165
|
+
return fakeServer;
|
|
166
|
+
}),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
httpMock = {
|
|
170
|
+
createServer: jest.fn(() => fakeServer),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
httpsMock = {
|
|
174
|
+
createServer: jest.fn(() => fakeServer),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
appMock = {
|
|
178
|
+
createApp: jest.fn(() => ({
|
|
179
|
+
app: {},
|
|
180
|
+
scheduler: { start: jest.fn() },
|
|
181
|
+
})),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
tlsMock = {
|
|
185
|
+
ensureTlsCredentials: jest.fn(() => ({
|
|
186
|
+
key: Buffer.from("fake-key"),
|
|
187
|
+
cert: Buffer.from("fake-cert"),
|
|
188
|
+
})),
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
tcpPortUsedMock = {
|
|
192
|
+
check: jest.fn(() => Promise.resolve(false)),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
controlPlaneMock = {
|
|
196
|
+
createControlPlane: jest.fn(() => ({
|
|
197
|
+
getToken: jest.fn(() => "test-token"),
|
|
198
|
+
})),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
jest.spyOn(console, "log").mockImplementation(() => {});
|
|
202
|
+
|
|
203
|
+
jest.unstable_mockModule("node:http", () => ({
|
|
204
|
+
default: httpMock,
|
|
205
|
+
...httpMock,
|
|
206
|
+
}));
|
|
207
|
+
jest.unstable_mockModule("node:https", () => ({
|
|
208
|
+
default: httpsMock,
|
|
209
|
+
...httpsMock,
|
|
210
|
+
}));
|
|
211
|
+
jest.unstable_mockModule("./app.js", () => appMock);
|
|
212
|
+
jest.unstable_mockModule("./tls.js", () => tlsMock);
|
|
213
|
+
jest.unstable_mockModule("tcp-port-used", () => ({
|
|
214
|
+
default: tcpPortUsedMock,
|
|
215
|
+
...tcpPortUsedMock,
|
|
216
|
+
}));
|
|
217
|
+
jest.unstable_mockModule("./control-plane.js", () => controlPlaneMock);
|
|
218
|
+
jest.unstable_mockModule("./peer.js", () => ({
|
|
219
|
+
attachPeerServer: jest.fn(),
|
|
220
|
+
}));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("creates http.Server when config.https is false", async () => {
|
|
224
|
+
const { startServer } = await import("./server.js");
|
|
225
|
+
await startServer({
|
|
226
|
+
port: 8888,
|
|
227
|
+
bindHost: "127.0.0.1",
|
|
228
|
+
corsMode: "localhost",
|
|
229
|
+
allowedOrigins: new Set(),
|
|
230
|
+
verbose: false,
|
|
231
|
+
peerjs: false,
|
|
232
|
+
rootPath: "/root",
|
|
233
|
+
databaseDir: "/db",
|
|
234
|
+
allowPrivateProxy: false,
|
|
235
|
+
https: false,
|
|
236
|
+
sslDir: "/ssl",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(httpMock.createServer).toHaveBeenCalledTimes(1);
|
|
240
|
+
expect(httpsMock.createServer).not.toHaveBeenCalled();
|
|
241
|
+
expect(tlsMock.ensureTlsCredentials).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("creates https.Server with TLS credentials when config.https is true", async () => {
|
|
245
|
+
const { startServer } = await import("./server.js");
|
|
246
|
+
await startServer({
|
|
247
|
+
port: 8888,
|
|
248
|
+
bindHost: "127.0.0.1",
|
|
249
|
+
corsMode: "localhost",
|
|
250
|
+
allowedOrigins: new Set(),
|
|
251
|
+
verbose: false,
|
|
252
|
+
peerjs: false,
|
|
253
|
+
rootPath: "/root",
|
|
254
|
+
databaseDir: "/db",
|
|
255
|
+
allowPrivateProxy: false,
|
|
256
|
+
https: true,
|
|
257
|
+
sslDir: "/ssl",
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(tlsMock.ensureTlsCredentials).toHaveBeenCalledTimes(1);
|
|
261
|
+
expect(httpsMock.createServer).toHaveBeenCalledWith(
|
|
262
|
+
{ key: Buffer.from("fake-key"), cert: Buffer.from("fake-cert") },
|
|
263
|
+
expect.anything(),
|
|
264
|
+
);
|
|
265
|
+
expect(httpMock.createServer).not.toHaveBeenCalled();
|
|
266
|
+
});
|
|
267
|
+
});
|
package/src/server/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { exit } from "node:process";
|
|
2
2
|
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import tcpPortUsed from "tcp-port-used";
|
|
@@ -10,10 +11,11 @@ import { attachPeerServer } from "./peer.js";
|
|
|
10
11
|
import { createControlPlane } from "./control-plane.js";
|
|
11
12
|
import { ServerPeer } from "./server-peer.js";
|
|
12
13
|
import { parseConfig, ServerConfig } from "./config.js";
|
|
14
|
+
import { ensureTlsCredentials } from "./tls.js";
|
|
13
15
|
|
|
14
16
|
export async function startServer(
|
|
15
17
|
customConfig?: ServerConfig,
|
|
16
|
-
): Promise<http.Server> {
|
|
18
|
+
): Promise<http.Server | https.Server> {
|
|
17
19
|
const config = customConfig || parseConfig();
|
|
18
20
|
const { app, scheduler } = createApp(config);
|
|
19
21
|
|
|
@@ -27,13 +29,17 @@ export async function startServer(
|
|
|
27
29
|
|
|
28
30
|
scheduler.start();
|
|
29
31
|
|
|
30
|
-
const httpServer =
|
|
32
|
+
const httpServer: http.Server | https.Server = config.https
|
|
33
|
+
? https.createServer(ensureTlsCredentials(config), app)
|
|
34
|
+
: http.createServer(app);
|
|
31
35
|
|
|
32
36
|
const controlPlane = createControlPlane({
|
|
33
37
|
httpServer,
|
|
34
38
|
app,
|
|
35
39
|
token: config.controlToken,
|
|
36
40
|
verbose: config.verbose,
|
|
41
|
+
allowedOrigins: config.allowedOrigins,
|
|
42
|
+
corsMode: config.corsMode,
|
|
37
43
|
});
|
|
38
44
|
|
|
39
45
|
let serverPeer: ServerPeer | null = null;
|
|
@@ -47,9 +53,20 @@ export async function startServer(
|
|
|
47
53
|
});
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
return new Promise<http.Server>((resolve) => {
|
|
56
|
+
return new Promise<http.Server | https.Server>((resolve) => {
|
|
51
57
|
httpServer.listen(config.port, config.bindHost, () => {
|
|
52
|
-
|
|
58
|
+
const protocol = config.https ? "https" : "http";
|
|
59
|
+
const wsProtocol = config.https ? "wss" : "ws";
|
|
60
|
+
|
|
61
|
+
console.log(
|
|
62
|
+
`Server running at ${protocol}://${config.bindHost}:${config.port}`,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (config.https) {
|
|
66
|
+
const certLocation =
|
|
67
|
+
config.certPath || path.join(config.sslDir, "cert.pem");
|
|
68
|
+
console.log(`TLS enabled (cert: ${certLocation})`);
|
|
69
|
+
}
|
|
53
70
|
|
|
54
71
|
if (config.bindHost === DEFAULT_DEV_IP) {
|
|
55
72
|
console.log(
|
|
@@ -70,7 +87,7 @@ export async function startServer(
|
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
console.log(
|
|
73
|
-
`Control plane active at
|
|
90
|
+
`Control plane active at ${protocol}://${config.bindHost}:${config.port}/api/control/events (SSE) and ${wsProtocol}://${config.bindHost}:${config.port}/ws/control (WebSocket)`,
|
|
74
91
|
);
|
|
75
92
|
console.log(`Control token: ${controlPlane.getToken()}`);
|
|
76
93
|
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import type { ServerConfig } from "./config.js";
|
|
3
|
+
|
|
4
|
+
describe("tls credentials", () => {
|
|
5
|
+
let fsMock: any;
|
|
6
|
+
let childProcessMock: any;
|
|
7
|
+
let processMock: any;
|
|
8
|
+
let osMock: any;
|
|
9
|
+
let baseConfig: ServerConfig;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
jest.resetModules();
|
|
13
|
+
|
|
14
|
+
fsMock = {
|
|
15
|
+
existsSync: jest.fn(),
|
|
16
|
+
readFileSync: jest.fn(),
|
|
17
|
+
mkdirSync: jest.fn(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
childProcessMock = {
|
|
21
|
+
execFileSync: jest.fn(),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
processMock = {
|
|
25
|
+
exit: jest.fn(),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
osMock = {
|
|
29
|
+
networkInterfaces: jest.fn(() => ({
|
|
30
|
+
eth0: [{ address: "192.168.1.50" }],
|
|
31
|
+
})),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
baseConfig = {
|
|
35
|
+
port: 8888,
|
|
36
|
+
bindHost: "127.0.0.1",
|
|
37
|
+
corsMode: "localhost",
|
|
38
|
+
allowedOrigins: new Set(),
|
|
39
|
+
verbose: false,
|
|
40
|
+
peerjs: false,
|
|
41
|
+
rootPath: "/app/public",
|
|
42
|
+
databaseDir: "/app/database",
|
|
43
|
+
allowPrivateProxy: false,
|
|
44
|
+
https: true,
|
|
45
|
+
sslDir: "/app/.cache/tls",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
49
|
+
jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
50
|
+
|
|
51
|
+
jest.unstable_mockModule("node:fs", () => ({
|
|
52
|
+
default: fsMock,
|
|
53
|
+
...fsMock,
|
|
54
|
+
}));
|
|
55
|
+
jest.unstable_mockModule("node:os", () => ({
|
|
56
|
+
default: osMock,
|
|
57
|
+
...osMock,
|
|
58
|
+
}));
|
|
59
|
+
jest.unstable_mockModule("node:child_process", () => ({
|
|
60
|
+
default: childProcessMock,
|
|
61
|
+
...childProcessMock,
|
|
62
|
+
}));
|
|
63
|
+
jest.unstable_mockModule("node:process", () => ({
|
|
64
|
+
exit: processMock.exit,
|
|
65
|
+
default: processMock,
|
|
66
|
+
}));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("reads explicit cert and key files when both are provided and exist", async () => {
|
|
70
|
+
fsMock.existsSync.mockReturnValue(true);
|
|
71
|
+
fsMock.readFileSync.mockImplementation((file: string) =>
|
|
72
|
+
Buffer.from(`content of ${file}`),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const { ensureTlsCredentials } = await import("./tls.js");
|
|
76
|
+
const credentials = ensureTlsCredentials({
|
|
77
|
+
...baseConfig,
|
|
78
|
+
certPath: "/custom/cert.pem",
|
|
79
|
+
keyPath: "/custom/key.pem",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(credentials.cert.toString()).toBe("content of /custom/cert.pem");
|
|
83
|
+
expect(credentials.key.toString()).toBe("content of /custom/key.pem");
|
|
84
|
+
expect(childProcessMock.execFileSync).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("exits when certPath is provided without keyPath", async () => {
|
|
88
|
+
const { ensureTlsCredentials } = await import("./tls.js");
|
|
89
|
+
ensureTlsCredentials({
|
|
90
|
+
...baseConfig,
|
|
91
|
+
certPath: "/custom/cert.pem",
|
|
92
|
+
keyPath: undefined,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(processMock.exit).toHaveBeenCalledWith(1);
|
|
96
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
97
|
+
expect.stringContaining("provided without --key"),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("exits when keyPath is provided without certPath", async () => {
|
|
102
|
+
const { ensureTlsCredentials } = await import("./tls.js");
|
|
103
|
+
ensureTlsCredentials({
|
|
104
|
+
...baseConfig,
|
|
105
|
+
certPath: undefined,
|
|
106
|
+
keyPath: "/custom/key.pem",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(processMock.exit).toHaveBeenCalledWith(1);
|
|
110
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
111
|
+
expect.stringContaining("provided without --cert"),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("exits when provided cert file does not exist", async () => {
|
|
116
|
+
fsMock.existsSync.mockImplementation(
|
|
117
|
+
(file: string) => file !== "/custom/cert.pem",
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const { ensureTlsCredentials } = await import("./tls.js");
|
|
121
|
+
ensureTlsCredentials({
|
|
122
|
+
...baseConfig,
|
|
123
|
+
certPath: "/custom/cert.pem",
|
|
124
|
+
keyPath: "/custom/key.pem",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(processMock.exit).toHaveBeenCalledWith(1);
|
|
128
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
129
|
+
expect.stringContaining("TLS certificate not found"),
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("exits when provided key file does not exist", async () => {
|
|
134
|
+
fsMock.existsSync.mockImplementation(
|
|
135
|
+
(file: string) => file !== "/custom/key.pem",
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const { ensureTlsCredentials } = await import("./tls.js");
|
|
139
|
+
ensureTlsCredentials({
|
|
140
|
+
...baseConfig,
|
|
141
|
+
certPath: "/custom/cert.pem",
|
|
142
|
+
keyPath: "/custom/key.pem",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(processMock.exit).toHaveBeenCalledWith(1);
|
|
146
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
147
|
+
expect.stringContaining("TLS private key not found"),
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns cached cert and key if already present in sslDir", async () => {
|
|
152
|
+
fsMock.existsSync.mockReturnValue(true);
|
|
153
|
+
fsMock.readFileSync.mockImplementation((file: string) =>
|
|
154
|
+
Buffer.from(`cached ${file}`),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const { ensureTlsCredentials } = await import("./tls.js");
|
|
158
|
+
const credentials = ensureTlsCredentials(baseConfig);
|
|
159
|
+
|
|
160
|
+
expect(credentials.cert.toString()).toBe("cached /app/.cache/tls/cert.pem");
|
|
161
|
+
expect(credentials.key.toString()).toBe("cached /app/.cache/tls/key.pem");
|
|
162
|
+
expect(childProcessMock.execFileSync).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("generates self-signed cert and key via openssl when not in sslDir", async () => {
|
|
166
|
+
fsMock.existsSync.mockReturnValue(false);
|
|
167
|
+
fsMock.readFileSync.mockImplementation((file: string) =>
|
|
168
|
+
Buffer.from(`generated ${file}`),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const { ensureTlsCredentials } = await import("./tls.js");
|
|
172
|
+
const credentials = ensureTlsCredentials(baseConfig);
|
|
173
|
+
|
|
174
|
+
expect(fsMock.mkdirSync).toHaveBeenCalledWith("/app/.cache/tls", {
|
|
175
|
+
recursive: true,
|
|
176
|
+
});
|
|
177
|
+
expect(childProcessMock.execFileSync).toHaveBeenCalledWith(
|
|
178
|
+
"openssl",
|
|
179
|
+
expect.arrayContaining([
|
|
180
|
+
"-addext",
|
|
181
|
+
expect.stringMatching(/subjectAltName=.*IP:192\.168\.1\.50/),
|
|
182
|
+
]),
|
|
183
|
+
{ stdio: "pipe" },
|
|
184
|
+
);
|
|
185
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
186
|
+
expect.stringContaining("Generated self-signed TLS certificate"),
|
|
187
|
+
);
|
|
188
|
+
expect(credentials.cert.toString()).toBe(
|
|
189
|
+
"generated /app/.cache/tls/cert.pem",
|
|
190
|
+
);
|
|
191
|
+
expect(credentials.key.toString()).toBe(
|
|
192
|
+
"generated /app/.cache/tls/key.pem",
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("resolves SAN entries including loopback, local interfaces, and bindHost", async () => {
|
|
197
|
+
const { resolveSanList } = await import("./tls.js");
|
|
198
|
+
const sans = resolveSanList("10.9.8.226");
|
|
199
|
+
|
|
200
|
+
expect(sans).toContain("DNS:localhost");
|
|
201
|
+
expect(sans).toContain("IP:127.0.0.1");
|
|
202
|
+
expect(sans).toContain("IP:::1");
|
|
203
|
+
expect(sans).toContain("IP:192.168.1.50");
|
|
204
|
+
expect(sans).toContain("IP:10.9.8.226");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("resolves DNS hostname for bindHost if bindHost is a domain", async () => {
|
|
208
|
+
const { resolveSanList } = await import("./tls.js");
|
|
209
|
+
const sans = resolveSanList("my-laptop.local");
|
|
210
|
+
|
|
211
|
+
expect(sans).toContain("DNS:my-laptop.local");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("falls back to standard openssl args when -addext fails", async () => {
|
|
215
|
+
fsMock.existsSync.mockReturnValue(false);
|
|
216
|
+
fsMock.readFileSync.mockImplementation((file: string) =>
|
|
217
|
+
Buffer.from(`generated ${file}`),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
childProcessMock.execFileSync
|
|
221
|
+
.mockImplementationOnce(() => {
|
|
222
|
+
throw new Error("unknown option -addext");
|
|
223
|
+
})
|
|
224
|
+
.mockImplementationOnce(() => Buffer.from(""));
|
|
225
|
+
|
|
226
|
+
const { ensureTlsCredentials } = await import("./tls.js");
|
|
227
|
+
const credentials = ensureTlsCredentials(baseConfig);
|
|
228
|
+
|
|
229
|
+
expect(childProcessMock.execFileSync).toHaveBeenCalledTimes(2);
|
|
230
|
+
expect(credentials.cert.toString()).toBe(
|
|
231
|
+
"generated /app/.cache/tls/cert.pem",
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("handles ENOENT when OpenSSL binary is missing", async () => {
|
|
236
|
+
fsMock.existsSync.mockReturnValue(false);
|
|
237
|
+
const enoentError: any = new Error("spawnSync openssl ENOENT");
|
|
238
|
+
enoentError.code = "ENOENT";
|
|
239
|
+
childProcessMock.execFileSync.mockImplementation(() => {
|
|
240
|
+
throw enoentError;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const { ensureTlsCredentials } = await import("./tls.js");
|
|
244
|
+
ensureTlsCredentials(baseConfig);
|
|
245
|
+
|
|
246
|
+
expect(processMock.exit).toHaveBeenCalledWith(1);
|
|
247
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
248
|
+
expect.stringContaining("OpenSSL must be installed"),
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("handles generic openssl execution error", async () => {
|
|
253
|
+
fsMock.existsSync.mockReturnValue(false);
|
|
254
|
+
childProcessMock.execFileSync.mockImplementation(() => {
|
|
255
|
+
throw new Error("Command failed");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const { ensureTlsCredentials } = await import("./tls.js");
|
|
259
|
+
ensureTlsCredentials(baseConfig);
|
|
260
|
+
|
|
261
|
+
expect(processMock.exit).toHaveBeenCalledWith(1);
|
|
262
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
263
|
+
expect.stringContaining("Error generating TLS certificate with OpenSSL"),
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
});
|