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