smart-home-engine 1.1.0 → 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.
@@ -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-DttCbWJj.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-DttCbWJj.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.1",
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,82 @@ 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
+ return `${sshConfig.user || 'she'}@${sshConfig.host}`;
65
54
  }
66
55
 
67
56
  /**
68
- * Connect to the remote host, execute a command, and return stdout/stderr.
57
+ * Run a command on the remote host via the system ssh client.
69
58
  * @param {object} sshConfig - config.broker.ssh
70
59
  * @param {string} command
71
60
  * @returns {Promise<{ stdout: string, stderr: string }>}
72
61
  */
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
- });
62
+ async function runCommand(sshConfig, command) {
63
+ const args = [...sshArgs(sshConfig), sshTarget(sshConfig), command];
64
+ try {
65
+ const { stdout, stderr } = await execFileAsync('ssh', args, { timeout: 15000 });
66
+ return { stdout: stdout.trim(), stderr: stderr.trim() };
67
+ } catch (err) {
68
+ const detail = (err.stderr || '').trim() || (err.stdout || '').trim() || err.message;
69
+ throw new Error(detail);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Read the content of a file on the remote host via ssh cat.
75
+ * @param {object} sshConfig
76
+ * @param {string} remotePath
77
+ * @returns {Promise<string>}
78
+ */
79
+ async function readRemoteFile(sshConfig, remotePath) {
80
+ const { stdout } = await runCommand(sshConfig, `cat -- "${remotePath}"`);
81
+ return stdout;
108
82
  }
109
83
 
110
84
  /**
111
- * Upload a local file to the remote host via SFTP.
85
+ * Upload a local file to the remote host via scp.
112
86
  * @param {object} sshConfig
113
87
  * @param {string} localPath
114
88
  * @param {string} remotePath
115
89
  */
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
- });
90
+ async function uploadFile(sshConfig, localPath, remotePath) {
91
+ const args = [...scpArgs(sshConfig), localPath, `${sshTarget(sshConfig)}:${remotePath}`];
92
+ try {
93
+ await execFileAsync('scp', args, { timeout: 30000 });
94
+ } catch (err) {
95
+ const detail = (err.stderr || '').trim() || err.message;
96
+ throw new Error(detail);
97
+ }
138
98
  }
139
99
 
140
100
  /**
141
101
  * Upload a string as file content to the remote host.
102
+ * Writes to a local temp file then uploads via scp.
142
103
  * @param {object} sshConfig
143
104
  * @param {string} content
144
105
  * @param {string} remotePath
145
106
  */
