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.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-BW2J83t5.js";import{t as I}from"./index-YxGnpZAh.js";/*!-----------------------------------------------------------------------------
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
@@ -172,7 +172,7 @@
172
172
  }
173
173
  })();
174
174
  </script>
175
- <script type="module" crossorigin src="/assets/index-YxGnpZAh.js"></script>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Node.js based script runner for use in MQTT based Smart Home environments",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
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::ca.
21
- * Issued cert metadata is stored in sheDB at broker::cert::<serial>.
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 a mosquitto.conf file into a structured object.
32
+ * Parse mosquitto.conf text into a structured object.
33
33
  *
34
- * @param {string} filePath
34
+ * @param {string} raw - raw mosquitto.conf content
35
35
  * @returns {{ listeners: object[], managed: object, passthrough: string[], raw: string }}
36
36
  */
37
- function parse(filePath) {
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 };
@@ -1,16 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * ssh-deploy.js — SSH/SFTP file deployment helper for remote broker management.
4
+ * ssh-deploy.js — SSH/SCP file deployment helper for remote broker management.
5
5
  *
6
- * Uses the `ssh2` npm package for SFTP uploads and remote command execution.
7
- * SSH keypair generation delegates to the `ssh-keygen` CLI tool.
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
- * Usage:
10
- * const ssh = require('./ssh-deploy');
11
- * await ssh.uploadFile(sshConfig, localPath, remotePath);
12
- * const { stdout } = await ssh.runCommand(sshConfig, 'sudo systemctl reload mosquitto');
13
- * const pubkey = await ssh.generateKeypair(identityFile);
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 ssh2 connection options from she broker.ssh config.
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 buildConnectOpts(sshConfig) {
49
- const identityFile = expandHome(sshConfig.identityFile || '~/.she/broker_id_ed25519');
50
- let privateKey;
51
- try {
52
- privateKey = fs.readFileSync(identityFile);
53
- } catch (err) {
54
- throw new Error(`Cannot read SSH identity file ${identityFile}: ${err.message}`);
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
- return {
58
- host: sshConfig.host,
59
- port: sshConfig.port || 22,
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
- * Connect to the remote host, execute a command, and return stdout/stderr.
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 { Client } = getSsh2();
75
- const opts = buildConnectOpts(sshConfig);
76
-
77
- return new Promise((resolve, reject) => {
78
- const conn = new Client();
79
- let stdout = '';
80
- let stderr = '';
81
-
82
- conn.on('ready', () => {
83
- conn.exec(command, (err, stream) => {
84
- if (err) {
85
- conn.end();
86
- return reject(err);
87
- }
88
- stream.on('close', (code) => {
89
- conn.end();
90
- if (code !== 0) {
91
- reject(new Error(`Remote command exited ${code}: ${stderr.trim() || stdout.trim()}`));
92
- } else {
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 SFTP.
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 { Client } = getSsh2();
118
- const opts = buildConnectOpts(sshConfig);
119
-
120
- return new Promise((resolve, reject) => {
121
- const conn = new Client();
122
- conn.on('ready', () => {
123
- conn.sftp((err, sftp) => {
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
- return runCommand(sshConfig, 'echo ok').then(() => ({ ok: true }));
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
- const pubkey = fs.readFileSync(expandedPath + '.pub', 'utf8');
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 };
@@ -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::cert::'),
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::cert::${result.serial}`, {
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::cert::${serial}`) : null;
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::cert::') && doc.revoked && doc._id !== `broker::cert::${serial}`,
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::cert::${serial}`, { revoked: true, revokedAt: new Date().toISOString() });
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::cert::${serial}`) : null;
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) || '~/.she/broker_id_ed25519';
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 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)
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
- // 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
+ 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
- // 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);
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
- 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;
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
- 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 {
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
  }