smart-home-engine 1.0.9 → 1.1.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/dist/web/assets/{index-B_L247qc.css → index-DKIgEFlE.css} +1 -1
- package/dist/web/assets/index-YxGnpZAh.js +230 -0
- package/dist/web/assets/{tsMode-CjmIPcRa.js → tsMode-DAKcfE4c.js} +1 -1
- package/dist/web/index.html +16 -6
- package/package.json +1 -1
- package/src/index.js +4 -0
- package/src/lib/ca.js +474 -0
- package/src/lib/dynsec.js +295 -0
- package/src/lib/mosquitto-conf.js +287 -0
- package/src/lib/ssh-deploy.js +216 -0
- package/src/matter/controller.js +5 -0
- package/src/sandbox/broker-sandbox.js +113 -0
- package/src/sandbox/stdlib.js +1 -4
- package/src/web/ai-api.js +21 -11
- package/src/web/broker-api.js +761 -0
- package/src/web/config-api.js +6 -4
- package/src/web/deps-api.js +4 -5
- package/src/web/git-api.js +2 -8
- package/src/web/log-ws.js +1 -1
- package/src/web/matter-api.js +1 -2
- package/src/web/scripts-api.js +8 -2
- package/src/web/server.js +8 -2
- package/dist/web/assets/index-BYavlZcf.js +0 -230
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{m as O}from"./monaco-langs-BW2J83t5.js";import{t as I}from"./index-
|
|
1
|
+
import{m as O}from"./monaco-langs-BW2J83t5.js";import{t as I}from"./index-YxGnpZAh.js";/*!-----------------------------------------------------------------------------
|
|
2
2
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
3
|
* Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
|
|
4
4
|
* Released under the MIT license
|
package/dist/web/index.html
CHANGED
|
@@ -141,10 +141,20 @@
|
|
|
141
141
|
scrollbar-width: thin;
|
|
142
142
|
scrollbar-color: var(--fg-dim) transparent;
|
|
143
143
|
}
|
|
144
|
-
*::-webkit-scrollbar {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
*::-webkit-scrollbar {
|
|
145
|
+
width: 6px;
|
|
146
|
+
height: 6px;
|
|
147
|
+
}
|
|
148
|
+
*::-webkit-scrollbar-track {
|
|
149
|
+
background: transparent;
|
|
150
|
+
}
|
|
151
|
+
*::-webkit-scrollbar-thumb {
|
|
152
|
+
background: var(--fg-dim);
|
|
153
|
+
border-radius: 3px;
|
|
154
|
+
}
|
|
155
|
+
*::-webkit-scrollbar-thumb:hover {
|
|
156
|
+
background: var(--fg-muted);
|
|
157
|
+
}
|
|
148
158
|
body {
|
|
149
159
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
|
150
160
|
background: var(--bg-app);
|
|
@@ -162,10 +172,10 @@
|
|
|
162
172
|
}
|
|
163
173
|
})();
|
|
164
174
|
</script>
|
|
165
|
-
<script type="module" crossorigin src="/assets/index-
|
|
175
|
+
<script type="module" crossorigin src="/assets/index-YxGnpZAh.js"></script>
|
|
166
176
|
<link rel="modulepreload" crossorigin href="/assets/monaco-langs-BW2J83t5.js">
|
|
167
177
|
<link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
|
|
168
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
178
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DKIgEFlE.css">
|
|
169
179
|
</head>
|
|
170
180
|
<body>
|
|
171
181
|
<div id="app"></div>
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -504,6 +504,10 @@ if (config.matterStorage) {
|
|
|
504
504
|
log.warn('matter controller disabled — set matterStorage in config.json to enable');
|
|
505
505
|
}
|
|
506
506
|
|
|
507
|
+
// dynsec broker admin client - only init when broker.dynsec credentials are configured
|
|
508
|
+
if (config.broker && config.broker.dynsec && config.url) {
|
|
509
|
+
require('./lib/dynsec').init(config, log);
|
|
510
|
+
}
|
|
507
511
|
// If no broker is configured, start scripts immediately.
|
|
508
512
|
// If a broker is configured, startOnce() fires from the quiet-period timer inside
|
|
509
513
|
// mqtt.on('connect') once the retained-message burst settles, or from the
|
package/src/lib/ca.js
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ca.js — Local CA operations for she's broker certificate manager.
|
|
5
|
+
*
|
|
6
|
+
* All operations shell out to the system `openssl` binary (standard on
|
|
7
|
+
* Linux/macOS). No new Node.js dependencies required.
|
|
8
|
+
*
|
|
9
|
+
* Directory layout under caDir (default ~/.she/broker/ca/):
|
|
10
|
+
* ca.key — CA private key (Ed25519, chmod 600)
|
|
11
|
+
* ca.crt — CA self-signed certificate
|
|
12
|
+
* ca.srl — serial counter file
|
|
13
|
+
* crl.pem — certificate revocation list
|
|
14
|
+
* clients/ — issued client certs (per-CN subdirectory)
|
|
15
|
+
* <cn>/
|
|
16
|
+
* client.key
|
|
17
|
+
* client.crt
|
|
18
|
+
* client.p12
|
|
19
|
+
*
|
|
20
|
+
* CA cert metadata is stored in sheDB at broker::ca.
|
|
21
|
+
* Issued cert metadata is stored in sheDB at broker::cert::<serial>.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const { execFile } = require('child_process');
|
|
25
|
+
const { promisify } = require('util');
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const os = require('os');
|
|
29
|
+
const crypto = require('crypto');
|
|
30
|
+
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
32
|
+
|
|
33
|
+
const OPENSSL = 'openssl';
|
|
34
|
+
|
|
35
|
+
// ── Path helpers ───────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function expandHome(p) {
|
|
38
|
+
if (p.startsWith('~/') || p === '~') {
|
|
39
|
+
return path.join(os.homedir(), p.slice(2));
|
|
40
|
+
}
|
|
41
|
+
return p;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function caDir(config) {
|
|
45
|
+
return expandHome((config.broker && config.broker.caDir) || '~/.she/broker/ca');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function caCertsDir(config) {
|
|
49
|
+
return expandHome((config.broker && config.broker.caCertsDir) || '~/.she/broker/ca-certs');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── openssl wrapper ────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
async function openssl(args, options = {}) {
|
|
55
|
+
const { stdout, stderr } = await execFileAsync(OPENSSL, args, {
|
|
56
|
+
timeout: 30000,
|
|
57
|
+
...options,
|
|
58
|
+
});
|
|
59
|
+
return { stdout, stderr };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── CA generation ──────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generate a new local CA keypair + self-signed cert.
|
|
66
|
+
* @param {object} config
|
|
67
|
+
* @param {{ cn?: string, days?: number }} options
|
|
68
|
+
* @returns {Promise<{ crt: string, fingerprint: string, expires: string }>}
|
|
69
|
+
*/
|
|
70
|
+
async function generateCA(config, { cn = 'she-broker-ca', days = 365 } = {}) {
|
|
71
|
+
const dir = caDir(config);
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
const keyPath = path.join(dir, 'ca.key');
|
|
75
|
+
const crtPath = path.join(dir, 'ca.crt');
|
|
76
|
+
const srlPath = path.join(dir, 'ca.srl');
|
|
77
|
+
|
|
78
|
+
// Generate Ed25519 key + self-signed cert
|
|
79
|
+
await openssl(['req', '-x509', '-newkey', 'ed25519', '-keyout', keyPath, '-out', crtPath, '-days', String(days), '-nodes', '-subj', `/CN=${cn}`]);
|
|
80
|
+
|
|
81
|
+
// chmod 600 the private key
|
|
82
|
+
try {
|
|
83
|
+
fs.chmodSync(keyPath, 0o600);
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore on systems where this fails */
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Initialise serial file
|
|
89
|
+
if (!fs.existsSync(srlPath)) {
|
|
90
|
+
fs.writeFileSync(srlPath, '01\n', 'utf8');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fingerprint = await certFingerprint(crtPath);
|
|
94
|
+
const expires = await certExpiry(crtPath);
|
|
95
|
+
const crt = fs.readFileSync(crtPath, 'utf8');
|
|
96
|
+
|
|
97
|
+
return { crt, fingerprint, expires, cn };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get CA status. Returns null if no CA exists.
|
|
102
|
+
* @param {object} config
|
|
103
|
+
*/
|
|
104
|
+
async function getCA(config) {
|
|
105
|
+
const dir = caDir(config);
|
|
106
|
+
const crtPath = path.join(dir, 'ca.crt');
|
|
107
|
+
if (!fs.existsSync(crtPath)) return null;
|
|
108
|
+
try {
|
|
109
|
+
const fingerprint = await certFingerprint(crtPath);
|
|
110
|
+
const expires = await certExpiry(crtPath);
|
|
111
|
+
const cn = await certCN(crtPath);
|
|
112
|
+
const crt = fs.readFileSync(crtPath, 'utf8');
|
|
113
|
+
return { crt, fingerprint, expires, cn };
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Server certificate ─────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Generate a server keypair + CSR + sign it with the local CA.
|
|
123
|
+
* @param {object} config
|
|
124
|
+
* @param {{ cn?: string, san?: string[], days?: number }} options
|
|
125
|
+
* @returns {Promise<{ crt: string, key: string, fingerprint: string, expires: string }>}
|
|
126
|
+
*/
|
|
127
|
+
async function generateServerCert(config, { cn, san = [], days = 365 } = {}) {
|
|
128
|
+
const dir = caDir(config);
|
|
129
|
+
const caKeyPath = path.join(dir, 'ca.key');
|
|
130
|
+
const caCrtPath = path.join(dir, 'ca.crt');
|
|
131
|
+
const srlPath = path.join(dir, 'ca.srl');
|
|
132
|
+
|
|
133
|
+
if (!fs.existsSync(caCrtPath)) throw new Error('No CA found — generate CA first');
|
|
134
|
+
|
|
135
|
+
const serverDir = path.join(dir, 'server');
|
|
136
|
+
fs.mkdirSync(serverDir, { recursive: true });
|
|
137
|
+
|
|
138
|
+
const keyPath = path.join(serverDir, 'server.key');
|
|
139
|
+
const csrPath = path.join(serverDir, 'server.csr');
|
|
140
|
+
const crtPath = path.join(serverDir, 'server.crt');
|
|
141
|
+
const extPath = path.join(serverDir, 'server.ext');
|
|
142
|
+
|
|
143
|
+
// Build SAN extension file
|
|
144
|
+
const sanEntries = [cn, ...san].filter(Boolean).map((s, i) => {
|
|
145
|
+
return /^\d+\.\d+\.\d+\.\d+$/.test(s) ? `IP.${i + 1}:${s}` : `DNS.${i + 1}:${s}`;
|
|
146
|
+
});
|
|
147
|
+
const extContent = `[SAN]\nsubjectAltName=${sanEntries.join(',')}\n`;
|
|
148
|
+
fs.writeFileSync(extPath, extContent, 'utf8');
|
|
149
|
+
|
|
150
|
+
// Generate key
|
|
151
|
+
await openssl(['genpkey', '-algorithm', 'ed25519', '-out', keyPath]);
|
|
152
|
+
try {
|
|
153
|
+
fs.chmodSync(keyPath, 0o600);
|
|
154
|
+
} catch {
|
|
155
|
+
/* ignore */
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Generate CSR
|
|
159
|
+
await openssl(['req', '-new', '-key', keyPath, '-out', csrPath, '-subj', `/CN=${cn || 'mosquitto'}`]);
|
|
160
|
+
|
|
161
|
+
// Sign with CA
|
|
162
|
+
await openssl([
|
|
163
|
+
'x509',
|
|
164
|
+
'-req',
|
|
165
|
+
'-in',
|
|
166
|
+
csrPath,
|
|
167
|
+
'-CA',
|
|
168
|
+
caCrtPath,
|
|
169
|
+
'-CAkey',
|
|
170
|
+
caKeyPath,
|
|
171
|
+
'-CAserial',
|
|
172
|
+
srlPath,
|
|
173
|
+
'-out',
|
|
174
|
+
crtPath,
|
|
175
|
+
'-days',
|
|
176
|
+
String(days),
|
|
177
|
+
'-extfile',
|
|
178
|
+
extPath,
|
|
179
|
+
'-extensions',
|
|
180
|
+
'SAN',
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
const fingerprint = await certFingerprint(crtPath);
|
|
184
|
+
const expires = await certExpiry(crtPath);
|
|
185
|
+
const crt = fs.readFileSync(crtPath, 'utf8');
|
|
186
|
+
const key = fs.readFileSync(keyPath, 'utf8');
|
|
187
|
+
|
|
188
|
+
return { crt, key, fingerprint, expires, certPath: crtPath, keyPath };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Client certificate issuance ────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Issue a client certificate signed by the local CA.
|
|
195
|
+
* Returns paths to .p12, .crt, .key and the p12 passphrase.
|
|
196
|
+
*
|
|
197
|
+
* @param {object} config
|
|
198
|
+
* @param {{ cn: string, days?: number }} options
|
|
199
|
+
* @returns {Promise<{ serial: string, crt: string, key: string, p12Path: string, passphrase: string, fingerprint: string, expires: string }>}
|
|
200
|
+
*/
|
|
201
|
+
async function issueClientCert(config, { cn, days = 365 } = {}) {
|
|
202
|
+
if (!cn) throw new Error('cn is required');
|
|
203
|
+
|
|
204
|
+
const dir = caDir(config);
|
|
205
|
+
const caKeyPath = path.join(dir, 'ca.key');
|
|
206
|
+
const caCrtPath = path.join(dir, 'ca.crt');
|
|
207
|
+
const srlPath = path.join(dir, 'ca.srl');
|
|
208
|
+
|
|
209
|
+
if (!fs.existsSync(caCrtPath)) throw new Error('No CA found — generate CA first');
|
|
210
|
+
|
|
211
|
+
// Sanitise CN for use as directory name
|
|
212
|
+
const safeCn = cn.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
213
|
+
const clientDir = path.join(dir, 'clients', safeCn);
|
|
214
|
+
fs.mkdirSync(clientDir, { recursive: true });
|
|
215
|
+
|
|
216
|
+
const keyPath = path.join(clientDir, 'client.key');
|
|
217
|
+
const csrPath = path.join(clientDir, 'client.csr');
|
|
218
|
+
const crtPath = path.join(clientDir, 'client.crt');
|
|
219
|
+
const p12Path = path.join(clientDir, 'client.p12');
|
|
220
|
+
|
|
221
|
+
// Generate client key
|
|
222
|
+
await openssl(['genpkey', '-algorithm', 'ed25519', '-out', keyPath]);
|
|
223
|
+
try {
|
|
224
|
+
fs.chmodSync(keyPath, 0o600);
|
|
225
|
+
} catch {
|
|
226
|
+
/* ignore */
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Generate CSR
|
|
230
|
+
await openssl(['req', '-new', '-key', keyPath, '-out', csrPath, '-subj', `/CN=${cn}`]);
|
|
231
|
+
|
|
232
|
+
// Sign with CA
|
|
233
|
+
await openssl(['x509', '-req', '-in', csrPath, '-CA', caCrtPath, '-CAkey', caKeyPath, '-CAserial', srlPath, '-out', crtPath, '-days', String(days)]);
|
|
234
|
+
|
|
235
|
+
// Read serial from the signed cert
|
|
236
|
+
const serial = await certSerial(crtPath);
|
|
237
|
+
|
|
238
|
+
// Bundle to .p12
|
|
239
|
+
const passphrase = crypto.randomBytes(10).toString('hex');
|
|
240
|
+
await openssl(['pkcs12', '-export', '-in', crtPath, '-inkey', keyPath, '-certfile', caCrtPath, '-out', p12Path, '-passout', `pass:${passphrase}`, '-legacy']);
|
|
241
|
+
|
|
242
|
+
const fingerprint = await certFingerprint(crtPath);
|
|
243
|
+
const expires = await certExpiry(crtPath);
|
|
244
|
+
const crt = fs.readFileSync(crtPath, 'utf8');
|
|
245
|
+
const key = fs.readFileSync(keyPath, 'utf8');
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
serial,
|
|
249
|
+
cn,
|
|
250
|
+
crt,
|
|
251
|
+
key,
|
|
252
|
+
p12Path,
|
|
253
|
+
passphrase,
|
|
254
|
+
fingerprint,
|
|
255
|
+
expires,
|
|
256
|
+
issued: new Date().toISOString(),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Regenerate CRL from a list of revoked cert files.
|
|
262
|
+
* @param {object} config
|
|
263
|
+
* @param {string[]} revokedCertPaths - PEM cert file paths to include in CRL
|
|
264
|
+
*/
|
|
265
|
+
async function generateCRL(config, revokedCertPaths = []) {
|
|
266
|
+
const dir = caDir(config);
|
|
267
|
+
const caKeyPath = path.join(dir, 'ca.key');
|
|
268
|
+
const caCrtPath = path.join(dir, 'ca.crt');
|
|
269
|
+
const crlPath = path.join(dir, 'crl.pem');
|
|
270
|
+
|
|
271
|
+
if (!fs.existsSync(caCrtPath)) throw new Error('No CA found');
|
|
272
|
+
|
|
273
|
+
// Build a minimal openssl database for revocations
|
|
274
|
+
const dbDir = path.join(dir, '.crldb');
|
|
275
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
276
|
+
const dbFile = path.join(dbDir, 'index.txt');
|
|
277
|
+
const dbAttr = path.join(dbDir, 'index.txt.attr');
|
|
278
|
+
const crlSrl = path.join(dbDir, 'crlnumber');
|
|
279
|
+
const confPath = path.join(dbDir, 'openssl.cnf');
|
|
280
|
+
|
|
281
|
+
// Initialise DB files if missing
|
|
282
|
+
if (!fs.existsSync(dbFile)) fs.writeFileSync(dbFile, '', 'utf8');
|
|
283
|
+
if (!fs.existsSync(dbAttr)) fs.writeFileSync(dbAttr, 'unique_subject = no\n', 'utf8');
|
|
284
|
+
if (!fs.existsSync(crlSrl)) fs.writeFileSync(crlSrl, '01\n', 'utf8');
|
|
285
|
+
|
|
286
|
+
const confContent = `
|
|
287
|
+
[ ca ]
|
|
288
|
+
default_ca = CA_default
|
|
289
|
+
|
|
290
|
+
[ CA_default ]
|
|
291
|
+
dir = ${dbDir}
|
|
292
|
+
database = ${dbFile}
|
|
293
|
+
new_certs_dir = ${dbDir}
|
|
294
|
+
certificate = ${caCrtPath}
|
|
295
|
+
private_key = ${caKeyPath}
|
|
296
|
+
default_md = default
|
|
297
|
+
default_crl_days = 30
|
|
298
|
+
crl_extensions = crl_ext
|
|
299
|
+
crlnumber = ${crlSrl}
|
|
300
|
+
|
|
301
|
+
[ crl_ext ]
|
|
302
|
+
authorityKeyIdentifier = keyid:always
|
|
303
|
+
|
|
304
|
+
[ req ]
|
|
305
|
+
default_bits = 2048
|
|
306
|
+
`;
|
|
307
|
+
fs.writeFileSync(confPath, confContent, 'utf8');
|
|
308
|
+
|
|
309
|
+
// Revoke each cert in the database
|
|
310
|
+
for (const certPath of revokedCertPaths) {
|
|
311
|
+
try {
|
|
312
|
+
await openssl(['ca', '-config', confPath, '-revoke', certPath, '-batch']);
|
|
313
|
+
} catch {
|
|
314
|
+
// cert may already be in the DB — ignore duplicate errors
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Generate CRL
|
|
319
|
+
await openssl(['ca', '-config', confPath, '-gencrl', '-out', crlPath, '-batch']);
|
|
320
|
+
|
|
321
|
+
return crlPath;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Trusted CA certs (capath) ──────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* List all trusted CA certs in the capath directory.
|
|
328
|
+
* @param {object} config
|
|
329
|
+
* @returns {Promise<{ filename: string, cn: string, fingerprint: string, expires: string }[]>}
|
|
330
|
+
*/
|
|
331
|
+
async function listTrustedCerts(config) {
|
|
332
|
+
const dir = caCertsDir(config);
|
|
333
|
+
let files;
|
|
334
|
+
try {
|
|
335
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith('.pem') || f.endsWith('.crt'));
|
|
336
|
+
} catch {
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const result = [];
|
|
341
|
+
for (const file of files) {
|
|
342
|
+
const fp = path.join(dir, file);
|
|
343
|
+
try {
|
|
344
|
+
const cn = await certCN(fp);
|
|
345
|
+
const fingerprint = await certFingerprint(fp);
|
|
346
|
+
const expires = await certExpiry(fp);
|
|
347
|
+
result.push({ filename: file, cn, fingerprint, expires });
|
|
348
|
+
} catch {
|
|
349
|
+
result.push({ filename: file, cn: '?', fingerprint: '?', expires: '?' });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Add a trusted CA cert to the capath directory.
|
|
357
|
+
* @param {object} config
|
|
358
|
+
* @param {string} pemContent - PEM certificate text
|
|
359
|
+
* @returns {Promise<{ filename: string, fingerprint: string }>}
|
|
360
|
+
*/
|
|
361
|
+
async function addTrustedCert(config, pemContent) {
|
|
362
|
+
const dir = caCertsDir(config);
|
|
363
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
364
|
+
|
|
365
|
+
// Derive filename from fingerprint
|
|
366
|
+
const tmpPath = path.join(dir, `_tmp_${Date.now()}.pem`);
|
|
367
|
+
fs.writeFileSync(tmpPath, pemContent, 'utf8');
|
|
368
|
+
let fingerprint;
|
|
369
|
+
try {
|
|
370
|
+
fingerprint = await certFingerprint(tmpPath);
|
|
371
|
+
} catch (e) {
|
|
372
|
+
fs.unlinkSync(tmpPath);
|
|
373
|
+
throw new Error(`Invalid certificate PEM: ${e.message}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const filename = fingerprint.replace(/:/g, '').slice(0, 16) + '.pem';
|
|
377
|
+
const destPath = path.join(dir, filename);
|
|
378
|
+
fs.renameSync(tmpPath, destPath);
|
|
379
|
+
|
|
380
|
+
// Re-run openssl rehash so mosquitto can find this cert
|
|
381
|
+
try {
|
|
382
|
+
await openssl(['rehash', dir]);
|
|
383
|
+
} catch {
|
|
384
|
+
/* openssl rehash may not exist on all platforms — non-fatal */
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return { filename, fingerprint };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Remove a trusted CA cert by its fingerprint.
|
|
392
|
+
* @param {object} config
|
|
393
|
+
* @param {string} fingerprint
|
|
394
|
+
*/
|
|
395
|
+
async function removeTrustedCert(config, fingerprint) {
|
|
396
|
+
const dir = caCertsDir(config);
|
|
397
|
+
const certs = await listTrustedCerts(config);
|
|
398
|
+
const match = certs.find((c) => c.fingerprint === fingerprint);
|
|
399
|
+
if (!match) throw new Error('Cert not found');
|
|
400
|
+
fs.unlinkSync(path.join(dir, match.filename));
|
|
401
|
+
|
|
402
|
+
// Rehash after removal
|
|
403
|
+
try {
|
|
404
|
+
await openssl(['rehash', dir]);
|
|
405
|
+
} catch {
|
|
406
|
+
/* non-fatal */
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Client cert file helpers (for download) ────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Get file paths for an issued client cert by CN.
|
|
414
|
+
* @param {object} config
|
|
415
|
+
* @param {string} cn
|
|
416
|
+
* @returns {{ keyPath, crtPath, p12Path, caPath }}
|
|
417
|
+
*/
|
|
418
|
+
function clientCertPaths(config, cn) {
|
|
419
|
+
const dir = caDir(config);
|
|
420
|
+
const safeCn = cn.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
421
|
+
const clientDir = path.join(dir, 'clients', safeCn);
|
|
422
|
+
return {
|
|
423
|
+
keyPath: path.join(clientDir, 'client.key'),
|
|
424
|
+
crtPath: path.join(clientDir, 'client.crt'),
|
|
425
|
+
p12Path: path.join(clientDir, 'client.p12'),
|
|
426
|
+
caPath: path.join(dir, 'ca.crt'),
|
|
427
|
+
serverCrtPath: path.join(dir, 'server', 'server.crt'),
|
|
428
|
+
serverKeyPath: path.join(dir, 'server', 'server.key'),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── openssl x509 parsing helpers ───────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
async function certFingerprint(certPath) {
|
|
435
|
+
const { stdout } = await openssl(['x509', '-in', certPath, '-fingerprint', '-sha256', '-noout']);
|
|
436
|
+
const match = stdout.match(/SHA256 Fingerprint=([0-9A-F:]+)/i);
|
|
437
|
+
return match ? match[1] : stdout.trim();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function certExpiry(certPath) {
|
|
441
|
+
const { stdout } = await openssl(['x509', '-in', certPath, '-enddate', '-noout']);
|
|
442
|
+
const match = stdout.match(/notAfter=(.+)/);
|
|
443
|
+
return match ? new Date(match[1]).toISOString() : stdout.trim();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function certCN(certPath) {
|
|
447
|
+
const { stdout } = await openssl(['x509', '-in', certPath, '-subject', '-noout', '-nameopt', 'RFC2253']);
|
|
448
|
+
const match = stdout.match(/CN=([^,]+)/);
|
|
449
|
+
return match ? match[1].trim() : stdout.trim();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function certSerial(certPath) {
|
|
453
|
+
const { stdout } = await openssl(['x509', '-in', certPath, '-serial', '-noout']);
|
|
454
|
+
const match = stdout.match(/serial=([0-9A-Fa-f]+)/);
|
|
455
|
+
return match ? match[1] : stdout.trim();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
module.exports = {
|
|
459
|
+
caDir,
|
|
460
|
+
caCertsDir,
|
|
461
|
+
generateCA,
|
|
462
|
+
getCA,
|
|
463
|
+
generateServerCert,
|
|
464
|
+
issueClientCert,
|
|
465
|
+
generateCRL,
|
|
466
|
+
listTrustedCerts,
|
|
467
|
+
addTrustedCert,
|
|
468
|
+
removeTrustedCert,
|
|
469
|
+
clientCertPaths,
|
|
470
|
+
certFingerprint,
|
|
471
|
+
certExpiry,
|
|
472
|
+
certCN,
|
|
473
|
+
certSerial,
|
|
474
|
+
};
|