smart-home-engine 1.0.10 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * dynsec — Dynamic Security plugin client for Mosquitto.
5
+ *
6
+ * Creates a dedicated MQTT connection using the she-admin credentials from
7
+ * config.broker.dynsec.{adminUsername, adminPassword}. All dynsec commands
8
+ * are serialised through a single-inflight request queue so concurrent calls
9
+ * do not confuse the response-correlation logic (the dynsec protocol has no
10
+ * request IDs).
11
+ *
12
+ * Usage:
13
+ * const dynsec = require('./dynsec');
14
+ * dynsec.init(config, log);
15
+ *
16
+ * const { connected } = dynsec.getStatus();
17
+ * const users = await dynsec.listClients(true);
18
+ */
19
+
20
+ const mqtt = require('mqtt');
21
+
22
+ const CONTROL_TOPIC = '$CONTROL/dynamic-security/v1';
23
+ const RESPONSE_TOPIC = '$CONTROL/dynamic-security/v1/response';
24
+
25
+ let _client = null;
26
+ let _connected = false;
27
+ let _configured = false;
28
+ let _timeout = 5000;
29
+ let _log = null;
30
+
31
+ // Serial request queue — one in-flight request at a time
32
+ const _queue = [];
33
+ let _inflight = false;
34
+ let _inflightResolve = null;
35
+
36
+ function _drain() {
37
+ if (_inflight || _queue.length === 0 || !_connected) return;
38
+
39
+ const { command, payload, resolve, reject } = _queue.shift();
40
+ _inflight = true;
41
+
42
+ const timer = setTimeout(() => {
43
+ _inflight = false;
44
+ _inflightResolve = null;
45
+ reject(new Error(`dynsec timeout waiting for response to "${command}"`));
46
+ _drain();
47
+ }, _timeout);
48
+
49
+ _inflightResolve = (responses) => {
50
+ clearTimeout(timer);
51
+ _inflight = false;
52
+ _inflightResolve = null;
53
+ const r = responses.find((resp) => resp.command === command);
54
+ if (r && r.error) {
55
+ reject(new Error(r.error));
56
+ } else {
57
+ resolve(r || {});
58
+ }
59
+ _drain();
60
+ };
61
+
62
+ _client.publish(CONTROL_TOPIC, JSON.stringify({ commands: [{ command, ...payload }] }));
63
+ }
64
+
65
+ function _request(command, payload = {}) {
66
+ if (!_configured) {
67
+ return Promise.reject(new Error('she.broker: dynsec not configured — set broker.dynsec in config.json'));
68
+ }
69
+ if (!_connected) {
70
+ return Promise.reject(new Error('she.broker: dynsec not connected'));
71
+ }
72
+ return new Promise((resolve, reject) => {
73
+ _queue.push({ command, payload, resolve, reject });
74
+ _drain();
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Initialise the dynsec client. No-op if broker.dynsec is not set in config.
80
+ * @param {object} config - she config object
81
+ * @param {object} log - she log object
82
+ */
83
+ function init(config, log) {
84
+ _log = log;
85
+ _timeout = (config.broker && config.broker.apiTimeout) || 5000;
86
+
87
+ const dynsecCfg = config.broker && config.broker.dynsec;
88
+ if (!dynsecCfg || !dynsecCfg.adminUsername || !dynsecCfg.adminPassword) {
89
+ _log.debug('dynsec: not configured, she.broker API disabled');
90
+ return;
91
+ }
92
+ if (!config.url) {
93
+ _log.warn('dynsec: no broker URL configured, she.broker API disabled');
94
+ return;
95
+ }
96
+
97
+ _configured = true;
98
+
99
+ const opts = {
100
+ username: dynsecCfg.adminUsername,
101
+ password: dynsecCfg.adminPassword,
102
+ clientId: 'she-dynsec-' + Math.random().toString(16).slice(2, 10),
103
+ clean: true,
104
+ };
105
+ // Inherit TLS options from main config so dynsec works over TLS-secured brokers
106
+ if (config.mqttCa) opts.ca = config.mqttCa;
107
+ if (config.mqttCert) opts.cert = config.mqttCert;
108
+ if (config.mqttKey) opts.key = config.mqttKey;
109
+ if (config.mqttVersion === '5') opts.protocolVersion = 5;
110
+
111
+ _client = mqtt.connect(config.url, opts);
112
+
113
+ _client.on('connect', () => {
114
+ _connected = true;
115
+ _log.info('dynsec: connected as', dynsecCfg.adminUsername);
116
+ _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
119
+ });
120
+ });
121
+
122
+ _client.on('close', () => {
123
+ if (_connected) {
124
+ _connected = false;
125
+ _log.warn('dynsec: disconnected');
126
+ }
127
+ });
128
+
129
+ _client.on('error', (err) => {
130
+ _log.error('dynsec: MQTT error:', err.message);
131
+ });
132
+
133
+ _client.on('message', (topic, payload) => {
134
+ if (topic !== RESPONSE_TOPIC) return;
135
+ let msg;
136
+ try {
137
+ msg = JSON.parse(payload.toString());
138
+ } catch {
139
+ _log.error('dynsec: invalid JSON on response topic');
140
+ return;
141
+ }
142
+ if (_inflightResolve && Array.isArray(msg.responses)) {
143
+ _inflightResolve(msg.responses);
144
+ }
145
+ });
146
+ }
147
+
148
+ /** @returns {{ connected: boolean, configured: boolean }} */
149
+ function getStatus() {
150
+ return { connected: _connected, configured: _configured };
151
+ }
152
+
153
+ // ── User management ────────────────────────────────────────────────────────────
154
+
155
+ function createClient(username, password, options = {}) {
156
+ return _request('createClient', { username, password, ...options });
157
+ }
158
+
159
+ function deleteClient(username) {
160
+ return _request('deleteClient', { username });
161
+ }
162
+
163
+ function setClientPassword(username, password) {
164
+ return _request('modifyClient', { username, password });
165
+ }
166
+
167
+ function listClients(verbose = false) {
168
+ return _request('listClients', { verbose }).then((r) => r.clients || []);
169
+ }
170
+
171
+ function getClient(username) {
172
+ return _request('getClient', { username }).then((r) => r.client);
173
+ }
174
+
175
+ // ── Role management ────────────────────────────────────────────────────────────
176
+
177
+ function createRole(rolename, options = {}) {
178
+ return _request('createRole', { rolename, ...options });
179
+ }
180
+
181
+ function deleteRole(rolename) {
182
+ return _request('deleteRole', { rolename });
183
+ }
184
+
185
+ function listRoles(verbose = false) {
186
+ return _request('listRoles', { verbose }).then((r) => r.roles || []);
187
+ }
188
+
189
+ function getRole(rolename) {
190
+ return _request('getRole', { rolename }).then((r) => r.role);
191
+ }
192
+
193
+ /**
194
+ * @param {string} rolename
195
+ * @param {string} acltype 'publishClientSend'|'publishClientReceive'|
196
+ * 'subscribeLiteral'|'subscribePattern'|
197
+ * 'unsubscribeLiteral'|'unsubscribePattern'
198
+ * @param {string} topic
199
+ * @param {boolean} allow
200
+ * @param {number} [priority=-1]
201
+ */
202
+ function addRoleACL(rolename, acltype, topic, allow, priority = -1) {
203
+ return _request('addRoleACL', { rolename, acltype, topic, allow, priority });
204
+ }
205
+
206
+ function removeRoleACL(rolename, acltype, topic) {
207
+ return _request('removeRoleACL', { rolename, acltype, topic });
208
+ }
209
+
210
+ // ── Role ↔ client assignment ───────────────────────────────────────────────────
211
+
212
+ function addClientRole(username, rolename, priority = -1) {
213
+ return _request('addClientRole', { username, rolename, priority });
214
+ }
215
+
216
+ function removeClientRole(username, rolename) {
217
+ return _request('removeClientRole', { username, rolename });
218
+ }
219
+
220
+ // ── Group management ───────────────────────────────────────────────────────────
221
+
222
+ function createGroup(groupname) {
223
+ return _request('createGroup', { groupname });
224
+ }
225
+
226
+ function deleteGroup(groupname) {
227
+ return _request('deleteGroup', { groupname });
228
+ }
229
+
230
+ function listGroups(verbose = false) {
231
+ return _request('listGroups', { verbose }).then((r) => r.groups || []);
232
+ }
233
+
234
+ function getGroup(groupname) {
235
+ return _request('getGroup', { groupname }).then((r) => r.group);
236
+ }
237
+
238
+ function addGroupClient(groupname, username, priority = -1) {
239
+ return _request('addGroupClient', { groupname, username, priority });
240
+ }
241
+
242
+ function removeGroupClient(groupname, username) {
243
+ return _request('removeGroupClient', { groupname, username });
244
+ }
245
+
246
+ function addGroupRole(groupname, rolename, priority = -1) {
247
+ return _request('addGroupRole', { groupname, rolename, priority });
248
+ }
249
+
250
+ function removeGroupRole(groupname, rolename) {
251
+ return _request('removeGroupRole', { groupname, rolename });
252
+ }
253
+
254
+ // ── Default ACL access ─────────────────────────────────────────────────────────
255
+
256
+ function getDefaultACLAccess() {
257
+ return _request('getDefaultACLAccess').then((r) => r.acls || []);
258
+ }
259
+
260
+ function setDefaultACLAccess(acls) {
261
+ return _request('setDefaultACLAccess', { acls });
262
+ }
263
+
264
+ module.exports = {
265
+ init,
266
+ getStatus,
267
+ // Users
268
+ createClient,
269
+ deleteClient,
270
+ setClientPassword,
271
+ listClients,
272
+ getClient,
273
+ // Roles
274
+ createRole,
275
+ deleteRole,
276
+ listRoles,
277
+ getRole,
278
+ addRoleACL,
279
+ removeRoleACL,
280
+ // Role assignments
281
+ addClientRole,
282
+ removeClientRole,
283
+ // Groups
284
+ createGroup,
285
+ deleteGroup,
286
+ listGroups,
287
+ getGroup,
288
+ addGroupClient,
289
+ removeGroupClient,
290
+ addGroupRole,
291
+ removeGroupRole,
292
+ // Default ACLs
293
+ getDefaultACLAccess,
294
+ setDefaultACLAccess,
295
+ };
@@ -0,0 +1,287 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * mosquitto-conf — parser, writer and reload helper for mosquitto.conf.
5
+ *
6
+ * she "owns" a single managed config file (path configured at
7
+ * config.broker.configDir + '/mosquitto.conf'). It reads the file, merges
8
+ * managed sections, and writes it back with a timestamped backup.
9
+ *
10
+ * Managed keys handled via structured API:
11
+ * - listener blocks (each keyed by port)
12
+ * - plugin (dynsec)
13
+ * - log_dest / log_type
14
+ * - persistence / persistence_location
15
+ * - allow_anonymous
16
+ *
17
+ * Everything else is preserved verbatim in the "advanced" passthrough block.
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const crypto = require('crypto');
23
+ const { execFile } = require('child_process');
24
+ const { promisify } = require('util');
25
+
26
+ const execFileAsync = promisify(execFile);
27
+
28
+ // Keys that she manages — all others are treated as passthrough
29
+ const MANAGED_SINGLE_KEYS = new Set(['allow_anonymous', 'persistence', 'persistence_location', 'log_dest', 'log_type', 'plugin', 'plugin_opt_dynsec_config_file']);
30
+
31
+ /**
32
+ * Parse a mosquitto.conf file into a structured object.
33
+ *
34
+ * @param {string} filePath
35
+ * @returns {{ listeners: object[], managed: object, passthrough: string[], raw: string }}
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
+
46
+ const lines = raw.split('\n');
47
+ const managed = {};
48
+ const listeners = [];
49
+ const passthrough = [];
50
+ let currentListener = null;
51
+
52
+ for (const line of lines) {
53
+ const trimmed = line.trim();
54
+ // blank lines and comments go to passthrough
55
+ if (!trimmed || trimmed.startsWith('#')) {
56
+ passthrough.push(line);
57
+ continue;
58
+ }
59
+
60
+ const spaceIdx = trimmed.indexOf(' ');
61
+ if (spaceIdx === -1) {
62
+ passthrough.push(line);
63
+ continue;
64
+ }
65
+
66
+ const key = trimmed.slice(0, spaceIdx).trim();
67
+ const value = trimmed.slice(spaceIdx + 1).trim();
68
+
69
+ if (key === 'listener') {
70
+ const parts = value.split(/\s+/);
71
+ currentListener = {
72
+ port: parseInt(parts[0], 10),
73
+ bindAddress: parts[1] || '',
74
+ protocol: 'mqtt',
75
+ tls: {},
76
+ };
77
+ listeners.push(currentListener);
78
+ } else if (currentListener && isListenerSubkey(key)) {
79
+ applyListenerKey(currentListener, key, value);
80
+ } else if (MANAGED_SINGLE_KEYS.has(key)) {
81
+ if (managed[key] !== undefined) {
82
+ // multi-value key (e.g. log_type) — convert to array
83
+ managed[key] = [].concat(managed[key]).concat(value);
84
+ } else {
85
+ managed[key] = value;
86
+ }
87
+ currentListener = null;
88
+ } else {
89
+ passthrough.push(line);
90
+ currentListener = null;
91
+ }
92
+ }
93
+
94
+ return { listeners, managed, passthrough, raw };
95
+ }
96
+
97
+ /** Keys that belong to a listener block */
98
+ function isListenerSubkey(key) {
99
+ return [
100
+ 'protocol',
101
+ 'socket_domain',
102
+ 'certfile',
103
+ 'keyfile',
104
+ 'cafile',
105
+ 'capath',
106
+ 'crlfile',
107
+ 'require_certificate',
108
+ 'use_identity_as_username',
109
+ 'tls_version',
110
+ 'websockets_log_level',
111
+ ].includes(key);
112
+ }
113
+
114
+ function applyListenerKey(listener, key, value) {
115
+ switch (key) {
116
+ case 'protocol':
117
+ listener.protocol = value;
118
+ break;
119
+ case 'certfile':
120
+ case 'keyfile':
121
+ case 'cafile':
122
+ case 'capath':
123
+ case 'crlfile':
124
+ case 'tls_version':
125
+ listener.tls[key] = value;
126
+ break;
127
+ case 'require_certificate':
128
+ listener.tls.require_certificate = value === 'true';
129
+ break;
130
+ case 'use_identity_as_username':
131
+ listener.tls.use_identity_as_username = value === 'true';
132
+ break;
133
+ default:
134
+ break;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Serialise the structured config back to mosquitto.conf text.
140
+ *
141
+ * @param {{ listeners: object[], managed: object, passthrough: string[] }} conf
142
+ * @returns {string}
143
+ */
144
+ function serialise(conf) {
145
+ const lines = [];
146
+
147
+ // Managed single-key entries first
148
+ const { managed = {}, listeners = [], passthrough = [] } = conf;
149
+
150
+ const keyOrder = ['allow_anonymous', 'persistence', 'persistence_location', 'log_dest', 'log_type', 'plugin', 'plugin_opt_dynsec_config_file'];
151
+ for (const key of keyOrder) {
152
+ if (managed[key] === undefined) continue;
153
+ const val = managed[key];
154
+ if (Array.isArray(val)) {
155
+ for (const v of val) lines.push(`${key} ${v}`);
156
+ } else {
157
+ lines.push(`${key} ${val}`);
158
+ }
159
+ }
160
+
161
+ if (lines.length > 0) lines.push('');
162
+
163
+ // Listener blocks
164
+ for (const l of listeners) {
165
+ const addr = l.bindAddress ? ` ${l.bindAddress}` : '';
166
+ lines.push(`listener ${l.port}${addr}`);
167
+ if (l.protocol && l.protocol !== 'mqtt') lines.push(`protocol ${l.protocol}`);
168
+ const tls = l.tls || {};
169
+ for (const tlsKey of ['certfile', 'keyfile', 'cafile', 'capath', 'crlfile', 'tls_version']) {
170
+ if (tls[tlsKey]) lines.push(`${tlsKey} ${tls[tlsKey]}`);
171
+ }
172
+ if (tls.require_certificate !== undefined) {
173
+ lines.push(`require_certificate ${tls.require_certificate ? 'true' : 'false'}`);
174
+ }
175
+ if (tls.use_identity_as_username !== undefined) {
176
+ lines.push(`use_identity_as_username ${tls.use_identity_as_username ? 'true' : 'false'}`);
177
+ }
178
+ lines.push('');
179
+ }
180
+
181
+ // Passthrough (comments, blanks, unmanaged keys)
182
+ for (const l of passthrough) lines.push(l);
183
+
184
+ return lines.join('\n');
185
+ }
186
+
187
+ /**
188
+ * SHA-256 checksum of a file path. Returns null if file does not exist.
189
+ */
190
+ function checksum(filePath) {
191
+ try {
192
+ const data = fs.readFileSync(filePath);
193
+ return crypto.createHash('sha256').update(data).digest('hex');
194
+ } catch (err) {
195
+ if (err.code === 'ENOENT') return null;
196
+ throw err;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Write config to disk with a timestamped backup.
202
+ * If knownChecksum is provided and the file has been modified externally,
203
+ * returns { ok: false, reason: 'external_modify' } instead of writing.
204
+ *
205
+ * @param {string} filePath
206
+ * @param {string} content
207
+ * @param {string|null} [knownChecksum]
208
+ * @returns {{ ok: boolean, backupPath?: string, reason?: string }}
209
+ */
210
+ function write(filePath, content, knownChecksum = null) {
211
+ if (knownChecksum !== null) {
212
+ const current = checksum(filePath);
213
+ if (current !== null && current !== knownChecksum) {
214
+ return { ok: false, reason: 'external_modify' };
215
+ }
216
+ }
217
+
218
+ // Create backup
219
+ let backupPath = null;
220
+ if (fs.existsSync(filePath)) {
221
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
222
+ backupPath = `${filePath}.bak-${ts}`;
223
+ fs.copyFileSync(filePath, backupPath);
224
+ }
225
+
226
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
227
+ fs.writeFileSync(filePath, content, 'utf8');
228
+
229
+ return { ok: true, backupPath };
230
+ }
231
+
232
+ /**
233
+ * List backup files for a given config path.
234
+ * @param {string} filePath
235
+ * @returns {string[]} sorted newest-first
236
+ */
237
+ function listBackups(filePath) {
238
+ const dir = path.dirname(filePath);
239
+ const base = path.basename(filePath);
240
+ let entries;
241
+ try {
242
+ entries = fs.readdirSync(dir);
243
+ } catch {
244
+ return [];
245
+ }
246
+ return entries
247
+ .filter((e) => e.startsWith(`${base}.bak-`))
248
+ .sort()
249
+ .reverse()
250
+ .map((e) => path.join(dir, e));
251
+ }
252
+
253
+ /**
254
+ * Restore a backup file over the live config.
255
+ * @param {string} backupPath
256
+ * @param {string} destPath
257
+ */
258
+ function restoreBackup(backupPath, destPath) {
259
+ fs.copyFileSync(backupPath, destPath);
260
+ }
261
+
262
+ /**
263
+ * Send SIGHUP to mosquitto (for local mode where she can signal the process).
264
+ * Falls back to running the configured reloadCmd.
265
+ *
266
+ * @param {object} brokerConfig - config.broker
267
+ * @returns {Promise<{stdout: string, stderr: string}>}
268
+ */
269
+ async function reload(brokerConfig) {
270
+ const cmd = brokerConfig.reloadCmd || 'sudo systemctl reload mosquitto';
271
+ const [bin, ...args] = cmd.split(/\s+/);
272
+ const result = await execFileAsync(bin, args, { timeout: 10000 });
273
+ return result;
274
+ }
275
+
276
+ /**
277
+ * Full restart of the mosquitto service.
278
+ * @param {object} brokerConfig
279
+ */
280
+ async function restart(brokerConfig) {
281
+ const cmd = brokerConfig.restartCmd || 'sudo systemctl restart mosquitto';
282
+ const [bin, ...args] = cmd.split(/\s+/);
283
+ const result = await execFileAsync(bin, args, { timeout: 15000 });
284
+ return result;
285
+ }
286
+
287
+ module.exports = { parse, serialise, checksum, write, listBackups, restoreBackup, reload, restart };