smart-home-engine 1.0.10 → 1.1.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.
@@ -0,0 +1,787 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * broker-api — HTTP API for mosquitto broker management.
5
+ *
6
+ * Mounted at /she/broker/* in server.js.
7
+ * All routes require Bearer token auth (handled by the outer authMiddleware).
8
+ *
9
+ * Covers:
10
+ * - Status / $SYS stats
11
+ * - mosquitto.conf read / write / reload / restart / backup management
12
+ * - dynsec: users, roles, role ACLs, role assignments, groups
13
+ * - Local CA + client cert issuance (delegates to ca.js)
14
+ */
15
+
16
+ const express = require('express');
17
+ const path = require('path');
18
+ const fs = require('fs');
19
+ const dynsec = require('../lib/dynsec');
20
+ const mosquittoConf = require('../lib/mosquitto-conf');
21
+ const ca = require('../lib/ca');
22
+ const sshDeploy = require('../lib/ssh-deploy');
23
+ const sheConfig = require('../config');
24
+
25
+ // Default SSH identity file respects the configured data directory
26
+ const DEFAULT_SSH_KEY = path.join(sheConfig['data-dir'], 'ssh', 'broker_id_ed25519');
27
+
28
+ const router = express.Router();
29
+
30
+ // ── Helpers ────────────────────────────────────────────────────────────────────
31
+
32
+ /** Get broker config from live config.json */
33
+ function getBrokerConfig(req) {
34
+ const configPath = req.app.locals.configPath;
35
+ if (!configPath) return {};
36
+ try {
37
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
38
+ return cfg.broker || {};
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ /** Resolve mosquitto.conf path from broker config */
45
+ function confPath(brokerConfig) {
46
+ const dir = brokerConfig.configDir || '/etc/mosquitto';
47
+ return path.join(dir, 'mosquitto.conf');
48
+ }
49
+
50
+ // Per-request checksum cache so the UI can detect external modifications
51
+ // Key: confPath, Value: last-seen checksum after a write
52
+ const _lastWriteChecksum = new Map();
53
+
54
+ function handleError(res, err) {
55
+ res.status(500).json({ error: err.message });
56
+ }
57
+
58
+ // ── Status ─────────────────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * GET /she/broker/status
62
+ * Returns dynsec connection status + any $SYS stats collected by the main
63
+ * MQTT client and stored in app.locals.mqttState.
64
+ */
65
+ router.get('/status', (req, res) => {
66
+ const ds = dynsec.getStatus();
67
+ const mqttState = req.app.locals.mqttState || {};
68
+
69
+ const sysPrefixes = ['$SYS/broker/version', '$SYS/broker/clients/', '$SYS/broker/uptime'];
70
+ const sys = {};
71
+ for (const [topic, entry] of Object.entries(mqttState)) {
72
+ if (sysPrefixes.some((p) => topic.startsWith(p))) {
73
+ sys[topic] = entry;
74
+ }
75
+ }
76
+
77
+ res.json({ dynsec: ds, sys, sshKeyDefault: DEFAULT_SSH_KEY });
78
+ });
79
+
80
+ // ── mosquitto.conf ─────────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * GET /she/broker/config
84
+ * Returns parsed config structure + raw text + checksum.
85
+ */
86
+ router.get('/config', (req, res) => {
87
+ try {
88
+ const bc = getBrokerConfig(req);
89
+ const fp = confPath(bc);
90
+ const parsed = mosquittoConf.parse(fp);
91
+ const cs = mosquittoConf.checksum(fp);
92
+ const backups = mosquittoConf.listBackups(fp).map((b) => path.basename(b));
93
+ res.json({ ...parsed, checksum: cs, backups });
94
+ } catch (err) {
95
+ handleError(res, err);
96
+ }
97
+ });
98
+
99
+ /**
100
+ * PUT /she/broker/config
101
+ * Write structured config (body: { listeners, managed, passthrough, checksum? }).
102
+ * body.checksum is the client's last-known checksum for external-modify detection.
103
+ */
104
+ router.put('/config', (req, res) => {
105
+ try {
106
+ const bc = getBrokerConfig(req);
107
+ const fp = confPath(bc);
108
+ const { listeners, managed, passthrough, checksum: clientChecksum } = req.body;
109
+ const content = mosquittoConf.serialise({ listeners, managed, passthrough });
110
+ const result = mosquittoConf.write(fp, content, clientChecksum ?? null);
111
+ if (!result.ok) {
112
+ return res.status(409).json({ error: 'external_modify', message: 'mosquitto.conf was modified externally since last read' });
113
+ }
114
+ _lastWriteChecksum.set(fp, mosquittoConf.checksum(fp));
115
+ res.json({ ok: true, backupPath: result.backupPath ? path.basename(result.backupPath) : null });
116
+ } catch (err) {
117
+ handleError(res, err);
118
+ }
119
+ });
120
+
121
+ /**
122
+ * PUT /she/broker/config/raw
123
+ * Write raw mosquitto.conf text directly (used by the Advanced editor).
124
+ */
125
+ router.put('/config/raw', (req, res) => {
126
+ try {
127
+ const bc = getBrokerConfig(req);
128
+ const fp = confPath(bc);
129
+ const { content, checksum: clientChecksum } = req.body;
130
+ if (typeof content !== 'string') {
131
+ return res.status(400).json({ error: 'content must be a string' });
132
+ }
133
+ const result = mosquittoConf.write(fp, content, clientChecksum ?? null);
134
+ if (!result.ok) {
135
+ return res.status(409).json({ error: 'external_modify', message: 'mosquitto.conf was modified externally since last read' });
136
+ }
137
+ _lastWriteChecksum.set(fp, mosquittoConf.checksum(fp));
138
+ res.json({ ok: true, backupPath: result.backupPath ? path.basename(result.backupPath) : null });
139
+ } catch (err) {
140
+ handleError(res, err);
141
+ }
142
+ });
143
+
144
+ /**
145
+ * GET /she/broker/config/backups
146
+ * List backup filenames.
147
+ */
148
+ router.get('/config/backups', (req, res) => {
149
+ try {
150
+ const bc = getBrokerConfig(req);
151
+ const fp = confPath(bc);
152
+ const backups = mosquittoConf.listBackups(fp).map((b) => path.basename(b));
153
+ res.json({ backups });
154
+ } catch (err) {
155
+ handleError(res, err);
156
+ }
157
+ });
158
+
159
+ /**
160
+ * POST /she/broker/config/restore
161
+ * Restore a named backup. Body: { backup: '<filename>' }
162
+ */
163
+ router.post('/config/restore', (req, res) => {
164
+ try {
165
+ const bc = getBrokerConfig(req);
166
+ const fp = confPath(bc);
167
+ const dir = path.dirname(fp);
168
+ const base = path.basename(fp);
169
+ const { backup } = req.body;
170
+ if (!backup || typeof backup !== 'string') {
171
+ return res.status(400).json({ error: 'backup filename required' });
172
+ }
173
+ // Safety: backup must be in the same directory and match expected prefix
174
+ const backupPath = path.resolve(dir, backup);
175
+ if (!backupPath.startsWith(path.resolve(dir) + path.sep) || !path.basename(backupPath).startsWith(`${base}.bak-`)) {
176
+ return res.status(400).json({ error: 'invalid backup filename' });
177
+ }
178
+ mosquittoConf.restoreBackup(backupPath, fp);
179
+ res.json({ ok: true });
180
+ } catch (err) {
181
+ handleError(res, err);
182
+ }
183
+ });
184
+
185
+ /**
186
+ * POST /she/broker/reload
187
+ * Send SIGHUP / systemctl reload to mosquitto.
188
+ * In remote mode, the command is executed on the broker host via SSH.
189
+ */
190
+ router.post('/reload', async (req, res) => {
191
+ try {
192
+ const bc = getBrokerConfig(req);
193
+ if (bc.mode === 'remote' && bc.ssh && bc.ssh.host) {
194
+ const cmd = bc.reloadCmd || 'sudo systemctl reload mosquitto';
195
+ const result = await sshDeploy.runCommand(bc.ssh, cmd);
196
+ return res.json({ ok: true, ...result });
197
+ }
198
+ const result = await mosquittoConf.reload(bc);
199
+ res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
200
+ } catch (err) {
201
+ handleError(res, err);
202
+ }
203
+ });
204
+
205
+ /**
206
+ * POST /she/broker/restart
207
+ * Full mosquitto service restart.
208
+ * In remote mode, the command is executed on the broker host via SSH.
209
+ */
210
+ router.post('/restart', async (req, res) => {
211
+ try {
212
+ const bc = getBrokerConfig(req);
213
+ if (bc.mode === 'remote' && bc.ssh && bc.ssh.host) {
214
+ const cmd = bc.restartCmd || 'sudo systemctl restart mosquitto';
215
+ const result = await sshDeploy.runCommand(bc.ssh, cmd);
216
+ return res.json({ ok: true, ...result });
217
+ }
218
+ const result = await mosquittoConf.restart(bc);
219
+ res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
220
+ } catch (err) {
221
+ handleError(res, err);
222
+ }
223
+ });
224
+
225
+ // ── dynsec: Users ─────────────────────────────────────────────────────────────
226
+
227
+ /** GET /she/broker/users */
228
+ router.get('/users', async (req, res) => {
229
+ try {
230
+ const users = await dynsec.listClients(true);
231
+ res.json({ users });
232
+ } catch (err) {
233
+ handleError(res, err);
234
+ }
235
+ });
236
+
237
+ /** POST /she/broker/users — body: { username, password } */
238
+ router.post('/users', async (req, res) => {
239
+ try {
240
+ const { username, password } = req.body;
241
+ if (!username || !password) return res.status(400).json({ error: 'username and password required' });
242
+ await dynsec.createClient(username, password);
243
+ res.json({ ok: true });
244
+ } catch (err) {
245
+ handleError(res, err);
246
+ }
247
+ });
248
+
249
+ /** DELETE /she/broker/users/:user */
250
+ router.delete('/users/:user', async (req, res) => {
251
+ try {
252
+ await dynsec.deleteClient(req.params.user);
253
+ res.json({ ok: true });
254
+ } catch (err) {
255
+ handleError(res, err);
256
+ }
257
+ });
258
+
259
+ /** PUT /she/broker/users/:user/password — body: { password } */
260
+ router.put('/users/:user/password', async (req, res) => {
261
+ try {
262
+ const { password } = req.body;
263
+ if (!password) return res.status(400).json({ error: 'password required' });
264
+ await dynsec.setClientPassword(req.params.user, password);
265
+ res.json({ ok: true });
266
+ } catch (err) {
267
+ handleError(res, err);
268
+ }
269
+ });
270
+
271
+ /** POST /she/broker/users/:user/roles — body: { rolename } */
272
+ router.post('/users/:user/roles', async (req, res) => {
273
+ try {
274
+ const { rolename } = req.body;
275
+ if (!rolename) return res.status(400).json({ error: 'rolename required' });
276
+ await dynsec.addClientRole(req.params.user, rolename);
277
+ res.json({ ok: true });
278
+ } catch (err) {
279
+ handleError(res, err);
280
+ }
281
+ });
282
+
283
+ /** DELETE /she/broker/users/:user/roles/:role */
284
+ router.delete('/users/:user/roles/:role', async (req, res) => {
285
+ try {
286
+ await dynsec.removeClientRole(req.params.user, req.params.role);
287
+ res.json({ ok: true });
288
+ } catch (err) {
289
+ handleError(res, err);
290
+ }
291
+ });
292
+
293
+ // ── dynsec: Roles ─────────────────────────────────────────────────────────────
294
+
295
+ /** GET /she/broker/roles */
296
+ router.get('/roles', async (req, res) => {
297
+ try {
298
+ const roles = await dynsec.listRoles(true);
299
+ res.json({ roles });
300
+ } catch (err) {
301
+ handleError(res, err);
302
+ }
303
+ });
304
+
305
+ /** POST /she/broker/roles — body: { rolename } */
306
+ router.post('/roles', async (req, res) => {
307
+ try {
308
+ const { rolename } = req.body;
309
+ if (!rolename) return res.status(400).json({ error: 'rolename required' });
310
+ await dynsec.createRole(rolename);
311
+ res.json({ ok: true });
312
+ } catch (err) {
313
+ handleError(res, err);
314
+ }
315
+ });
316
+
317
+ /** DELETE /she/broker/roles/:role */
318
+ router.delete('/roles/:role', async (req, res) => {
319
+ try {
320
+ await dynsec.deleteRole(req.params.role);
321
+ res.json({ ok: true });
322
+ } catch (err) {
323
+ handleError(res, err);
324
+ }
325
+ });
326
+
327
+ /** POST /she/broker/roles/:role/acls — body: { acltype, topic, allow, priority? } */
328
+ router.post('/roles/:role/acls', async (req, res) => {
329
+ try {
330
+ const { acltype, topic, allow, priority } = req.body;
331
+ if (!acltype || !topic || allow === undefined) return res.status(400).json({ error: 'acltype, topic and allow required' });
332
+ await dynsec.addRoleACL(req.params.role, acltype, topic, !!allow, priority);
333
+ res.json({ ok: true });
334
+ } catch (err) {
335
+ handleError(res, err);
336
+ }
337
+ });
338
+
339
+ /** DELETE /she/broker/roles/:role/acls — body: { acltype, topic } */
340
+ router.delete('/roles/:role/acls', async (req, res) => {
341
+ try {
342
+ const { acltype, topic } = req.body;
343
+ if (!acltype || !topic) return res.status(400).json({ error: 'acltype and topic required' });
344
+ await dynsec.removeRoleACL(req.params.role, acltype, topic);
345
+ res.json({ ok: true });
346
+ } catch (err) {
347
+ handleError(res, err);
348
+ }
349
+ });
350
+
351
+ // ── dynsec: Groups ────────────────────────────────────────────────────────────
352
+
353
+ /** GET /she/broker/groups */
354
+ router.get('/groups', async (req, res) => {
355
+ try {
356
+ const groups = await dynsec.listGroups(true);
357
+ res.json({ groups });
358
+ } catch (err) {
359
+ handleError(res, err);
360
+ }
361
+ });
362
+
363
+ /** POST /she/broker/groups — body: { groupname } */
364
+ router.post('/groups', async (req, res) => {
365
+ try {
366
+ const { groupname } = req.body;
367
+ if (!groupname) return res.status(400).json({ error: 'groupname required' });
368
+ await dynsec.createGroup(groupname);
369
+ res.json({ ok: true });
370
+ } catch (err) {
371
+ handleError(res, err);
372
+ }
373
+ });
374
+
375
+ /** DELETE /she/broker/groups/:group */
376
+ router.delete('/groups/:group', async (req, res) => {
377
+ try {
378
+ await dynsec.deleteGroup(req.params.group);
379
+ res.json({ ok: true });
380
+ } catch (err) {
381
+ handleError(res, err);
382
+ }
383
+ });
384
+
385
+ /** POST /she/broker/groups/:group/clients — body: { username } */
386
+ router.post('/groups/:group/clients', async (req, res) => {
387
+ try {
388
+ const { username } = req.body;
389
+ if (!username) return res.status(400).json({ error: 'username required' });
390
+ await dynsec.addGroupClient(req.params.group, username);
391
+ res.json({ ok: true });
392
+ } catch (err) {
393
+ handleError(res, err);
394
+ }
395
+ });
396
+
397
+ /** DELETE /she/broker/groups/:group/clients/:user */
398
+ router.delete('/groups/:group/clients/:user', async (req, res) => {
399
+ try {
400
+ await dynsec.removeGroupClient(req.params.group, req.params.user);
401
+ res.json({ ok: true });
402
+ } catch (err) {
403
+ handleError(res, err);
404
+ }
405
+ });
406
+
407
+ /** POST /she/broker/groups/:group/roles — body: { rolename } */
408
+ router.post('/groups/:group/roles', async (req, res) => {
409
+ try {
410
+ const { rolename } = req.body;
411
+ if (!rolename) return res.status(400).json({ error: 'rolename required' });
412
+ await dynsec.addGroupRole(req.params.group, rolename);
413
+ res.json({ ok: true });
414
+ } catch (err) {
415
+ handleError(res, err);
416
+ }
417
+ });
418
+
419
+ /** DELETE /she/broker/groups/:group/roles/:role */
420
+ router.delete('/groups/:group/roles/:role', async (req, res) => {
421
+ try {
422
+ await dynsec.removeGroupRole(req.params.group, req.params.role);
423
+ res.json({ ok: true });
424
+ } catch (err) {
425
+ handleError(res, err);
426
+ }
427
+ });
428
+
429
+ // ── CA routes ─────────────────────────────────────────────────────────────────
430
+
431
+ /** GET /she/broker/ca — CA cert info */
432
+ router.get('/ca', async (req, res) => {
433
+ try {
434
+ const bc = getBrokerConfig(req);
435
+ const info = await ca.getCA(bc);
436
+ res.json({ ca: info });
437
+ } catch (err) {
438
+ handleError(res, err);
439
+ }
440
+ });
441
+
442
+ /** POST /she/broker/ca/generate — Generate new CA keypair + cert */
443
+ router.post('/ca/generate', async (req, res) => {
444
+ try {
445
+ const bc = getBrokerConfig(req);
446
+ const { cn, days } = req.body;
447
+ const info = await ca.generateCA(bc, { cn, days });
448
+ res.json({ ok: true, ...info });
449
+ } catch (err) {
450
+ handleError(res, err);
451
+ }
452
+ });
453
+
454
+ /** GET /she/broker/ca/server — Server cert info */
455
+ router.get('/ca/server', async (req, res) => {
456
+ try {
457
+ const bc = getBrokerConfig(req);
458
+ const serverCrt = path.join(ca.caDir(bc), 'server', 'server.crt');
459
+ const fs = require('fs');
460
+ if (!fs.existsSync(serverCrt)) return res.json({ server: null });
461
+ const fingerprint = await ca.certFingerprint(serverCrt);
462
+ const expires = await ca.certExpiry(serverCrt);
463
+ const cn = await ca.certCN(serverCrt);
464
+ res.json({ server: { fingerprint, expires, cn } });
465
+ } catch (err) {
466
+ handleError(res, err);
467
+ }
468
+ });
469
+
470
+ /** POST /she/broker/ca/server/generate — Generate server cert */
471
+ router.post('/ca/server/generate', async (req, res) => {
472
+ try {
473
+ const bc = getBrokerConfig(req);
474
+ const { cn, san, days } = req.body;
475
+ const result = await ca.generateServerCert(bc, { cn, san, days });
476
+ res.json({ ok: true, fingerprint: result.fingerprint, expires: result.expires, certPath: result.certPath, keyPath: result.keyPath });
477
+ } catch (err) {
478
+ handleError(res, err);
479
+ }
480
+ });
481
+
482
+ /** GET /she/broker/ca/certs — List issued client certs (from sheDB) */
483
+ router.get('/ca/certs', async (req, res) => {
484
+ try {
485
+ const db = req.app.locals.db;
486
+ if (!db) return res.json({ certs: [] });
487
+ const certs = db.query(
488
+ (doc) => doc._id && doc._id.startsWith('she/broker/cert/'),
489
+ (doc) => doc,
490
+ );
491
+ res.json({ certs });
492
+ } catch (err) {
493
+ handleError(res, err);
494
+ }
495
+ });
496
+
497
+ /** POST /she/broker/ca/certs — Issue new client cert */
498
+ router.post('/ca/certs', async (req, res) => {
499
+ try {
500
+ const bc = getBrokerConfig(req);
501
+ const { cn, days } = req.body;
502
+ if (!cn) return res.status(400).json({ error: 'cn required' });
503
+ const result = await ca.issueClientCert(bc, { cn, days });
504
+ // Store metadata in sheDB
505
+ const db = req.app.locals.db;
506
+ if (db) {
507
+ db.set(`she/broker/cert/${result.serial}`, {
508
+ cn: result.cn,
509
+ serial: result.serial,
510
+ fingerprint: result.fingerprint,
511
+ issued: result.issued,
512
+ expires: result.expires,
513
+ revoked: false,
514
+ revokedAt: null,
515
+ });
516
+ }
517
+ // Return everything except the passphrase hidden
518
+ res.json({
519
+ ok: true,
520
+ serial: result.serial,
521
+ cn: result.cn,
522
+ fingerprint: result.fingerprint,
523
+ issued: result.issued,
524
+ expires: result.expires,
525
+ passphrase: result.passphrase,
526
+ // crt and key included so UI can offer individual downloads
527
+ crt: result.crt,
528
+ key: result.key,
529
+ });
530
+ } catch (err) {
531
+ handleError(res, err);
532
+ }
533
+ });
534
+
535
+ /** DELETE /she/broker/ca/certs/:serial — Revoke client cert, regenerate CRL */
536
+ router.delete('/ca/certs/:serial', async (req, res) => {
537
+ try {
538
+ const bc = getBrokerConfig(req);
539
+ const { serial } = req.params;
540
+ const db = req.app.locals.db;
541
+ const meta = db ? db.get(`she/broker/cert/${serial}`) : null;
542
+ if (!meta) return res.status(404).json({ error: 'cert not found' });
543
+
544
+ // Find the cert file
545
+ const paths = ca.clientCertPaths(bc, meta.cn);
546
+ const revokedPaths = [];
547
+ const fs = require('fs');
548
+ if (fs.existsSync(paths.crtPath)) revokedPaths.push(paths.crtPath);
549
+
550
+ // Collect all other revoked certs
551
+ if (db) {
552
+ const allCerts = db.query(
553
+ (doc) => doc._id && doc._id.startsWith('she/broker/cert/') && doc.revoked && doc._id !== `she/broker/cert/${serial}`,
554
+ (doc) => doc,
555
+ );
556
+ for (const c of allCerts) {
557
+ const p = ca.clientCertPaths(bc, c.cn);
558
+ if (fs.existsSync(p.crtPath)) revokedPaths.push(p.crtPath);
559
+ }
560
+ }
561
+
562
+ await ca.generateCRL(bc, revokedPaths);
563
+
564
+ // Mark revoked in sheDB
565
+ if (db) {
566
+ db.extend(`she/broker/cert/${serial}`, { revoked: true, revokedAt: new Date().toISOString() });
567
+ }
568
+
569
+ res.json({ ok: true });
570
+ } catch (err) {
571
+ handleError(res, err);
572
+ }
573
+ });
574
+
575
+ /** GET /she/broker/ca/certs/:serial/download?type=p12|crt|key|ca */
576
+ router.get('/ca/certs/:serial/download', async (req, res) => {
577
+ try {
578
+ const bc = getBrokerConfig(req);
579
+ const { serial } = req.params;
580
+ const { type = 'p12' } = req.query;
581
+ const db = req.app.locals.db;
582
+ const meta = db ? db.get(`she/broker/cert/${serial}`) : null;
583
+ if (!meta) return res.status(404).json({ error: 'cert not found' });
584
+
585
+ const paths = ca.clientCertPaths(bc, meta.cn);
586
+ const fs = require('fs');
587
+ let filePath, contentType, filename;
588
+
589
+ switch (type) {
590
+ case 'p12':
591
+ filePath = paths.p12Path;
592
+ contentType = 'application/x-pkcs12';
593
+ filename = `${meta.cn}.p12`;
594
+ break;
595
+ case 'crt':
596
+ filePath = paths.crtPath;
597
+ contentType = 'application/x-pem-file';
598
+ filename = `${meta.cn}.crt`;
599
+ break;
600
+ case 'key':
601
+ filePath = paths.keyPath;
602
+ contentType = 'application/x-pem-file';
603
+ filename = `${meta.cn}.key`;
604
+ break;
605
+ case 'ca':
606
+ filePath = paths.caPath;
607
+ contentType = 'application/x-pem-file';
608
+ filename = 'ca.crt';
609
+ break;
610
+ default:
611
+ return res.status(400).json({ error: 'invalid type' });
612
+ }
613
+
614
+ if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'file not found' });
615
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
616
+ res.setHeader('Content-Type', contentType);
617
+ fs.createReadStream(filePath).pipe(res);
618
+ } catch (err) {
619
+ handleError(res, err);
620
+ }
621
+ });
622
+
623
+ /** GET /she/broker/ca/trusted — List trusted CA certs in capath dir */
624
+ router.get('/ca/trusted', async (req, res) => {
625
+ try {
626
+ const bc = getBrokerConfig(req);
627
+ const certs = await ca.listTrustedCerts(bc);
628
+ res.json({ certs });
629
+ } catch (err) {
630
+ handleError(res, err);
631
+ }
632
+ });
633
+
634
+ /** POST /she/broker/ca/trusted — Add trusted CA cert (body: { pem: string }) */
635
+ router.post('/ca/trusted', async (req, res) => {
636
+ try {
637
+ const bc = getBrokerConfig(req);
638
+ const { pem } = req.body;
639
+ if (!pem || typeof pem !== 'string') return res.status(400).json({ error: 'pem required' });
640
+ const result = await ca.addTrustedCert(bc, pem);
641
+ res.json({ ok: true, ...result });
642
+ } catch (err) {
643
+ handleError(res, err);
644
+ }
645
+ });
646
+
647
+ /** DELETE /she/broker/ca/trusted/:fingerprint — Remove trusted CA cert */
648
+ router.delete('/ca/trusted/:fingerprint', async (req, res) => {
649
+ try {
650
+ const bc = getBrokerConfig(req);
651
+ const fingerprint = decodeURIComponent(req.params.fingerprint);
652
+ await ca.removeTrustedCert(bc, fingerprint);
653
+ res.json({ ok: true });
654
+ } catch (err) {
655
+ handleError(res, err);
656
+ }
657
+ });
658
+
659
+ module.exports = { router };
660
+
661
+ // ── SSH routes ─────────────────────────────────────────────────────────────────
662
+ // Note: these routes are mounted on the same router but defined after module.exports
663
+ // because they add to `router` (which is already exported by reference).
664
+
665
+ /** POST /she/broker/ssh/keygen — Generate SSH keypair */
666
+ router.post('/ssh/keygen', async (req, res) => {
667
+ try {
668
+ const bc = getBrokerConfig(req);
669
+ const identityFile = (bc.ssh && bc.ssh.identityFile) || DEFAULT_SSH_KEY;
670
+ const publicKey = await sshDeploy.generateKeypair(identityFile);
671
+ res.json({ ok: true, publicKey });
672
+ } catch (err) {
673
+ handleError(res, err);
674
+ }
675
+ });
676
+
677
+ /** POST /she/broker/ssh/test — Test SSH connection */
678
+ router.post('/ssh/test', async (req, res) => {
679
+ try {
680
+ const bc = getBrokerConfig(req);
681
+ if (!bc.ssh || !bc.ssh.host) return res.status(400).json({ error: 'broker.ssh.host not configured' });
682
+ await sshDeploy.testConnection(bc.ssh);
683
+ res.json({ ok: true });
684
+ } catch (err) {
685
+ res.json({ ok: false, error: err.message });
686
+ }
687
+ });
688
+
689
+ // ── Bootstrap wizard ───────────────────────────────────────────────────────────
690
+
691
+ /**
692
+ * POST /she/broker/wizard/probe
693
+ * Check if dynsec is already active by looking at the dynsec client status.
694
+ */
695
+ router.post('/wizard/probe', (req, res) => {
696
+ const status = dynsec.getStatus();
697
+ res.json({ active: status.connected, configured: status.configured });
698
+ });
699
+
700
+ /**
701
+ * POST /she/broker/wizard/bootstrap
702
+ * Full bootstrap flow:
703
+ * 1. Generate dynamic-security.json via mosquitto_ctrl
704
+ * - Remote mode: run mosquitto_ctrl on the broker host via SSH
705
+ * - Local mode: run mosquitto_ctrl locally
706
+ * 2. Ensure plugin line exists in mosquitto.conf
707
+ * 3. Return credentials (store in config.json via /she/config)
708
+ *
709
+ * Note: mosquitto_ctrl is part of the mosquitto package and must be installed
710
+ * on the same host as the broker. It cannot be used to manage a remote broker,
711
+ * which is why we invoke it via SSH in remote mode.
712
+ *
713
+ * Body: { adminUsername?, adminPassword?, configDir? }
714
+ */
715
+ router.post('/wizard/bootstrap', async (req, res) => {
716
+ try {
717
+ const bc = getBrokerConfig(req);
718
+ const crypto = require('crypto');
719
+ const { execFile } = require('child_process');
720
+ const { promisify } = require('util');
721
+ const execFileAsync = promisify(execFile);
722
+
723
+ const username = req.body.adminUsername || 'she-admin';
724
+ const password = req.body.adminPassword || crypto.randomBytes(18).toString('base64url');
725
+ const configDir = (req.body.configDir || bc.configDir || '/etc/mosquitto').replace(/\\/g, '/');
726
+ const isRemote = bc.mode === 'remote' && bc.ssh && bc.ssh.host;
727
+
728
+ const dynSecPath = `${configDir}/dynamic-security.json`;
729
+ const confFilePath = `${configDir}/mosquitto.conf`;
730
+
731
+ if (isRemote) {
732
+ // mosquitto_ctrl must run on the broker host — invoke it via SSH.
733
+ try {
734
+ await sshDeploy.runCommand(bc.ssh, `mosquitto_ctrl dynsec init "${dynSecPath}" "${username}" "${password}"`);
735
+ } catch (err) {
736
+ return res.status(500).json({
737
+ error: `mosquitto_ctrl failed on remote host: ${err.message}. Ensure mosquitto is installed on the remote broker host.`,
738
+ });
739
+ }
740
+
741
+ // Read the remote mosquitto.conf, parse, and add the plugin line if missing.
742
+ let remoteConfRaw = '';
743
+ try {
744
+ remoteConfRaw = await sshDeploy.readRemoteFile(bc.ssh, confFilePath);
745
+ } catch {
746
+ // File may not exist yet — start from an empty config
747
+ }
748
+ const parsed = mosquittoConf.parseText(remoteConfRaw);
749
+ if (!parsed.managed.plugin || !String(parsed.managed.plugin).includes('mosquitto_dynamic_security')) {
750
+ parsed.managed.plugin = 'mosquitto_dynamic_security.so';
751
+ parsed.managed.plugin_opt_dynsec_config_file = dynSecPath;
752
+ const content = mosquittoConf.serialise(parsed);
753
+ await sshDeploy.uploadContent(bc.ssh, content, confFilePath);
754
+ }
755
+ } else {
756
+ // Local mode: run mosquitto_ctrl on this host.
757
+ fs.mkdirSync(configDir, { recursive: true });
758
+ try {
759
+ await execFileAsync('mosquitto_ctrl', ['dynsec', 'init', dynSecPath, username, password], { timeout: 10000 });
760
+ } catch (err) {
761
+ return res.status(500).json({
762
+ error: `mosquitto_ctrl failed: ${err.message}. Ensure mosquitto is installed on this host.`,
763
+ });
764
+ }
765
+
766
+ // Ensure plugin line exists in local mosquitto.conf
767
+ const parsed = mosquittoConf.parse(confFilePath);
768
+ if (!parsed.managed.plugin || !String(parsed.managed.plugin).includes('mosquitto_dynamic_security')) {
769
+ parsed.managed.plugin = 'mosquitto_dynamic_security.so';
770
+ parsed.managed.plugin_opt_dynsec_config_file = dynSecPath;
771
+ const content = mosquittoConf.serialise(parsed);
772
+ mosquittoConf.write(confFilePath, content);
773
+ }
774
+ }
775
+
776
+ res.json({
777
+ ok: true,
778
+ adminUsername: username,
779
+ adminPassword: password,
780
+ dynSecPath,
781
+ confFilePath,
782
+ message: `Bootstrap complete. Save these credentials to config.json under broker.dynsec, then restart mosquitto (POST /she/broker/restart).`,
783
+ });
784
+ } catch (err) {
785
+ handleError(res, err);
786
+ }
787
+ });