smart-home-engine 1.1.0 → 1.1.2
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-YxGnpZAh.js → index-DvaCcW0R.js} +46 -46
- package/dist/web/assets/{tsMode-DAKcfE4c.js → tsMode-BnfCWkGv.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/lib/ca.js +2 -2
- package/src/lib/mosquitto-conf.js +21 -13
- package/src/lib/ssh-deploy.js +66 -119
- package/src/web/broker-api.js +74 -50
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{m as O}from"./monaco-langs-BW2J83t5.js";import{t as I}from"./index-
|
|
1
|
+
import{m as O}from"./monaco-langs-BW2J83t5.js";import{t as I}from"./index-DvaCcW0R.js";/*!-----------------------------------------------------------------------------
|
|
2
2
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
3
|
* Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
|
|
4
4
|
* Released under the MIT license
|
package/dist/web/index.html
CHANGED
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
}
|
|
173
173
|
})();
|
|
174
174
|
</script>
|
|
175
|
-
<script type="module" crossorigin src="/assets/index-
|
|
175
|
+
<script type="module" crossorigin src="/assets/index-DvaCcW0R.js"></script>
|
|
176
176
|
<link rel="modulepreload" crossorigin href="/assets/monaco-langs-BW2J83t5.js">
|
|
177
177
|
<link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
|
|
178
178
|
<link rel="stylesheet" crossorigin href="/assets/index-DKIgEFlE.css">
|
package/package.json
CHANGED
package/src/lib/ca.js
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* client.crt
|
|
18
18
|
* client.p12
|
|
19
19
|
*
|
|
20
|
-
* CA cert metadata is stored in sheDB at broker
|
|
21
|
-
* Issued cert metadata is stored in sheDB at broker
|
|
20
|
+
* CA cert metadata is stored in sheDB at she/broker/ca.
|
|
21
|
+
* Issued cert metadata is stored in sheDB at she/broker/cert/<serial>.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
const { execFile } = require('child_process');
|
|
@@ -29,20 +29,12 @@ const execFileAsync = promisify(execFile);
|
|
|
29
29
|
const MANAGED_SINGLE_KEYS = new Set(['allow_anonymous', 'persistence', 'persistence_location', 'log_dest', 'log_type', 'plugin', 'plugin_opt_dynsec_config_file']);
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Parse
|
|
32
|
+
* Parse mosquitto.conf text into a structured object.
|
|
33
33
|
*
|
|
34
|
-
* @param {string}
|
|
34
|
+
* @param {string} raw - raw mosquitto.conf content
|
|
35
35
|
* @returns {{ listeners: object[], managed: object, passthrough: string[], raw: string }}
|
|
36
36
|
*/
|
|
37
|
-
function
|
|
38
|
-
let raw = '';
|
|
39
|
-
try {
|
|
40
|
-
raw = fs.readFileSync(filePath, 'utf8');
|
|
41
|
-
} catch (err) {
|
|
42
|
-
if (err.code !== 'ENOENT') throw err;
|
|
43
|
-
return { listeners: [], managed: {}, passthrough: [], raw: '' };
|
|
44
|
-
}
|
|
45
|
-
|
|
37
|
+
function parseText(raw) {
|
|
46
38
|
const lines = raw.split('\n');
|
|
47
39
|
const managed = {};
|
|
48
40
|
const listeners = [];
|
|
@@ -79,7 +71,6 @@ function parse(filePath) {
|
|
|
79
71
|
applyListenerKey(currentListener, key, value);
|
|
80
72
|
} else if (MANAGED_SINGLE_KEYS.has(key)) {
|
|
81
73
|
if (managed[key] !== undefined) {
|
|
82
|
-
// multi-value key (e.g. log_type) — convert to array
|
|
83
74
|
managed[key] = [].concat(managed[key]).concat(value);
|
|
84
75
|
} else {
|
|
85
76
|
managed[key] = value;
|
|
@@ -94,6 +85,23 @@ function parse(filePath) {
|
|
|
94
85
|
return { listeners, managed, passthrough, raw };
|
|
95
86
|
}
|
|
96
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Parse a mosquitto.conf file into a structured object.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} filePath
|
|
92
|
+
* @returns {{ listeners: object[], managed: object, passthrough: string[], raw: string }}
|
|
93
|
+
*/
|
|
94
|
+
function parse(filePath) {
|
|
95
|
+
let raw = '';
|
|
96
|
+
try {
|
|
97
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err.code !== 'ENOENT') throw err;
|
|
100
|
+
return { listeners: [], managed: {}, passthrough: [], raw: '' };
|
|
101
|
+
}
|
|
102
|
+
return parseText(raw);
|
|
103
|
+
}
|
|
104
|
+
|
|
97
105
|
/** Keys that belong to a listener block */
|
|
98
106
|
function isListenerSubkey(key) {
|
|
99
107
|
return [
|
|
@@ -284,4 +292,4 @@ async function restart(brokerConfig) {
|
|
|
284
292
|
return result;
|
|
285
293
|
}
|
|
286
294
|
|
|
287
|
-
module.exports = { parse, serialise, checksum, write, listBackups, restoreBackup, reload, restart };
|
|
295
|
+
module.exports = { parse, parseText, serialise, checksum, write, listBackups, restoreBackup, reload, restart };
|
package/src/lib/ssh-deploy.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* ssh-deploy.js — SSH/
|
|
4
|
+
* ssh-deploy.js — SSH/SCP file deployment helper for remote broker management.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* SSH keypair generation
|
|
6
|
+
* Shells out to the system ssh and scp clients — no npm dependencies required.
|
|
7
|
+
* SSH keypair generation uses the system ssh-keygen binary.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Requires ssh, scp, and ssh-keygen available in PATH on the she host.
|
|
10
|
+
*
|
|
11
|
+
* StrictHostKeyChecking=accept-new trusts new hosts on first connect and
|
|
12
|
+
* verifies the key on subsequent connections, protecting against MITM after
|
|
13
|
+
* the initial handshake without blocking automation.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const { execFile } = require('child_process');
|
|
@@ -21,19 +21,6 @@ const os = require('os');
|
|
|
21
21
|
|
|
22
22
|
const execFileAsync = promisify(execFile);
|
|
23
23
|
|
|
24
|
-
// ssh2 is an optional dependency — only loaded when needed
|
|
25
|
-
let _ssh2 = null;
|
|
26
|
-
function getSsh2() {
|
|
27
|
-
if (!_ssh2) {
|
|
28
|
-
try {
|
|
29
|
-
_ssh2 = require('ssh2');
|
|
30
|
-
} catch {
|
|
31
|
-
throw new Error('ssh2 package not installed — run: npm install ssh2');
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return _ssh2;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
24
|
function expandHome(p) {
|
|
38
25
|
if (typeof p === 'string' && (p.startsWith('~/') || p === '~')) {
|
|
39
26
|
return path.join(os.homedir(), p.slice(2));
|
|
@@ -42,109 +29,83 @@ function expandHome(p) {
|
|
|
42
29
|
}
|
|
43
30
|
|
|
44
31
|
/**
|
|
45
|
-
* Build
|
|
32
|
+
* Build the common ssh argument list (flags only, no target/command).
|
|
46
33
|
* @param {object} sshConfig - config.broker.ssh
|
|
34
|
+
* @returns {string[]}
|
|
47
35
|
*/
|
|
48
|
-
function
|
|
49
|
-
const identityFile = expandHome(sshConfig.identityFile || '~/.she/broker_id_ed25519');
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
36
|
+
function sshArgs(sshConfig) {
|
|
37
|
+
const identityFile = expandHome(sshConfig.identityFile || '~/.she/ssh/broker_id_ed25519');
|
|
38
|
+
return ['-i', identityFile, '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-p', String(sshConfig.port || 22)];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the scp argument list prefix.
|
|
43
|
+
* scp uses -P (capital) for port, unlike ssh which uses -p.
|
|
44
|
+
* @param {object} sshConfig
|
|
45
|
+
* @returns {string[]}
|
|
46
|
+
*/
|
|
47
|
+
function scpArgs(sshConfig) {
|
|
48
|
+
const identityFile = expandHome(sshConfig.identityFile || '~/.she/ssh/broker_id_ed25519');
|
|
49
|
+
return ['-i', identityFile, '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-P', String(sshConfig.port || 22)];
|
|
50
|
+
}
|
|
56
51
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
username: sshConfig.user || 'she',
|
|
61
|
-
privateKey,
|
|
62
|
-
readyTimeout: 10000,
|
|
63
|
-
keepaliveInterval: 0,
|
|
64
|
-
};
|
|
52
|
+
function sshTarget(sshConfig) {
|
|
53
|
+
const user = sshConfig.user || os.userInfo().username;
|
|
54
|
+
return `${user}@${sshConfig.host}`;
|
|
65
55
|
}
|
|
66
56
|
|
|
67
57
|
/**
|
|
68
|
-
*
|
|
58
|
+
* Run a command on the remote host via the system ssh client.
|
|
69
59
|
* @param {object} sshConfig - config.broker.ssh
|
|
70
60
|
* @param {string} command
|
|
71
61
|
* @returns {Promise<{ stdout: string, stderr: string }>}
|
|
72
62
|
*/
|
|
73
|
-
function runCommand(sshConfig, command) {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
|
|
94
|
-
}
|
|
95
|
-
});
|
|
96
|
-
stream.on('data', (d) => {
|
|
97
|
-
stdout += d.toString();
|
|
98
|
-
});
|
|
99
|
-
stream.stderr.on('data', (d) => {
|
|
100
|
-
stderr += d.toString();
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
conn.on('error', reject);
|
|
106
|
-
conn.connect(opts);
|
|
107
|
-
});
|
|
63
|
+
async function runCommand(sshConfig, command) {
|
|
64
|
+
const args = [...sshArgs(sshConfig), sshTarget(sshConfig), command];
|
|
65
|
+
try {
|
|
66
|
+
const { stdout, stderr } = await execFileAsync('ssh', args, { timeout: 15000 });
|
|
67
|
+
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
|
68
|
+
} catch (err) {
|
|
69
|
+
const detail = (err.stderr || '').trim() || (err.stdout || '').trim() || err.message;
|
|
70
|
+
throw new Error(detail);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the content of a file on the remote host via ssh cat.
|
|
76
|
+
* @param {object} sshConfig
|
|
77
|
+
* @param {string} remotePath
|
|
78
|
+
* @returns {Promise<string>}
|
|
79
|
+
*/
|
|
80
|
+
async function readRemoteFile(sshConfig, remotePath) {
|
|
81
|
+
const { stdout } = await runCommand(sshConfig, `cat -- "${remotePath}"`);
|
|
82
|
+
return stdout;
|
|
108
83
|
}
|
|
109
84
|
|
|
110
85
|
/**
|
|
111
|
-
* Upload a local file to the remote host via
|
|
86
|
+
* Upload a local file to the remote host via scp.
|
|
112
87
|
* @param {object} sshConfig
|
|
113
88
|
* @param {string} localPath
|
|
114
89
|
* @param {string} remotePath
|
|
115
90
|
*/
|
|
116
|
-
function uploadFile(sshConfig, localPath, remotePath) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (err) {
|
|
125
|
-
conn.end();
|
|
126
|
-
return reject(err);
|
|
127
|
-
}
|
|
128
|
-
sftp.fastPut(localPath, remotePath, (err2) => {
|
|
129
|
-
conn.end();
|
|
130
|
-
if (err2) reject(err2);
|
|
131
|
-
else resolve();
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
conn.on('error', reject);
|
|
136
|
-
conn.connect(opts);
|
|
137
|
-
});
|
|
91
|
+
async function uploadFile(sshConfig, localPath, remotePath) {
|
|
92
|
+
const args = [...scpArgs(sshConfig), localPath, `${sshTarget(sshConfig)}:${remotePath}`];
|
|
93
|
+
try {
|
|
94
|
+
await execFileAsync('scp', args, { timeout: 30000 });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const detail = (err.stderr || '').trim() || err.message;
|
|
97
|
+
throw new Error(detail);
|
|
98
|
+
}
|
|
138
99
|
}
|
|
139
100
|
|
|
140
101
|
/**
|
|
141
102
|
* Upload a string as file content to the remote host.
|
|
103
|
+
* Writes to a local temp file then uploads via scp.
|
|
142
104
|
* @param {object} sshConfig
|
|
143
105
|
* @param {string} content
|
|
144
106
|
* @param {string} remotePath
|
|
145
107
|
*/
|
|
146
108
|
async function uploadContent(sshConfig, content, remotePath) {
|
|
147
|
-
// Write to a temp file, then SFTP upload, then delete temp
|
|
148
109
|
const tmp = path.join(os.tmpdir(), `she-ssh-${Date.now()}.tmp`);
|
|
149
110
|
fs.writeFileSync(tmp, content, 'utf8');
|
|
150
111
|
try {
|
|
@@ -162,8 +123,9 @@ async function uploadContent(sshConfig, content, remotePath) {
|
|
|
162
123
|
* Test SSH connectivity. Resolves to { ok: true } on success, or throws.
|
|
163
124
|
* @param {object} sshConfig
|
|
164
125
|
*/
|
|
165
|
-
function testConnection(sshConfig) {
|
|
166
|
-
|
|
126
|
+
async function testConnection(sshConfig) {
|
|
127
|
+
await runCommand(sshConfig, 'echo ok');
|
|
128
|
+
return { ok: true };
|
|
167
129
|
}
|
|
168
130
|
|
|
169
131
|
/**
|
|
@@ -172,11 +134,10 @@ function testConnection(sshConfig) {
|
|
|
172
134
|
* @returns {Promise<string>} the public key text
|
|
173
135
|
*/
|
|
174
136
|
async function generateKeypair(identityFile) {
|
|
175
|
-
const expandedPath = expandHome(identityFile || '~/.she/broker_id_ed25519');
|
|
137
|
+
const expandedPath = expandHome(identityFile || '~/.she/ssh/broker_id_ed25519');
|
|
176
138
|
const dir = path.dirname(expandedPath);
|
|
177
139
|
fs.mkdirSync(dir, { recursive: true });
|
|
178
140
|
|
|
179
|
-
// Remove existing key if present
|
|
180
141
|
try {
|
|
181
142
|
fs.unlinkSync(expandedPath);
|
|
182
143
|
} catch {
|
|
@@ -188,20 +149,7 @@ async function generateKeypair(identityFile) {
|
|
|
188
149
|
/* ok */
|
|
189
150
|
}
|
|
190
151
|
|
|
191
|
-
await execFileAsync(
|
|
192
|
-
'ssh-keygen',
|
|
193
|
-
[
|
|
194
|
-
'-t',
|
|
195
|
-
'ed25519',
|
|
196
|
-
'-f',
|
|
197
|
-
expandedPath,
|
|
198
|
-
'-N',
|
|
199
|
-
'', // no passphrase
|
|
200
|
-
'-C',
|
|
201
|
-
'she-broker',
|
|
202
|
-
],
|
|
203
|
-
{ timeout: 15000 },
|
|
204
|
-
);
|
|
152
|
+
await execFileAsync('ssh-keygen', ['-t', 'ed25519', '-f', expandedPath, '-N', '', '-C', 'she-broker'], { timeout: 15000 });
|
|
205
153
|
|
|
206
154
|
try {
|
|
207
155
|
fs.chmodSync(expandedPath, 0o600);
|
|
@@ -209,8 +157,7 @@ async function generateKeypair(identityFile) {
|
|
|
209
157
|
/* ignore */
|
|
210
158
|
}
|
|
211
159
|
|
|
212
|
-
|
|
213
|
-
return pubkey.trim();
|
|
160
|
+
return fs.readFileSync(expandedPath + '.pub', 'utf8').trim();
|
|
214
161
|
}
|
|
215
162
|
|
|
216
|
-
module.exports = { runCommand, uploadFile, uploadContent, testConnection, generateKeypair };
|
|
163
|
+
module.exports = { runCommand, readRemoteFile, uploadFile, uploadContent, testConnection, generateKeypair };
|
package/src/web/broker-api.js
CHANGED
|
@@ -20,6 +20,10 @@ const dynsec = require('../lib/dynsec');
|
|
|
20
20
|
const mosquittoConf = require('../lib/mosquitto-conf');
|
|
21
21
|
const ca = require('../lib/ca');
|
|
22
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');
|
|
23
27
|
|
|
24
28
|
const router = express.Router();
|
|
25
29
|
|
|
@@ -70,7 +74,7 @@ router.get('/status', (req, res) => {
|
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
res.json({ dynsec: ds, sys });
|
|
77
|
+
res.json({ dynsec: ds, sys, sshKeyDefault: DEFAULT_SSH_KEY });
|
|
74
78
|
});
|
|
75
79
|
|
|
76
80
|
// ── mosquitto.conf ─────────────────────────────────────────────────────────────
|
|
@@ -181,10 +185,15 @@ router.post('/config/restore', (req, res) => {
|
|
|
181
185
|
/**
|
|
182
186
|
* POST /she/broker/reload
|
|
183
187
|
* Send SIGHUP / systemctl reload to mosquitto.
|
|
188
|
+
* In remote mode, the command is executed on the broker host via SSH.
|
|
184
189
|
*/
|
|
185
190
|
router.post('/reload', async (req, res) => {
|
|
186
191
|
try {
|
|
187
192
|
const bc = getBrokerConfig(req);
|
|
193
|
+
if (bc.ssh && bc.ssh.host) {
|
|
194
|
+
const result = await sshDeploy.runCommand(bc.ssh, cmd);
|
|
195
|
+
return res.json({ ok: true, ...result });
|
|
196
|
+
}
|
|
188
197
|
const result = await mosquittoConf.reload(bc);
|
|
189
198
|
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
|
|
190
199
|
} catch (err) {
|
|
@@ -195,10 +204,15 @@ router.post('/reload', async (req, res) => {
|
|
|
195
204
|
/**
|
|
196
205
|
* POST /she/broker/restart
|
|
197
206
|
* Full mosquitto service restart.
|
|
207
|
+
* In remote mode, the command is executed on the broker host via SSH.
|
|
198
208
|
*/
|
|
199
209
|
router.post('/restart', async (req, res) => {
|
|
200
210
|
try {
|
|
201
211
|
const bc = getBrokerConfig(req);
|
|
212
|
+
if (bc.ssh && bc.ssh.host) {
|
|
213
|
+
const result = await sshDeploy.runCommand(bc.ssh, cmd);
|
|
214
|
+
return res.json({ ok: true, ...result });
|
|
215
|
+
}
|
|
202
216
|
const result = await mosquittoConf.restart(bc);
|
|
203
217
|
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
|
|
204
218
|
} catch (err) {
|
|
@@ -469,7 +483,7 @@ router.get('/ca/certs', async (req, res) => {
|
|
|
469
483
|
const db = req.app.locals.db;
|
|
470
484
|
if (!db) return res.json({ certs: [] });
|
|
471
485
|
const certs = db.query(
|
|
472
|
-
(doc) => doc._id && doc._id.startsWith('broker
|
|
486
|
+
(doc) => doc._id && doc._id.startsWith('she/broker/cert/'),
|
|
473
487
|
(doc) => doc,
|
|
474
488
|
);
|
|
475
489
|
res.json({ certs });
|
|
@@ -488,7 +502,7 @@ router.post('/ca/certs', async (req, res) => {
|
|
|
488
502
|
// Store metadata in sheDB
|
|
489
503
|
const db = req.app.locals.db;
|
|
490
504
|
if (db) {
|
|
491
|
-
db.set(`broker
|
|
505
|
+
db.set(`she/broker/cert/${result.serial}`, {
|
|
492
506
|
cn: result.cn,
|
|
493
507
|
serial: result.serial,
|
|
494
508
|
fingerprint: result.fingerprint,
|
|
@@ -522,7 +536,7 @@ router.delete('/ca/certs/:serial', async (req, res) => {
|
|
|
522
536
|
const bc = getBrokerConfig(req);
|
|
523
537
|
const { serial } = req.params;
|
|
524
538
|
const db = req.app.locals.db;
|
|
525
|
-
const meta = db ? db.get(`broker
|
|
539
|
+
const meta = db ? db.get(`she/broker/cert/${serial}`) : null;
|
|
526
540
|
if (!meta) return res.status(404).json({ error: 'cert not found' });
|
|
527
541
|
|
|
528
542
|
// Find the cert file
|
|
@@ -534,7 +548,7 @@ router.delete('/ca/certs/:serial', async (req, res) => {
|
|
|
534
548
|
// Collect all other revoked certs
|
|
535
549
|
if (db) {
|
|
536
550
|
const allCerts = db.query(
|
|
537
|
-
(doc) => doc._id && doc._id.startsWith('broker
|
|
551
|
+
(doc) => doc._id && doc._id.startsWith('she/broker/cert/') && doc.revoked && doc._id !== `she/broker/cert/${serial}`,
|
|
538
552
|
(doc) => doc,
|
|
539
553
|
);
|
|
540
554
|
for (const c of allCerts) {
|
|
@@ -547,7 +561,7 @@ router.delete('/ca/certs/:serial', async (req, res) => {
|
|
|
547
561
|
|
|
548
562
|
// Mark revoked in sheDB
|
|
549
563
|
if (db) {
|
|
550
|
-
db.extend(`broker
|
|
564
|
+
db.extend(`she/broker/cert/${serial}`, { revoked: true, revokedAt: new Date().toISOString() });
|
|
551
565
|
}
|
|
552
566
|
|
|
553
567
|
res.json({ ok: true });
|
|
@@ -563,7 +577,7 @@ router.get('/ca/certs/:serial/download', async (req, res) => {
|
|
|
563
577
|
const { serial } = req.params;
|
|
564
578
|
const { type = 'p12' } = req.query;
|
|
565
579
|
const db = req.app.locals.db;
|
|
566
|
-
const meta = db ? db.get(`broker
|
|
580
|
+
const meta = db ? db.get(`she/broker/cert/${serial}`) : null;
|
|
567
581
|
if (!meta) return res.status(404).json({ error: 'cert not found' });
|
|
568
582
|
|
|
569
583
|
const paths = ca.clientCertPaths(bc, meta.cn);
|
|
@@ -650,7 +664,7 @@ module.exports = { router };
|
|
|
650
664
|
router.post('/ssh/keygen', async (req, res) => {
|
|
651
665
|
try {
|
|
652
666
|
const bc = getBrokerConfig(req);
|
|
653
|
-
const identityFile = (bc.ssh && bc.ssh.identityFile) ||
|
|
667
|
+
const identityFile = (bc.ssh && bc.ssh.identityFile) || DEFAULT_SSH_KEY;
|
|
654
668
|
const publicKey = await sshDeploy.generateKeypair(identityFile);
|
|
655
669
|
res.json({ ok: true, publicKey });
|
|
656
670
|
} catch (err) {
|
|
@@ -684,13 +698,17 @@ router.post('/wizard/probe', (req, res) => {
|
|
|
684
698
|
/**
|
|
685
699
|
* POST /she/broker/wizard/bootstrap
|
|
686
700
|
* Full bootstrap flow:
|
|
687
|
-
* 1. Generate dynamic-security.json
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
701
|
+
* 1. Generate dynamic-security.json via mosquitto_ctrl
|
|
702
|
+
* - Remote mode: run mosquitto_ctrl on the broker host via SSH
|
|
703
|
+
* - Local mode: run mosquitto_ctrl locally
|
|
704
|
+
* 2. Ensure plugin line exists in mosquitto.conf
|
|
705
|
+
* 3. Return credentials (store in config.json via /she/config)
|
|
706
|
+
*
|
|
707
|
+
* Note: mosquitto_ctrl is part of the mosquitto package and must be installed
|
|
708
|
+
* on the same host as the broker. It cannot be used to manage a remote broker,
|
|
709
|
+
* which is why we invoke it via SSH in remote mode.
|
|
691
710
|
*
|
|
692
711
|
* Body: { adminUsername?, adminPassword?, configDir? }
|
|
693
|
-
* The generated password is returned in the response (store in config.json via /she/config).
|
|
694
712
|
*/
|
|
695
713
|
router.post('/wizard/bootstrap', async (req, res) => {
|
|
696
714
|
try {
|
|
@@ -702,47 +720,53 @@ router.post('/wizard/bootstrap', async (req, res) => {
|
|
|
702
720
|
|
|
703
721
|
const username = req.body.adminUsername || 'she-admin';
|
|
704
722
|
const password = req.body.adminPassword || crypto.randomBytes(18).toString('base64url');
|
|
705
|
-
const configDir = req.body.configDir || bc.configDir || '/etc/mosquitto';
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
const dynSecPath =
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
} catch {
|
|
721
|
-
/* ok */
|
|
722
|
-
}
|
|
723
|
+
const configDir = (req.body.configDir || bc.configDir || '/etc/mosquitto').replace(/\\/g, '/');
|
|
724
|
+
const isRemote = !!(bc.ssh && bc.ssh.host);
|
|
725
|
+
|
|
726
|
+
const dynSecPath = `${configDir}/dynamic-security.json`;
|
|
727
|
+
const confFilePath = `${configDir}/mosquitto.conf`;
|
|
728
|
+
|
|
729
|
+
if (isRemote) {
|
|
730
|
+
// mosquitto_ctrl must run on the broker host — invoke it via SSH.
|
|
731
|
+
try {
|
|
732
|
+
await sshDeploy.runCommand(bc.ssh, `mosquitto_ctrl dynsec init "${dynSecPath}" "${username}" "${password}"`);
|
|
733
|
+
} catch (err) {
|
|
734
|
+
return res.status(500).json({
|
|
735
|
+
error: `mosquitto_ctrl failed on remote host: ${err.message}. Ensure mosquitto is installed on the remote broker host.`,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
723
738
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
739
|
+
// Read the remote mosquitto.conf, parse, and add the plugin line if missing.
|
|
740
|
+
let remoteConfRaw = '';
|
|
741
|
+
try {
|
|
742
|
+
remoteConfRaw = await sshDeploy.readRemoteFile(bc.ssh, confFilePath);
|
|
743
|
+
} catch {
|
|
744
|
+
// File may not exist yet — start from an empty config
|
|
745
|
+
}
|
|
746
|
+
const parsed = mosquittoConf.parseText(remoteConfRaw);
|
|
747
|
+
if (!parsed.managed.plugin || !String(parsed.managed.plugin).includes('mosquitto_dynamic_security')) {
|
|
748
|
+
parsed.managed.plugin = 'mosquitto_dynamic_security.so';
|
|
749
|
+
parsed.managed.plugin_opt_dynsec_config_file = dynSecPath;
|
|
750
|
+
const content = mosquittoConf.serialise(parsed);
|
|
751
|
+
await sshDeploy.uploadContent(bc.ssh, content, confFilePath);
|
|
752
|
+
}
|
|
727
753
|
} else {
|
|
754
|
+
// Local mode: run mosquitto_ctrl on this host.
|
|
728
755
|
fs.mkdirSync(configDir, { recursive: true });
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
const pluginLine = 'mosquitto_dynamic_security.so';
|
|
737
|
-
const pluginOptLine = dynSecPath;
|
|
756
|
+
try {
|
|
757
|
+
await execFileAsync('mosquitto_ctrl', ['dynsec', 'init', dynSecPath, username, password], { timeout: 10000 });
|
|
758
|
+
} catch (err) {
|
|
759
|
+
return res.status(500).json({
|
|
760
|
+
error: `mosquitto_ctrl failed: ${err.message}. Ensure mosquitto is installed on this host.`,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
738
763
|
|
|
739
|
-
|
|
740
|
-
parsed
|
|
741
|
-
parsed.managed.
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
} else {
|
|
764
|
+
// Ensure plugin line exists in local mosquitto.conf
|
|
765
|
+
const parsed = mosquittoConf.parse(confFilePath);
|
|
766
|
+
if (!parsed.managed.plugin || !String(parsed.managed.plugin).includes('mosquitto_dynamic_security')) {
|
|
767
|
+
parsed.managed.plugin = 'mosquitto_dynamic_security.so';
|
|
768
|
+
parsed.managed.plugin_opt_dynsec_config_file = dynSecPath;
|
|
769
|
+
const content = mosquittoConf.serialise(parsed);
|
|
746
770
|
mosquittoConf.write(confFilePath, content);
|
|
747
771
|
}
|
|
748
772
|
}
|