shadow-claw 1.24.1 → 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 +1 -1
- package/dist/public/README.md +2 -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/index.html +1 -1
- package/dist/public/index.js +1 -1
- package/dist/public/{initControlPlane-DKNht5ph.js → initControlPlane-D5kHKcB5.js} +1 -1
- 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/{orchestrator-8w9-exfN.js → orchestrator-CnwkoBpe.js} +1 -1
- package/dist/public/{peerjs-D8hck8Ih.js → peerjs-CuSJUH-r.js} +1 -1
- package/dist/public/service-worker.js +1 -1
- package/dist/public/{shadow-claw-Cs8wbk3d.js → shadow-claw-CUbhMmcB.js} +2 -2
- package/dist/public/{shadow-claw-chat-DGPKpmU5.js → shadow-claw-chat-BN85a1xk.js} +1 -1
- package/dist/public/{shadow-claw-control-plane-B1F0jnPk.js → shadow-claw-control-plane-C0lSxLpW.js} +1 -1
- package/dist/public/{shadow-claw-settings-D6lPBn6m.js → shadow-claw-settings-BZWrAoQA.js} +1 -1
- package/dist/server.js +203 -203
- package/package.json +1 -1
- package/src/core/utils/initControlPlane.ts +2 -0
- package/src/server/config.test.ts +49 -0
- package/src/server/config.ts +47 -0
- package/src/server/control-plane.ts +2 -1
- package/src/server/peer.ts +6 -2
- package/src/server/server.test.ts +118 -0
- package/src/server/server.ts +20 -5
- package/src/server/tls.test.ts +266 -0
- package/src/server/tls.ts +178 -0
package/package.json
CHANGED
|
@@ -334,9 +334,11 @@ export async function executeClientControlCommand(
|
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
const { BackupController } = await import("../backup-controller.js");
|
|
337
|
+
|
|
337
338
|
const backupCtrl = new BackupController({
|
|
338
339
|
clientId,
|
|
339
340
|
token: args.token,
|
|
341
|
+
serverBaseUrl: getControlPlaneServerUrl().httpUrl,
|
|
340
342
|
fileEnumerator: () => collectFiles(groupDir),
|
|
341
343
|
fileReader: (filePath) => readFile(filePath),
|
|
342
344
|
});
|
|
@@ -111,4 +111,53 @@ describe("config", () => {
|
|
|
111
111
|
|
|
112
112
|
expect(config.rootPath).toContain("public");
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
it("defaults https to false and provides default sslDir", async () => {
|
|
116
|
+
const { parseConfig } = await import("./config.js");
|
|
117
|
+
const config = parseConfig();
|
|
118
|
+
|
|
119
|
+
expect(config.https).toBe(false);
|
|
120
|
+
expect(config.certPath).toBeUndefined();
|
|
121
|
+
expect(config.keyPath).toBeUndefined();
|
|
122
|
+
expect(config.sslDir).toContain(".cache/tls");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("parses HTTPS options from CLI flags", async () => {
|
|
126
|
+
commanderMock.Command().opts.mockReturnValue({
|
|
127
|
+
https: true,
|
|
128
|
+
cert: "/custom/cert.pem",
|
|
129
|
+
key: "/custom/key.pem",
|
|
130
|
+
sslDir: "/custom/ssl",
|
|
131
|
+
corsAllowOrigin: [],
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const { parseConfig } = await import("./config.js");
|
|
135
|
+
const config = parseConfig();
|
|
136
|
+
|
|
137
|
+
expect(config.https).toBe(true);
|
|
138
|
+
expect(config.certPath).toBe("/custom/cert.pem");
|
|
139
|
+
expect(config.keyPath).toBe("/custom/key.pem");
|
|
140
|
+
expect(config.sslDir).toBe("/custom/ssl");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("parses HTTPS options from environment variables", async () => {
|
|
144
|
+
processMock.env.SHADOWCLAW_HTTPS = "1";
|
|
145
|
+
processMock.env.SHADOWCLAW_TLS_CERT = "/env/cert.pem";
|
|
146
|
+
processMock.env.SHADOWCLAW_TLS_KEY = "/env/key.pem";
|
|
147
|
+
processMock.env.SHADOWCLAW_SSL_DIR = "env-ssl";
|
|
148
|
+
|
|
149
|
+
const { parseConfig } = await import("./config.js");
|
|
150
|
+
const config = parseConfig();
|
|
151
|
+
|
|
152
|
+
expect(config.https).toBe(true);
|
|
153
|
+
expect(config.certPath).toBe("/env/cert.pem");
|
|
154
|
+
expect(config.keyPath).toBe("/env/key.pem");
|
|
155
|
+
expect(config.sslDir).toBe(
|
|
156
|
+
(await import("node:path")).default.resolve(
|
|
157
|
+
config.rootPath,
|
|
158
|
+
"..",
|
|
159
|
+
"env-ssl",
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
114
163
|
});
|
package/src/server/config.ts
CHANGED
|
@@ -27,6 +27,10 @@ export interface ServerConfig {
|
|
|
27
27
|
*/
|
|
28
28
|
controlToken?: string;
|
|
29
29
|
allowPrivateProxy: boolean;
|
|
30
|
+
https: boolean;
|
|
31
|
+
certPath?: string;
|
|
32
|
+
keyPath?: string;
|
|
33
|
+
sslDir: string;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export function parseConfig(): ServerConfig {
|
|
@@ -67,6 +71,13 @@ export function parseConfig(): ServerConfig {
|
|
|
67
71
|
.option(
|
|
68
72
|
"--control-token <token>",
|
|
69
73
|
"Secret token for control-plane authentication",
|
|
74
|
+
)
|
|
75
|
+
.option("--https", "Enable HTTPS dev server", false)
|
|
76
|
+
.option("--cert <path>", "Path to existing TLS certificate file")
|
|
77
|
+
.option("--key <path>", "Path to existing TLS private key file")
|
|
78
|
+
.option(
|
|
79
|
+
"--ssl-dir <path>",
|
|
80
|
+
"Directory for TLS certificate generation/storage",
|
|
70
81
|
);
|
|
71
82
|
|
|
72
83
|
program.parse();
|
|
@@ -178,6 +189,38 @@ export function parseConfig(): ServerConfig {
|
|
|
178
189
|
? options.controlToken.trim()
|
|
179
190
|
: envControlToken || undefined;
|
|
180
191
|
|
|
192
|
+
// HTTPS & TLS logic
|
|
193
|
+
const https =
|
|
194
|
+
Boolean(options.https) ||
|
|
195
|
+
["1", "true", "yes"].includes(
|
|
196
|
+
(env.SHADOWCLAW_HTTPS || "").toLowerCase().trim(),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const envCert = (env.SHADOWCLAW_TLS_CERT || env.SHADOWCLAW_CERT || "").trim();
|
|
200
|
+
const certPath = options.cert
|
|
201
|
+
? path.resolve(options.cert)
|
|
202
|
+
: envCert
|
|
203
|
+
? path.resolve(envCert)
|
|
204
|
+
: undefined;
|
|
205
|
+
|
|
206
|
+
const envKey = (env.SHADOWCLAW_TLS_KEY || env.SHADOWCLAW_KEY || "").trim();
|
|
207
|
+
const keyPath = options.key
|
|
208
|
+
? path.resolve(options.key)
|
|
209
|
+
: envKey
|
|
210
|
+
? path.resolve(envKey)
|
|
211
|
+
: undefined;
|
|
212
|
+
|
|
213
|
+
const envSslDir = (
|
|
214
|
+
env.SHADOWCLAW_SSL_DIR ||
|
|
215
|
+
env.SHADOWCLAW_TLS_DIR ||
|
|
216
|
+
""
|
|
217
|
+
).trim();
|
|
218
|
+
const sslDir = options.sslDir
|
|
219
|
+
? path.resolve(options.sslDir)
|
|
220
|
+
: envSslDir
|
|
221
|
+
? path.resolve(projectRoot, envSslDir)
|
|
222
|
+
: path.resolve(databaseDir, "..", ".cache", "tls");
|
|
223
|
+
|
|
181
224
|
return {
|
|
182
225
|
port,
|
|
183
226
|
bindHost,
|
|
@@ -189,5 +232,9 @@ export function parseConfig(): ServerConfig {
|
|
|
189
232
|
databaseDir,
|
|
190
233
|
controlToken,
|
|
191
234
|
allowPrivateProxy,
|
|
235
|
+
https,
|
|
236
|
+
certPath,
|
|
237
|
+
keyPath,
|
|
238
|
+
sslDir,
|
|
192
239
|
};
|
|
193
240
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import http from "node:http";
|
|
10
|
+
import https from "node:https";
|
|
10
11
|
import { URL } from "node:url";
|
|
11
12
|
import { WebSocketServer, WebSocket } from "ws";
|
|
12
13
|
import type { Express, Request, Response, NextFunction } from "express";
|
|
@@ -29,7 +30,7 @@ import type {
|
|
|
29
30
|
} from "./control-plane-types.js";
|
|
30
31
|
|
|
31
32
|
export interface ControlPlaneOptions {
|
|
32
|
-
httpServer: http.Server;
|
|
33
|
+
httpServer: http.Server | https.Server;
|
|
33
34
|
app?: Express;
|
|
34
35
|
token?: string;
|
|
35
36
|
heartbeatTimeoutMs?: number;
|
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,
|
|
@@ -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,7 +29,9 @@ 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,
|
|
@@ -49,9 +53,20 @@ export async function startServer(
|
|
|
49
53
|
});
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
return new Promise<http.Server>((resolve) => {
|
|
56
|
+
return new Promise<http.Server | https.Server>((resolve) => {
|
|
53
57
|
httpServer.listen(config.port, config.bindHost, () => {
|
|
54
|
-
|
|
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
|
+
}
|
|
55
70
|
|
|
56
71
|
if (config.bindHost === DEFAULT_DEV_IP) {
|
|
57
72
|
console.log(
|
|
@@ -72,7 +87,7 @@ export async function startServer(
|
|
|
72
87
|
}
|
|
73
88
|
|
|
74
89
|
console.log(
|
|
75
|
-
`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)`,
|
|
76
91
|
);
|
|
77
92
|
console.log(`Control token: ${controlPlane.getToken()}`);
|
|
78
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
|
+
});
|