smart-home-engine 1.1.5 → 1.1.9

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-BcELVSd4.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-BW2J83t5.js";import{t as I}from"./index-CZNc_m6c.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-BcELVSd4.js"></script>
175
+ <script type="module" crossorigin src="/assets/index-CZNc_m6c.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-iTrR9H4f.css">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.1.5",
3
+ "version": "1.1.9",
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/index.js CHANGED
@@ -119,6 +119,7 @@ if (typeof config.port !== 'undefined') {
119
119
  log.error('http server start failed:', err.message);
120
120
  process.exit(1);
121
121
  });
122
+ require('./web/broker-api').setLogger(log);
122
123
  }
123
124
 
124
125
  const chokidar = require('chokidar');
@@ -145,6 +146,7 @@ const scheduler = modules['node-schedule'];
145
146
  const StateStore = require('./lib/state-store');
146
147
  const sandboxModules = [];
147
148
  const store = new StateStore();
149
+ if (typeof config.port !== 'undefined') require('./web/broker-api').setStore(store);
148
150
  const scripts = {};
149
151
  const scriptOrigins = new Map(); // file → 'builtin' | 'user'
150
152
  const subscriptions = [];
@@ -360,6 +362,7 @@ if (config.url) {
360
362
  log.info('mqtt connected ' + config.url);
361
363
  log.debug('mqtt subscribe #');
362
364
  mqtt.subscribe('#');
365
+ mqtt.subscribe('$SYS/#');
363
366
  mqttEventCallbacks.filter((c) => c.event === 'connect').forEach((c) => c.callback());
364
367
 
365
368
  if (!_started) {
package/src/lib/dynsec.js CHANGED
@@ -39,11 +39,20 @@ function _drain() {
39
39
  const { command, payload, resolve, reject } = _queue.shift();
40
40
  _inflight = true;
41
41
 
42
+ const safePayload = { ...payload };
43
+ if ('password' in safePayload) safePayload.password = '***';
44
+ if (_log) _log.debug(`dynsec: → sending "${command}"`, JSON.stringify(safePayload));
45
+
42
46
  const timer = setTimeout(() => {
43
47
  _inflight = false;
44
48
  _inflightResolve = null;
49
+ if (_log) _log.warn(`dynsec: timeout waiting for response to "${command}" (${_timeout}ms) — is the dynsec plugin loaded and the admin user configured?`);
45
50
  reject(new Error(`dynsec timeout waiting for response to "${command}"`));
46
- _drain();
51
+ // Fail-fast: reject all remaining queued commands since the broker is not responding
52
+ while (_queue.length > 0) {
53
+ const queued = _queue.shift();
54
+ queued.reject(new Error(`dynsec: aborting "${queued.command}" — previous command timed out`));
55
+ }
47
56
  }, _timeout);
48
57
 
49
58
  _inflightResolve = (responses) => {
@@ -52,8 +61,10 @@ function _drain() {
52
61
  _inflightResolve = null;
53
62
  const r = responses.find((resp) => resp.command === command);
54
63
  if (r && r.error) {
64
+ if (_log) _log.debug(`dynsec: ✕ "${command}" error: ${r.error}`);
55
65
  reject(new Error(r.error));
56
66
  } else {
67
+ if (_log) _log.debug(`dynsec: ✓ "${command}" ok`);
57
68
  resolve(r || {});
58
69
  }
59
70
  _drain();
@@ -67,8 +78,12 @@ function _request(command, payload = {}) {
67
78
  return Promise.reject(new Error('she.broker: dynsec not configured — set broker.dynsec in config.json'));
68
79
  }
69
80
  if (!_connected) {
81
+ if (_log) _log.debug(`dynsec: request "${command}" rejected — not connected (queue length: ${_queue.length})`);
70
82
  return Promise.reject(new Error('she.broker: dynsec not connected'));
71
83
  }
84
+ const safePayload = { ...payload };
85
+ if ('password' in safePayload) safePayload.password = '***';
86
+ if (_log) _log.debug(`dynsec: queuing "${command}" (queue length: ${_queue.length}, inflight: ${_inflight})`, JSON.stringify(safePayload));
72
87
  return new Promise((resolve, reject) => {
73
88
  _queue.push({ command, payload, resolve, reject });
74
89
  _drain();
@@ -111,11 +126,15 @@ function init(config, log) {
111
126
  _client = mqtt.connect(config.url, opts);
112
127
 
113
128
  _client.on('connect', () => {
114
- _connected = true;
115
- _log.info('dynsec: connected as', dynsecCfg.adminUsername);
129
+ _log.info('dynsec: MQTT connect event, subscribing to response topic');
116
130
  _client.subscribe(RESPONSE_TOPIC, (err) => {
117
- if (err) _log.error('dynsec: failed to subscribe to response topic:', err.message);
118
- else _drain(); // flush any requests queued before connection
131
+ if (err) {
132
+ _log.error('dynsec: failed to subscribe to response topic:', err.message);
133
+ } else {
134
+ _connected = true;
135
+ _log.info('dynsec: ready — subscribed as', dynsecCfg.adminUsername);
136
+ _drain(); // flush any requests queued before connection
137
+ }
119
138
  });
120
139
  });
121
140
 
@@ -139,8 +158,12 @@ function init(config, log) {
139
158
  _log.error('dynsec: invalid JSON on response topic');
140
159
  return;
141
160
  }
161
+ const cmds = Array.isArray(msg.responses) ? msg.responses.map((r) => r.command).join(', ') : 'none';
162
+ if (_log) _log.debug(`dynsec: ← response received, commands: [${cmds}]`);
142
163
  if (_inflightResolve && Array.isArray(msg.responses)) {
143
164
  _inflightResolve(msg.responses);
165
+ } else if (!_inflightResolve) {
166
+ if (_log) _log.debug(`dynsec: unexpected response (no inflight request), commands: [${cmds}]`);
144
167
  }
145
168
  });
146
169
  }
@@ -16,6 +16,7 @@
16
16
  const express = require('express');
17
17
  const path = require('path');
18
18
  const fs = require('fs');
19
+ const crypto = require('crypto');
19
20
  const dynsec = require('../lib/dynsec');
20
21
  const mosquittoConf = require('../lib/mosquitto-conf');
21
22
  const ca = require('../lib/ca');
@@ -27,6 +28,19 @@ const DEFAULT_SSH_KEY = path.join(sheConfig['data-dir'], 'ssh', 'broker_id_ed255
27
28
 
28
29
  const router = express.Router();
29
30
 
31
+ let _log = null;
32
+ let _store = null;
33
+
34
+ /** Must be called once from index.js so broker-api can emit debug-level log lines. */
35
+ function setLogger(log) {
36
+ _log = log;
37
+ }
38
+
39
+ /** Must be called once from index.js to give broker-api access to the MQTT state store. */
40
+ function setStore(store) {
41
+ _store = store;
42
+ }
43
+
30
44
  // ── Helpers ────────────────────────────────────────────────────────────────────
31
45
 
32
46
  /** Get broker config from live config.json */
@@ -64,13 +78,14 @@ function handleError(res, err) {
64
78
  */
65
79
  router.get('/status', (req, res) => {
66
80
  const ds = dynsec.getStatus();
67
- const mqttState = req.app.locals.mqttState || {};
68
81
 
69
- const sysPrefixes = ['$SYS/broker/version', '$SYS/broker/clients/', '$SYS/broker/uptime'];
70
82
  const sys = {};
71
- for (const [topic, entry] of Object.entries(mqttState)) {
72
- if (sysPrefixes.some((p) => topic.startsWith(p))) {
73
- sys[topic] = entry;
83
+ if (_store) {
84
+ const sysPrefixes = ['$SYS/broker/version', '$SYS/broker/uptime', '$SYS/broker/clients/', '$SYS/broker/messages/'];
85
+ for (const [topic, entry] of _store.mqttEntries()) {
86
+ if (sysPrefixes.some((p) => topic.startsWith(p))) {
87
+ sys[topic] = entry;
88
+ }
74
89
  }
75
90
  }
76
91
 
@@ -82,14 +97,22 @@ router.get('/status', (req, res) => {
82
97
  /**
83
98
  * GET /she/broker/config
84
99
  * Returns parsed config structure + raw text + checksum.
100
+ * In remote mode, reads mosquitto.conf from the broker host via SSH.
85
101
  */
86
- router.get('/config', (req, res) => {
102
+ router.get('/config', async (req, res) => {
87
103
  try {
88
104
  const bc = getBrokerConfig(req);
89
105
  const fp = confPath(bc);
106
+ const backups = mosquittoConf.listBackups(fp).map((b) => path.basename(b));
107
+ if (bc.ssh && bc.ssh.host) {
108
+ _log?.debug(`broker: reading remote config from ${bc.ssh.host}:${fp}`);
109
+ const raw = await sshDeploy.readRemoteFile(bc.ssh, fp);
110
+ const parsed = mosquittoConf.parseText(raw);
111
+ const cs = crypto.createHash('sha256').update(raw).digest('hex');
112
+ return res.json({ ...parsed, checksum: cs, backups });
113
+ }
90
114
  const parsed = mosquittoConf.parse(fp);
91
115
  const cs = mosquittoConf.checksum(fp);
92
- const backups = mosquittoConf.listBackups(fp).map((b) => path.basename(b));
93
116
  res.json({ ...parsed, checksum: cs, backups });
94
117
  } catch (err) {
95
118
  handleError(res, err);
@@ -100,17 +123,25 @@ router.get('/config', (req, res) => {
100
123
  * PUT /she/broker/config
101
124
  * Write structured config (body: { listeners, managed, passthrough, checksum? }).
102
125
  * body.checksum is the client's last-known checksum for external-modify detection.
126
+ * In remote mode, the file is also deployed to the broker host via SCP.
103
127
  */
104
- router.put('/config', (req, res) => {
128
+ router.put('/config', async (req, res) => {
105
129
  try {
106
130
  const bc = getBrokerConfig(req);
107
131
  const fp = confPath(bc);
108
132
  const { listeners, managed, passthrough, checksum: clientChecksum } = req.body;
109
133
  const content = mosquittoConf.serialise({ listeners, managed, passthrough });
110
- const result = mosquittoConf.write(fp, content, clientChecksum ?? null);
134
+ const remote = bc.ssh && bc.ssh.host;
135
+ _log?.info(`broker: writing config to ${remote ? `remote ${bc.ssh.host}:${fp}` : `local ${fp}`}`);
136
+ // In remote mode skip local conflict check — remote is the source of truth
137
+ const result = mosquittoConf.write(fp, content, remote ? null : (clientChecksum ?? null));
111
138
  if (!result.ok) {
112
139
  return res.status(409).json({ error: 'external_modify', message: 'mosquitto.conf was modified externally since last read' });
113
140
  }
141
+ if (remote) {
142
+ _log?.debug(`broker: uploading config to ${bc.ssh.host}:${fp}`);
143
+ await sshDeploy.uploadContent(bc.ssh, content, fp);
144
+ }
114
145
  _lastWriteChecksum.set(fp, mosquittoConf.checksum(fp));
115
146
  res.json({ ok: true, backupPath: result.backupPath ? path.basename(result.backupPath) : null });
116
147
  } catch (err) {
@@ -121,8 +152,9 @@ router.put('/config', (req, res) => {
121
152
  /**
122
153
  * PUT /she/broker/config/raw
123
154
  * Write raw mosquitto.conf text directly (used by the Advanced editor).
155
+ * In remote mode, the file is also deployed to the broker host via SCP.
124
156
  */
125
- router.put('/config/raw', (req, res) => {
157
+ router.put('/config/raw', async (req, res) => {
126
158
  try {
127
159
  const bc = getBrokerConfig(req);
128
160
  const fp = confPath(bc);
@@ -130,10 +162,17 @@ router.put('/config/raw', (req, res) => {
130
162
  if (typeof content !== 'string') {
131
163
  return res.status(400).json({ error: 'content must be a string' });
132
164
  }
133
- const result = mosquittoConf.write(fp, content, clientChecksum ?? null);
165
+ const remote = bc.ssh && bc.ssh.host;
166
+ _log?.info(`broker: writing raw config to ${remote ? `remote ${bc.ssh.host}:${fp}` : `local ${fp}`}`);
167
+ // In remote mode skip local conflict check — remote is the source of truth
168
+ const result = mosquittoConf.write(fp, content, remote ? null : (clientChecksum ?? null));
134
169
  if (!result.ok) {
135
170
  return res.status(409).json({ error: 'external_modify', message: 'mosquitto.conf was modified externally since last read' });
136
171
  }
172
+ if (remote) {
173
+ _log?.debug(`broker: uploading raw config to ${bc.ssh.host}:${fp}`);
174
+ await sshDeploy.uploadContent(bc.ssh, content, fp);
175
+ }
137
176
  _lastWriteChecksum.set(fp, mosquittoConf.checksum(fp));
138
177
  res.json({ ok: true, backupPath: result.backupPath ? path.basename(result.backupPath) : null });
139
178
  } catch (err) {
@@ -191,12 +230,18 @@ router.post('/reload', async (req, res) => {
191
230
  try {
192
231
  const bc = getBrokerConfig(req);
193
232
  if (bc.ssh && bc.ssh.host) {
233
+ const cmd = bc.reloadCmd || 'sudo systemctl reload mosquitto';
234
+ _log?.debug(`broker: remote reload on ${bc.ssh.host}: ${cmd}`);
194
235
  const result = await sshDeploy.runCommand(bc.ssh, cmd);
236
+ _log?.debug(`broker: remote reload stdout=${result.stdout} stderr=${result.stderr}`);
195
237
  return res.json({ ok: true, ...result });
196
238
  }
239
+ _log?.debug('broker: local reload mosquitto');
197
240
  const result = await mosquittoConf.reload(bc);
241
+ _log?.debug(`broker: local reload stdout=${result.stdout} stderr=${result.stderr}`);
198
242
  res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
199
243
  } catch (err) {
244
+ _log?.debug(`broker: reload error: ${err.message}`);
200
245
  handleError(res, err);
201
246
  }
202
247
  });
@@ -211,12 +256,17 @@ router.post('/restart', async (req, res) => {
211
256
  const bc = getBrokerConfig(req);
212
257
  if (bc.ssh && bc.ssh.host) {
213
258
  const cmd = bc.restartCmd || 'sudo systemctl restart mosquitto';
259
+ _log?.debug(`broker: remote restart on ${bc.ssh.host}: ${cmd}`);
214
260
  const result = await sshDeploy.runCommand(bc.ssh, cmd);
261
+ _log?.debug(`broker: remote restart stdout=${result.stdout} stderr=${result.stderr}`);
215
262
  return res.json({ ok: true, ...result });
216
263
  }
264
+ _log?.debug('broker: local restart mosquitto');
217
265
  const result = await mosquittoConf.restart(bc);
266
+ _log?.debug(`broker: local restart stdout=${result.stdout} stderr=${result.stderr}`);
218
267
  res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
219
268
  } catch (err) {
269
+ _log?.debug(`broker: restart error: ${err.message}`);
220
270
  handleError(res, err);
221
271
  }
222
272
  });
@@ -655,7 +705,7 @@ router.delete('/ca/trusted/:fingerprint', async (req, res) => {
655
705
  }
656
706
  });
657
707
 
658
- module.exports = { router };
708
+ module.exports = { router, setLogger, setStore };
659
709
 
660
710
  // ── SSH routes ─────────────────────────────────────────────────────────────────
661
711
  // Note: these routes are mounted on the same router but defined after module.exports
@@ -680,9 +730,12 @@ router.post('/ssh/keygen', async (req, res) => {
680
730
  try {
681
731
  const bc = getBrokerConfig(req);
682
732
  const identityFile = (bc.ssh && bc.ssh.identityFile) || DEFAULT_SSH_KEY;
733
+ _log?.debug(`broker: generating SSH keypair at ${identityFile}`);
683
734
  const publicKey = await sshDeploy.generateKeypair(identityFile);
735
+ _log?.debug('broker: SSH keypair generated ok');
684
736
  res.json({ ok: true, publicKey });
685
737
  } catch (err) {
738
+ _log?.debug(`broker: SSH keygen error: ${err.message}`);
686
739
  handleError(res, err);
687
740
  }
688
741
  });
@@ -692,9 +745,14 @@ router.post('/ssh/test', async (req, res) => {
692
745
  try {
693
746
  const bc = getBrokerConfig(req);
694
747
  if (!bc.ssh || !bc.ssh.host) return res.status(400).json({ error: 'broker.ssh.host not configured' });
748
+ const user = (bc.ssh && bc.ssh.user) || require('os').userInfo().username;
749
+ const key = sshDeploy.expandHome((bc.ssh && bc.ssh.identityFile) || DEFAULT_SSH_KEY);
750
+ _log?.debug(`broker: testing SSH to ${user}@${bc.ssh.host}:${bc.ssh.port || 22} key=${key}`);
695
751
  await sshDeploy.testConnection(bc.ssh);
752
+ _log?.debug(`broker: SSH connection to ${bc.ssh.host} ok`);
696
753
  res.json({ ok: true });
697
754
  } catch (err) {
755
+ _log?.debug(`broker: SSH test to ${bc.ssh && bc.ssh.host} failed: ${err.message}`);
698
756
  res.json({ ok: false, error: err.message });
699
757
  }
700
758
  });
@@ -741,11 +799,17 @@ router.post('/wizard/bootstrap', async (req, res) => {
741
799
  const dynSecPath = `${configDir}/dynamic-security.json`;
742
800
  const confFilePath = `${configDir}/mosquitto.conf`;
743
801
 
802
+ _log?.debug(`broker: wizard bootstrap mode=${isRemote ? 'remote' : 'local'} configDir=${configDir} adminUser=${username}`);
803
+
744
804
  if (isRemote) {
745
805
  // mosquitto_ctrl must run on the broker host — invoke it via SSH.
806
+ const ctrlCmd = `mosquitto_ctrl dynsec init "${dynSecPath}" "${username}" "${password}"`;
807
+ _log?.debug(`broker: SSH mosquitto_ctrl on ${bc.ssh.host}: mosquitto_ctrl dynsec init "${dynSecPath}" "${username}" ***`);
746
808
  try {
747
- await sshDeploy.runCommand(bc.ssh, `mosquitto_ctrl dynsec init "${dynSecPath}" "${username}" "${password}"`);
809
+ const r = await sshDeploy.runCommand(bc.ssh, ctrlCmd);
810
+ _log?.debug(`broker: mosquitto_ctrl ok stdout=${r.stdout} stderr=${r.stderr}`);
748
811
  } catch (err) {
812
+ _log?.debug(`broker: mosquitto_ctrl SSH failed: ${err.message}`);
749
813
  return res.status(500).json({
750
814
  error: `mosquitto_ctrl failed on remote host: ${err.message}. Ensure mosquitto is installed on the remote broker host.`,
751
815
  });
@@ -754,23 +818,32 @@ router.post('/wizard/bootstrap', async (req, res) => {
754
818
  // Read the remote mosquitto.conf, parse, and add the plugin line if missing.
755
819
  let remoteConfRaw = '';
756
820
  try {
821
+ _log?.debug(`broker: reading remote conf ${bc.ssh.host}:${confFilePath}`);
757
822
  remoteConfRaw = await sshDeploy.readRemoteFile(bc.ssh, confFilePath);
758
- } catch {
759
- // File may not exist yet — start from an empty config
823
+ _log?.debug(`broker: remote conf read ok (${remoteConfRaw.length} bytes)`);
824
+ } catch (e) {
825
+ _log?.debug(`broker: remote conf read failed (${e.message}), starting from empty config`);
760
826
  }
761
827
  const parsed = mosquittoConf.parseText(remoteConfRaw);
762
828
  if (!parsed.managed.plugin || !String(parsed.managed.plugin).includes('mosquitto_dynamic_security')) {
763
829
  parsed.managed.plugin = 'mosquitto_dynamic_security.so';
764
830
  parsed.managed.plugin_opt_dynsec_config_file = dynSecPath;
765
831
  const content = mosquittoConf.serialise(parsed);
832
+ _log?.debug(`broker: uploading updated conf to ${bc.ssh.host}:${confFilePath}`);
766
833
  await sshDeploy.uploadContent(bc.ssh, content, confFilePath);
834
+ _log?.debug('broker: conf upload ok');
835
+ } else {
836
+ _log?.debug('broker: plugin line already present in remote conf, skipping upload');
767
837
  }
768
838
  } else {
769
839
  // Local mode: run mosquitto_ctrl on this host.
770
840
  fs.mkdirSync(configDir, { recursive: true });
841
+ _log?.debug(`broker: local mosquitto_ctrl dynsec init ${dynSecPath} ${username} ***`);
771
842
  try {
772
- await execFileAsync('mosquitto_ctrl', ['dynsec', 'init', dynSecPath, username, password], { timeout: 10000 });
843
+ const r = await execFileAsync('mosquitto_ctrl', ['dynsec', 'init', dynSecPath, username, password], { timeout: 10000 });
844
+ _log?.debug(`broker: mosquitto_ctrl ok stdout=${r.stdout} stderr=${r.stderr}`);
773
845
  } catch (err) {
846
+ _log?.debug(`broker: local mosquitto_ctrl failed: ${err.message}`);
774
847
  return res.status(500).json({
775
848
  error: `mosquitto_ctrl failed: ${err.message}. Ensure mosquitto is installed on this host.`,
776
849
  });
@@ -782,7 +855,11 @@ router.post('/wizard/bootstrap', async (req, res) => {
782
855
  parsed.managed.plugin = 'mosquitto_dynamic_security.so';
783
856
  parsed.managed.plugin_opt_dynsec_config_file = dynSecPath;
784
857
  const content = mosquittoConf.serialise(parsed);
858
+ _log?.debug(`broker: writing updated local conf to ${confFilePath}`);
785
859
  mosquittoConf.write(confFilePath, content);
860
+ _log?.debug('broker: local conf write ok');
861
+ } else {
862
+ _log?.debug('broker: plugin line already present in local conf, skipping write');
786
863
  }
787
864
  }
788
865