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.
- package/README.md +4 -1
- package/dist/web/assets/{index-B_L247qc.css → index-DKIgEFlE.css} +1 -1
- package/dist/web/assets/index-DttCbWJj.js +230 -0
- package/dist/web/assets/{tsMode-D1vQpxKE.js → tsMode-BcbP1HsB.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 +295 -0
- package/src/lib/ssh-deploy.js +162 -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 +787 -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/scripts-api.js +8 -2
- package/src/web/server.js +8 -2
- package/dist/web/assets/index-DPRSyXE_.js +0 -230
|
@@ -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
|
+
});
|