146
107
  async function uploadContent(sshConfig, content, remotePath) {
147
- // Write to a temp file, then SFTP upload, then delete temp
148
108
  const tmp = path.join(os.tmpdir(), `she-ssh-${Date.now()}.tmp`);
149
109
  fs.writeFileSync(tmp, content, 'utf8');
150
110
  try {
@@ -162,8 +122,9 @@ async function uploadContent(sshConfig, content, remotePath) {
162
122
  * Test SSH connectivity. Resolves to { ok: true } on success, or throws.
163
123
  * @param {object} sshConfig
164
124
  */
165
- function testConnection(sshConfig) {
166
- return runCommand(sshConfig, 'echo ok').then(() => ({ ok: true }));
125
+ async function testConnection(sshConfig) {
126
+ await runCommand(sshConfig, 'echo ok');
127
+ return { ok: true };
167
128
  }
168
129
 
169
130
  /**
@@ -172,11 +133,10 @@ function testConnection(sshConfig) {
172
133
  * @returns {Promise<string>} the public key text
173
134
  */
174
135
  async function generateKeypair(identityFile) {
175
- const expandedPath = expandHome(identityFile || '~/.she/broker_id_ed25519');
136
+ const expandedPath = expandHome(identityFile || '~/.she/ssh/broker_id_ed25519');
176
137
  const dir = path.dirname(expandedPath);
177
138
  fs.mkdirSync(dir, { recursive: true });
178
139
 
179
- // Remove existing key if present
180
140
  try {
181
141
  fs.unlinkSync(expandedPath);
182
142
  } catch {
@@ -188,20 +148,7 @@ async function generateKeypair(identityFile) {
188
148
  /* ok */
189
149
  }
190
150
 
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
- );
151
+ await execFileAsync('ssh-keygen', ['-t', 'ed25519', '-f', expandedPath, '-N', '', '-C', 'she-broker'], { timeout: 15000 });
205
152
 
206
153
  try {
207
154
  fs.chmodSync(expandedPath, 0o600);
@@ -209,8 +156,7 @@ async function generateKeypair(identityFile) {
209
156
  /* ignore */
210
157
  }
211
158
 
212
- const pubkey = fs.readFileSync(expandedPath + '.pub', 'utf8');
213
- return pubkey.trim();
159
+ return fs.readFileSync(expandedPath + '.pub', 'utf8').trim();
214
160
  }
215
161
 
216
- module.exports = { runCommand, uploadFile, uploadContent, testConnection, generateKeypair };
162
+ 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,16 @@ 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.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
+ }
188
198
  const result = await mosquittoConf.reload(bc);
189
199
  res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
190
200
  } catch (err) {
@@ -195,10 +205,16 @@ router.post('/reload', async (req, res) => {
195
205
  /**
196
206
  * POST /she/broker/restart
197
207
  * Full mosquitto service restart.
208
+ * In remote mode, the command is executed on the broker host via SSH.
198
209
  */
199
210
  router.post('/restart', async (req, res) => {
200
211
  try {
201
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
+ }
202
218
  const result = await mosquittoConf.restart(bc);
203
219
  res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
204
220
  } catch (err) {
@@ -469,7 +485,7 @@ router.get('/ca/certs', async (req, res) => {
469
485
  const db = req.app.locals.db;
470
486
  if (!db) return res.json({ certs: [] });
471
487
  const certs = db.query(
472
- (doc) => doc._id && doc._id.startsWith('broker::cert::'),
488
+ (doc) => doc._id && doc._id.startsWith('she/broker/cert/'),
473
489
  (doc) => doc,
474
490
  );
475
491
  res.json({ certs });
@@ -488,7 +504,7 @@ router.post('/ca/certs', async (req, res) => {
488
504
  // Store metadata in sheDB
489
505
  const db = req.app.locals.db;
490
506
  if (db) {
491
- db.set(`broker::cert::${result.serial}`, {
507
+ db.set(`she/broker/cert/${result.serial}`, {
492
508
  cn: result.cn,
493
509
  serial: result.serial,
494
510
  fingerprint: result.fingerprint,
@@ -522,7 +538,7 @@ router.delete('/ca/certs/:serial', async (req, res) => {
522
538
  const bc = getBrokerConfig(req);
523
539
  const { serial } = req.params;
524
540
  const db = req.app.locals.db;
525
- const meta = db ? db.get(`broker::cert::${serial}`) : null;
541
+ const meta = db ? db.get(`she/broker/cert/${serial}`) : null;
526
542
  if (!meta) return res.status(404).json({ error: 'cert not found' });
527
543
 
528
544
  // Find the cert file
@@ -534,7 +550,7 @@ router.delete('/ca/certs/:serial', async (req, res) => {
534
550
  // Collect all other revoked certs
535
551
  if (db) {
536
552
  const allCerts = db.query(
537
- (doc) => doc._id && doc._id.startsWith('broker::cert::') && doc.revoked && doc._id !== `broker::cert::${serial}`,
553
+ (doc) => doc._id && doc._id.startsWith('she/broker/cert/') && doc.revoked && doc._id !== `she/broker/cert/${serial}`,
538
554
  (doc) => doc,
539
555
  );
540
556
  for (const c of allCerts) {
@@ -547,7 +563,7 @@ router.delete('/ca/certs/:serial', async (req, res) => {
547
563
 
548
564
  // Mark revoked in sheDB
549
565
  if (db) {
550
- db.extend(`broker::cert::${serial}`, { revoked: true, revokedAt: new Date().toISOString() });
566
+ db.extend(`she/broker/cert/${serial}`, { revoked: true, revokedAt: new Date().toISOString() });
551
567
  }
552
568
 
553
569
  res.json({ ok: true });
@@ -563,7 +579,7 @@ router.get('/ca/certs/:serial/download', async (req, res) => {
563
579
  const { serial } = req.params;
564
580
  const { type = 'p12' } = req.query;
565
581
  const db = req.app.locals.db;
566
- const meta = db ? db.get(`broker::cert::${serial}`) : null;
582
+ const meta = db ? db.get(`she/broker/cert/${serial}`) : null;
567
583
  if (!meta) return res.status(404).json({ error: 'cert not found' });
568
584
 
569
585
  const paths = ca.clientCertPaths(bc, meta.cn);
@@ -650,7 +666,7 @@ module.exports = { router };
650
666
  router.post('/ssh/keygen', async (req, res) => {
651
667
  try {
652
668
  const bc = getBrokerConfig(req);
653
- const identityFile = (bc.ssh && bc.ssh.identityFile) || '~/.she/broker_id_ed25519';
669
+ const identityFile = (bc.ssh && bc.ssh.identityFile) || DEFAULT_SSH_KEY;
654
670
  const publicKey = await sshDeploy.generateKeypair(identityFile);
655
671
  res.json({ ok: true, publicKey });
656
672
  } catch (err) {
@@ -684,13 +700,17 @@ router.post('/wizard/probe', (req, res) => {
684
700
  /**
685
701
  * POST /she/broker/wizard/bootstrap
686
702
  * 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)
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.
691
712
  *
692
713
  * Body: { adminUsername?, adminPassword?, configDir? }
693
- * The generated password is returned in the response (store in config.json via /she/config).
694
714
  */
695
715
  router.post('/wizard/bootstrap', async (req, res) => {
696
716
  try {
@@ -702,47 +722,53 @@ router.post('/wizard/bootstrap', async (req, res) => {
702
722
 
703
723
  const username = req.body.adminUsername || 'she-admin';
704
724
  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
- }
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
+ }
723
740
 
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);
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
+ }
727
755
  } else {
756
+ // Local mode: run mosquitto_ctrl on this host.
728
757
  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;
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
+ }
738
765
 
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 {
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);
746
772
  mosquittoConf.write(confFilePath, content);
747
773
  }
748
774
  }