smart-home-engine 1.21.0 → 1.34.3
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 +6 -3
- package/dist/web/assets/index-BtBtSJng.js +271 -0
- package/dist/web/assets/index-CXLA5efM.css +1 -0
- package/dist/web/assets/{jsonMode-tUihOf0o.js → jsonMode-BRIqcXAV.js} +1 -1
- package/dist/web/assets/{monaco-langs-Dt8a9eeo.js → monaco-langs-DKJI_o9J.js} +1 -1
- package/dist/web/assets/{tsMode-BsFm8afo.js → tsMode-CFhaNPy5.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +1 -1
- package/service/install.sh +10 -1
- package/service/she-servicectl +638 -0
- package/service/smart-home-engine.service +1 -1
- package/src/index.js +46 -12
- package/src/lib/npm-registry.js +80 -0
- package/src/lib/secrets.js +320 -0
- package/src/lib/services-catalog.js +144 -0
- package/src/lib/services-host.js +275 -0
- package/src/lib/services-inventory.js +180 -0
- package/src/lib/ssh-deploy.js +13 -8
- package/src/web/scripts-api.js +1 -1
- package/src/web/secrets-api.js +61 -0
- package/src/web/server.js +12 -1
- package/src/web/services-api.js +1555 -0
- package/dist/web/assets/index-AcVonavW.js +0 -235
- package/dist/web/assets/index-BAau5K4k.css +0 -1
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Host drivers for xyz2mqtt service management (roadmap I4/I5).
|
|
5
|
+
*
|
|
6
|
+
* A driver runs the privileged helper `she-servicectl` on one host and is the
|
|
7
|
+
* only thing that differs between the she host itself (local: sudo + execFile)
|
|
8
|
+
* and remote hosts (ssh, I5). Everything above this layer — the API, the
|
|
9
|
+
* inventory correlation — only sees:
|
|
10
|
+
*
|
|
11
|
+
* driver.name host label
|
|
12
|
+
* driver.exec(args, {stdin}) → {stdout, stderr, code} (rejects HostError)
|
|
13
|
+
* driver.spawn(args) → ChildProcess (long-running: logs --follow)
|
|
14
|
+
*
|
|
15
|
+
* Errors carry `code`:
|
|
16
|
+
* HELPER_MISSING the helper is not installed on the host
|
|
17
|
+
* SUDO_DENIED sudoers does not allow the helper for this user
|
|
18
|
+
* HELPER_FAILED the helper rejected the call (exit 2, message in stderr)
|
|
19
|
+
* SSH_FAILED the ssh connection itself failed (exit 255: auth, host key, unreachable)
|
|
20
|
+
* EXEC_FAILED the underlying command failed (non-zero exit)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const { execFile, spawn } = require('child_process');
|
|
26
|
+
const sshDeploy = require('./ssh-deploy');
|
|
27
|
+
|
|
28
|
+
const HELPER = '/usr/local/bin/she-servicectl';
|
|
29
|
+
const HELPER_VERSION = 8; // must match VERSION in service/she-servicectl
|
|
30
|
+
/** Default identity for services.hosts[].ssh — one key for all managed hosts (I5). */
|
|
31
|
+
const DEFAULT_SERVICES_IDENTITY = '~/.she/ssh/services_id_ed25519';
|
|
32
|
+
/** The helper as shipped with this she version (copied to remote hosts by the deploy route). */
|
|
33
|
+
const HELPER_SOURCE = path.join(__dirname, '..', '..', 'service', 'she-servicectl');
|
|
34
|
+
|
|
35
|
+
class HostError extends Error {
|
|
36
|
+
constructor(code, message, extra = {}) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.code = code;
|
|
39
|
+
Object.assign(this, extra);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function classify(err, stdout, stderr, code, { ssh = false } = {}) {
|
|
44
|
+
const text = String(stderr || '').trim();
|
|
45
|
+
if (ssh && code === 255) return new HostError('SSH_FAILED', text || 'ssh connection failed');
|
|
46
|
+
if ((err && err.code === 'ENOENT') || code === 127 || /she-servicectl: (command )?not found|she-servicectl: No such file/i.test(text)) {
|
|
47
|
+
return new HostError('HELPER_MISSING', 'she-servicectl is not installed on this host');
|
|
48
|
+
}
|
|
49
|
+
if (/a password is required|not allowed to execute|not in the sudoers/i.test(text)) {
|
|
50
|
+
return new HostError('SUDO_DENIED', 'sudo does not allow she-servicectl for this user: ' + text);
|
|
51
|
+
}
|
|
52
|
+
if (/^she-servicectl: /.test(text) || code === 2) {
|
|
53
|
+
return new HostError('HELPER_FAILED', text.replace(/^she-servicectl: /, '') || 'helper rejected the call', { stdout, stderr: text, exitCode: code });
|
|
54
|
+
}
|
|
55
|
+
return new HostError('EXEC_FAILED', text || (err && err.message) || `exit ${code}`, { stdout, stderr: text, exitCode: code });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Driver for the host she runs on: `sudo -n she-servicectl …`.
|
|
60
|
+
* @param {{helper?: string, sudo?: boolean, name?: string, env?: object}} [opts]
|
|
61
|
+
*/
|
|
62
|
+
function createLocalDriver(opts = {}) {
|
|
63
|
+
const helper = opts.helper || HELPER;
|
|
64
|
+
const useSudo = opts.sudo !== false;
|
|
65
|
+
const name = opts.name || 'local';
|
|
66
|
+
// LC_ALL=C keeps systemctl/journalctl output parseable; sudo keeps only a few variables anyway
|
|
67
|
+
const env = { ...(opts.env || process.env), LC_ALL: 'C' };
|
|
68
|
+
|
|
69
|
+
function command(args) {
|
|
70
|
+
return useSudo ? ['sudo', ['-n', helper, ...args]] : [helper, [...args]];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function exec(args, { stdin, timeout = 30000 } = {}) {
|
|
74
|
+
if (!fs.existsSync(helper)) return Promise.reject(new HostError('HELPER_MISSING', 'she-servicectl is not installed on this host'));
|
|
75
|
+
const [cmd, argv] = command(args);
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const child = execFile(cmd, argv, { timeout, env, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
78
|
+
const code = err && typeof err.code === 'number' ? err.code : err ? null : 0;
|
|
79
|
+
if (err) return reject(classify(err, stdout, stderr, code));
|
|
80
|
+
resolve({ stdout, stderr, code: 0 });
|
|
81
|
+
});
|
|
82
|
+
if (stdin !== undefined) {
|
|
83
|
+
child.stdin.on('error', () => {});
|
|
84
|
+
child.stdin.end(stdin);
|
|
85
|
+
} else {
|
|
86
|
+
child.stdin.end();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function spawnHelper(args) {
|
|
92
|
+
const [cmd, argv] = command(args);
|
|
93
|
+
return spawn(cmd, argv, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { name, local: true, helperPath: helper, exec, spawn: spawnHelper };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Quote a word for a POSIX shell (the remote side runs the helper through the login shell). */
|
|
100
|
+
function shellQuote(s) {
|
|
101
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Driver for a remote host: `ssh <host> sudo -n she-servicectl …` with the system ssh/scp
|
|
106
|
+
* clients (BatchMode, accept-new — like the broker deploy). stdin is passed through.
|
|
107
|
+
* @param {{name: string, ssh: {host: string, port?: number, user?: string, identityFile?: string}}} hostCfg
|
|
108
|
+
* @param {{helper?: string, sudo?: boolean, defaultIdentity?: string, sshBin?: string, scpBin?: string, env?: object}} [opts]
|
|
109
|
+
*/
|
|
110
|
+
function createSshDriver(hostCfg, opts = {}) {
|
|
111
|
+
const helper = opts.helper || HELPER;
|
|
112
|
+
const useSudo = opts.sudo !== false;
|
|
113
|
+
const sshCfg = hostCfg.ssh || {};
|
|
114
|
+
const defaultIdentity = opts.defaultIdentity || DEFAULT_SERVICES_IDENTITY;
|
|
115
|
+
const sshBin = opts.sshBin || 'ssh';
|
|
116
|
+
const scpBin = opts.scpBin || 'scp';
|
|
117
|
+
const env = { ...(opts.env || process.env), LC_ALL: 'C' };
|
|
118
|
+
const name = hostCfg.name;
|
|
119
|
+
|
|
120
|
+
function remoteCommand(args) {
|
|
121
|
+
return [...(useSudo ? ['sudo', '-n'] : []), helper, ...args].map(shellQuote).join(' ');
|
|
122
|
+
}
|
|
123
|
+
function sshArgv(command) {
|
|
124
|
+
return [...sshDeploy.sshArgs(sshCfg, defaultIdentity), sshDeploy.sshTarget(sshCfg), command];
|
|
125
|
+
}
|
|
126
|
+
function runSsh(command, { stdin, timeout = 45000, maxBuffer = 16 * 1024 * 1024 } = {}) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const child = execFile(sshBin, sshArgv(command), { timeout, env, maxBuffer }, (err, stdout, stderr) => {
|
|
129
|
+
const code = err && typeof err.code === 'number' ? err.code : err ? null : 0;
|
|
130
|
+
if (err) return reject(classify(err, stdout, stderr, code, { ssh: true }));
|
|
131
|
+
resolve({ stdout, stderr, code: 0 });
|
|
132
|
+
});
|
|
133
|
+
child.stdin.on('error', () => {});
|
|
134
|
+
child.stdin.end(stdin === undefined ? '' : stdin);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** helper call */
|
|
139
|
+
function exec(args, options) {
|
|
140
|
+
return runSsh(remoteCommand(args), options);
|
|
141
|
+
}
|
|
142
|
+
/** long-running helper call (logs --follow) */
|
|
143
|
+
function spawnHelper(args) {
|
|
144
|
+
return spawn(sshBin, sshArgv(remoteCommand(args)), { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
145
|
+
}
|
|
146
|
+
/** arbitrary command on the host — only used by the helper deploy */
|
|
147
|
+
function run(command, options) {
|
|
148
|
+
return runSsh(command, options);
|
|
149
|
+
}
|
|
150
|
+
/** scp a local file to `remotePath` (relative to the SSH user's home unless absolute) */
|
|
151
|
+
function upload(localPath, remotePath) {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
// scp needs IPv6 literals in brackets: user@[fe80::1]:path
|
|
154
|
+
const target = sshDeploy.sshTarget(sshCfg);
|
|
155
|
+
const at = target.indexOf('@');
|
|
156
|
+
const hostPart = target.slice(at + 1);
|
|
157
|
+
const bracketed = hostPart.includes(':') && !hostPart.startsWith('[') ? target.slice(0, at + 1) + '[' + hostPart + ']' : target;
|
|
158
|
+
const argv = [...sshDeploy.scpArgs(sshCfg, defaultIdentity), localPath, bracketed + ':' + remotePath];
|
|
159
|
+
execFile(scpBin, argv, { timeout: 60000, env }, (err, stdout, stderr) => {
|
|
160
|
+
if (err) return reject(new HostError('SSH_FAILED', String(stderr || err.message).trim()));
|
|
161
|
+
resolve();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { name, local: false, helperPath: helper, target: sshDeploy.sshTarget(sshCfg), exec, spawn: spawnHelper, run, upload };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Parse the JSON of `she-servicectl list`. */
|
|
170
|
+
function parseList(stdout) {
|
|
171
|
+
let data;
|
|
172
|
+
try {
|
|
173
|
+
data = JSON.parse(stdout);
|
|
174
|
+
} catch {
|
|
175
|
+
throw new HostError('EXEC_FAILED', 'unexpected helper output: ' + String(stdout).slice(0, 200));
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
helper: Number(data.helper) || 0,
|
|
179
|
+
helperOutdated: (Number(data.helper) || 0) < HELPER_VERSION,
|
|
180
|
+
hostname: data.hostname || null,
|
|
181
|
+
node: data.node || null,
|
|
182
|
+
brokerEnv: data.brokerEnv === true,
|
|
183
|
+
adapters: Array.isArray(data.adapters) ? data.adapters : [],
|
|
184
|
+
instances: Array.isArray(data.instances) ? data.instances : [],
|
|
185
|
+
/** pre-core single-instance units (<adapter>.service), helper v5+ */
|
|
186
|
+
legacy: Array.isArray(data.legacy) ? data.legacy : [],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Parse `journalctl -o json` lines into {ts, level, msg, pid}. */
|
|
191
|
+
function parseJournal(text) {
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const line of String(text).split('\n')) {
|
|
194
|
+
if (!line.trim()) continue;
|
|
195
|
+
let j;
|
|
196
|
+
try {
|
|
197
|
+
j = JSON.parse(line);
|
|
198
|
+
} catch {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const usec = Number(j.__REALTIME_TIMESTAMP);
|
|
202
|
+
const prio = Number(j.PRIORITY);
|
|
203
|
+
let msg = j.MESSAGE;
|
|
204
|
+
if (Array.isArray(msg)) msg = Buffer.from(msg).toString('utf8'); // journald encodes non-UTF8 as byte arrays
|
|
205
|
+
out.push({
|
|
206
|
+
ts: Number.isFinite(usec) ? Math.round(usec / 1000) : Date.now(),
|
|
207
|
+
level: prio <= 3 ? 'error' : prio === 4 ? 'warn' : prio <= 6 ? 'info' : 'debug',
|
|
208
|
+
msg: typeof msg === 'string' ? msg : String(msg ?? ''),
|
|
209
|
+
pid: j._PID ? Number(j._PID) : null,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Parse an env file into {entries: [{key, value, line}], comments} keeping order.
|
|
217
|
+
* @param {string} text
|
|
218
|
+
*/
|
|
219
|
+
function parseEnvFile(text) {
|
|
220
|
+
const env = {};
|
|
221
|
+
for (const line of String(text).split('\n')) {
|
|
222
|
+
const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
|
|
223
|
+
if (m) env[m[1]] = m[2];
|
|
224
|
+
}
|
|
225
|
+
return env;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Serialize an env object to the file format the core writes (header comments + KEY=VALUE). */
|
|
229
|
+
function formatEnvFile(env, header = []) {
|
|
230
|
+
const lines = header.map((h) => (h.startsWith('#') ? h : '# ' + h));
|
|
231
|
+
for (const [k, v] of Object.entries(env)) {
|
|
232
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(k)) throw new HostError('HELPER_FAILED', 'invalid env variable name: ' + k);
|
|
233
|
+
if (v === undefined || v === null || v === '') continue;
|
|
234
|
+
lines.push(`${k}=${String(v).replace(/[\r\n]+/g, ' ')}`);
|
|
235
|
+
}
|
|
236
|
+
return lines.join('\n') + '\n';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const SECRET_NAME_RE = /(password|passwd|token|secret|cookie|api[-_]?key|private[-_]?key)/i;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Environment variable names whose values must be masked: `x-secret` properties
|
|
243
|
+
* of the schema plus the name heuristic (SV-10).
|
|
244
|
+
* @param {object|null} schema `--config-schema` output
|
|
245
|
+
* @param {string[]} [envNames] additional names to test with the heuristic
|
|
246
|
+
* @returns {Set<string>}
|
|
247
|
+
*/
|
|
248
|
+
function secretEnvVars(schema, envNames = []) {
|
|
249
|
+
const set = new Set();
|
|
250
|
+
const props = schema && schema.properties ? schema.properties : {};
|
|
251
|
+
for (const [key, p] of Object.entries(props)) {
|
|
252
|
+
const envName = p && typeof p['x-env'] === 'string' ? p['x-env'] : null;
|
|
253
|
+
if (!envName) continue;
|
|
254
|
+
if ((p && p['x-secret'] === true) || SECRET_NAME_RE.test(key)) set.add(envName);
|
|
255
|
+
}
|
|
256
|
+
for (const n of envNames) if (SECRET_NAME_RE.test(n)) set.add(n);
|
|
257
|
+
return set;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = {
|
|
261
|
+
HELPER,
|
|
262
|
+
HELPER_VERSION,
|
|
263
|
+
HELPER_SOURCE,
|
|
264
|
+
DEFAULT_SERVICES_IDENTITY,
|
|
265
|
+
HostError,
|
|
266
|
+
createLocalDriver,
|
|
267
|
+
createSshDriver,
|
|
268
|
+
shellQuote,
|
|
269
|
+
parseList,
|
|
270
|
+
parseJournal,
|
|
271
|
+
parseEnvFile,
|
|
272
|
+
formatEnvFile,
|
|
273
|
+
secretEnvVars,
|
|
274
|
+
SECRET_NAME_RE,
|
|
275
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* xyz2mqtt service inventory (roadmap I4, Tier 0).
|
|
5
|
+
*
|
|
6
|
+
* Pure functions over the daemon's state store. Adapters built on
|
|
7
|
+
* mqtt-interfaces-core publish two retained topics per instance:
|
|
8
|
+
*
|
|
9
|
+
* <name>/connected 0 (LWT / shut down) · 1 (mqtt only) · 2 (mqtt + device)
|
|
10
|
+
* <name>/info {name: <npm package>, version, spec, node, host, pid, started, maintenance, …}
|
|
11
|
+
*
|
|
12
|
+
* An instance is "core" when it has an info payload with `name` and `version`;
|
|
13
|
+
* a `connected` topic without info is a *legacy* instance (pre-core adapters
|
|
14
|
+
* such as hm2mqtt, hue2mqtt) which only supports the connected state.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here talks to MQTT — the API layer publishes to the maintenance
|
|
17
|
+
* topics and clears retained messages; this module only derives the picture
|
|
18
|
+
* and the list of topics a wipe would clear.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { analyzeDiscovery, DEFAULT_PREFIX } = require('./ha-discovery');
|
|
22
|
+
|
|
23
|
+
const LOG_LEVELS = ['error', 'warn', 'info', 'debug'];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a `<name>/connected` payload to 0 | 1 | 2, or null when unusable.
|
|
27
|
+
* @param {*} val
|
|
28
|
+
* @returns {0|1|2|null}
|
|
29
|
+
*/
|
|
30
|
+
function parseConnected(val) {
|
|
31
|
+
if (val === null || val === undefined) return null;
|
|
32
|
+
// adapters running with --json-payloads still publish connected as a plain number,
|
|
33
|
+
// but tolerate {val} objects and JSON strings in case somebody wraps it
|
|
34
|
+
let v = val;
|
|
35
|
+
if (typeof v === 'string') {
|
|
36
|
+
const t = v.trim();
|
|
37
|
+
if (t.startsWith('{')) {
|
|
38
|
+
try {
|
|
39
|
+
v = JSON.parse(t);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
v = t;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (v && typeof v === 'object' && 'val' in v) v = v.val;
|
|
48
|
+
const n = Number(v);
|
|
49
|
+
return n === 0 || n === 1 || n === 2 ? n : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse a `<name>/info` payload into an object, or null.
|
|
54
|
+
* @param {*} val
|
|
55
|
+
* @returns {object|null}
|
|
56
|
+
*/
|
|
57
|
+
function parseInfo(val) {
|
|
58
|
+
let v = val;
|
|
59
|
+
if (typeof v === 'string') {
|
|
60
|
+
try {
|
|
61
|
+
v = JSON.parse(v);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
|
|
67
|
+
// adapters publish info as a plain object; a {val: {...}} wrapper is tolerated
|
|
68
|
+
if (v.val && typeof v.val === 'object' && !('name' in v) && 'name' in v.val) v = v.val;
|
|
69
|
+
return v;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Derive the service inventory from the retained MQTT state.
|
|
74
|
+
*
|
|
75
|
+
* @param {Iterable<[string, {val:*, ts:number, lc?:number}]>} entries `store.mqttEntries()`
|
|
76
|
+
* @param {{now?: number}} [opts]
|
|
77
|
+
* @returns {{instances: Array<object>, coreCount: number, legacyCount: number}}
|
|
78
|
+
*/
|
|
79
|
+
function analyzeServices(entries, opts = {}) {
|
|
80
|
+
const now = opts.now ?? Date.now();
|
|
81
|
+
const infos = new Map(); // instance → {info, ts}
|
|
82
|
+
const conns = new Map(); // instance → {connected, ts, lc}
|
|
83
|
+
const topics = new Set();
|
|
84
|
+
|
|
85
|
+
for (const [topic, obj] of entries) {
|
|
86
|
+
topics.add(topic);
|
|
87
|
+
const slash = topic.indexOf('/');
|
|
88
|
+
if (slash <= 0 || topic.indexOf('/', slash + 1) !== -1) continue; // exactly two levels
|
|
89
|
+
const instance = topic.slice(0, slash);
|
|
90
|
+
const leaf = topic.slice(slash + 1);
|
|
91
|
+
if (instance.startsWith('$')) continue;
|
|
92
|
+
if (leaf === 'info') {
|
|
93
|
+
const info = parseInfo(obj && obj.val);
|
|
94
|
+
if (info && typeof info.name === 'string' && info.name) infos.set(instance, { info, ts: obj.ts });
|
|
95
|
+
} else if (leaf === 'connected') {
|
|
96
|
+
const connected = parseConnected(obj && obj.val);
|
|
97
|
+
if (connected !== null) conns.set(instance, { connected, ts: obj.ts, lc: obj.lc });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const names = new Set([...infos.keys(), ...conns.keys()]);
|
|
102
|
+
const instances = [];
|
|
103
|
+
for (const instance of names) {
|
|
104
|
+
const i = infos.get(instance);
|
|
105
|
+
const c = conns.get(instance);
|
|
106
|
+
const info = i ? i.info : null;
|
|
107
|
+
const connected = c ? c.connected : null;
|
|
108
|
+
let started = null;
|
|
109
|
+
if (info && typeof info.started === 'string') {
|
|
110
|
+
const t = Date.parse(info.started);
|
|
111
|
+
if (!Number.isNaN(t)) started = t;
|
|
112
|
+
}
|
|
113
|
+
const statusTopics = [...topics].filter((t) => t.startsWith(instance + '/status/')).length;
|
|
114
|
+
instances.push({
|
|
115
|
+
instance,
|
|
116
|
+
legacy: !info,
|
|
117
|
+
adapter: info ? info.name : null,
|
|
118
|
+
version: info && typeof info.version === 'string' ? info.version : null,
|
|
119
|
+
spec: info && typeof info.spec === 'string' ? info.spec : null,
|
|
120
|
+
host: info && typeof info.host === 'string' ? info.host : null,
|
|
121
|
+
node: info && typeof info.node === 'string' ? info.node : null,
|
|
122
|
+
pid: info && Number.isInteger(info.pid) ? info.pid : null,
|
|
123
|
+
started,
|
|
124
|
+
// uptime only means something while the process is up; a `started` of a
|
|
125
|
+
// retained info from a dead instance would just grow forever
|
|
126
|
+
uptime: started !== null && connected !== null && connected > 0 ? Math.max(0, now - started) : null,
|
|
127
|
+
maintenance: info ? info.maintenance === true : false,
|
|
128
|
+
connected,
|
|
129
|
+
connectedTs: c ? c.ts : null,
|
|
130
|
+
connectedLc: c ? (c.lc ?? c.ts) : null,
|
|
131
|
+
infoTs: i ? i.ts : null,
|
|
132
|
+
statusTopics,
|
|
133
|
+
info,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
instances.sort((a, b) => a.instance.localeCompare(b.instance));
|
|
137
|
+
return {
|
|
138
|
+
instances,
|
|
139
|
+
coreCount: instances.filter((x) => !x.legacy).length,
|
|
140
|
+
legacyCount: instances.filter((x) => x.legacy).length,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Retained topics that belong to an instance and can be cleared when it is gone:
|
|
146
|
+
* `<name>/info`, `<name>/connected`, everything below `<name>/status/` and
|
|
147
|
+
* `<name>/maintenance/`, plus the Home Assistant discovery announcements whose
|
|
148
|
+
* availability or state topics point at the instance (never announcements that
|
|
149
|
+
* also belong to another device — `analyzeDiscovery` already excludes shared ones).
|
|
150
|
+
*
|
|
151
|
+
* @param {Iterable<[string, {val:*, ts:number}]>} entries `store.mqttEntries()`
|
|
152
|
+
* @param {string} instance topic prefix
|
|
153
|
+
* @param {{haPrefix?: string}} [opts]
|
|
154
|
+
* @returns {{own: string[], discovery: string[]}}
|
|
155
|
+
*/
|
|
156
|
+
function wipeTopics(entries, instance, opts = {}) {
|
|
157
|
+
const list = Array.isArray(entries) ? entries : [...entries];
|
|
158
|
+
const own = [];
|
|
159
|
+
for (const [topic] of list) {
|
|
160
|
+
if (topic === instance + '/info' || topic === instance + '/connected' || topic.startsWith(instance + '/status/') || topic.startsWith(instance + '/maintenance/')) {
|
|
161
|
+
own.push(topic);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
own.sort();
|
|
165
|
+
|
|
166
|
+
const haPrefix = opts.haPrefix || DEFAULT_PREFIX;
|
|
167
|
+
const discovery = new Set();
|
|
168
|
+
const { devices } = analyzeDiscovery(list, { prefix: haPrefix });
|
|
169
|
+
const isOurs = (t) => t === instance + '/connected' || t.startsWith(instance + '/');
|
|
170
|
+
for (const d of devices) {
|
|
171
|
+
// a device is the instance's when every topic it references lives under the instance
|
|
172
|
+
// prefix — the core builds availability from <name>/connected and state from <name>/status/…
|
|
173
|
+
if (d.refTopics.length > 0 && d.refTopics.every(isOurs)) {
|
|
174
|
+
for (const t of d.configTopics) discovery.add(t);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { own, discovery: [...discovery].sort() };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = { analyzeServices, wipeTopics, parseConnected, parseInfo, LOG_LEVELS };
|
package/src/lib/ssh-deploy.js
CHANGED
|
@@ -28,13 +28,16 @@ function expandHome(p) {
|
|
|
28
28
|
return p;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
const DEFAULT_IDENTITY = '~/.she/ssh/broker_id_ed25519';
|
|
32
|
+
|
|
31
33
|
/**
|
|
32
34
|
* Build the common ssh argument list (flags only, no target/command).
|
|
33
|
-
* @param {object} sshConfig - config.broker.ssh
|
|
35
|
+
* @param {object} sshConfig - config.broker.ssh (or services.hosts[].ssh)
|
|
36
|
+
* @param {string} [defaultIdentity] identity file used when sshConfig has none
|
|
34
37
|
* @returns {string[]}
|
|
35
38
|
*/
|
|
36
|
-
function sshArgs(sshConfig) {
|
|
37
|
-
const identityFile = expandHome(sshConfig.identityFile ||
|
|
39
|
+
function sshArgs(sshConfig, defaultIdentity = DEFAULT_IDENTITY) {
|
|
40
|
+
const identityFile = expandHome(sshConfig.identityFile || defaultIdentity);
|
|
38
41
|
return ['-i', identityFile, '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-p', String(sshConfig.port || 22)];
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -42,10 +45,11 @@ function sshArgs(sshConfig) {
|
|
|
42
45
|
* Build the scp argument list prefix.
|
|
43
46
|
* scp uses -P (capital) for port, unlike ssh which uses -p.
|
|
44
47
|
* @param {object} sshConfig
|
|
48
|
+
* @param {string} [defaultIdentity]
|
|
45
49
|
* @returns {string[]}
|
|
46
50
|
*/
|
|
47
|
-
function scpArgs(sshConfig) {
|
|
48
|
-
const identityFile = expandHome(sshConfig.identityFile ||
|
|
51
|
+
function scpArgs(sshConfig, defaultIdentity = DEFAULT_IDENTITY) {
|
|
52
|
+
const identityFile = expandHome(sshConfig.identityFile || defaultIdentity);
|
|
49
53
|
return ['-i', identityFile, '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-P', String(sshConfig.port || 22)];
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -131,9 +135,10 @@ async function testConnection(sshConfig) {
|
|
|
131
135
|
/**
|
|
132
136
|
* Generate an Ed25519 SSH keypair using the system ssh-keygen binary.
|
|
133
137
|
* @param {string} identityFile - path for the private key (e.g. ~/.she/broker_id_ed25519)
|
|
138
|
+
* @param {string} [comment] key comment (default she-broker)
|
|
134
139
|
* @returns {Promise<string>} the public key text
|
|
135
140
|
*/
|
|
136
|
-
async function generateKeypair(identityFile) {
|
|
141
|
+
async function generateKeypair(identityFile, comment = 'she-broker') {
|
|
137
142
|
const expandedPath = expandHome(identityFile || '~/.she/ssh/broker_id_ed25519');
|
|
138
143
|
const dir = path.dirname(expandedPath);
|
|
139
144
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -149,7 +154,7 @@ async function generateKeypair(identityFile) {
|
|
|
149
154
|
/* ok */
|
|
150
155
|
}
|
|
151
156
|
|
|
152
|
-
await execFileAsync('ssh-keygen', ['-t', 'ed25519', '-f', expandedPath, '-N', '', '-C',
|
|
157
|
+
await execFileAsync('ssh-keygen', ['-t', 'ed25519', '-f', expandedPath, '-N', '', '-C', comment], { timeout: 15000 });
|
|
153
158
|
|
|
154
159
|
try {
|
|
155
160
|
fs.chmodSync(expandedPath, 0o600);
|
|
@@ -160,4 +165,4 @@ async function generateKeypair(identityFile) {
|
|
|
160
165
|
return fs.readFileSync(expandedPath + '.pub', 'utf8').trim();
|
|
161
166
|
}
|
|
162
167
|
|
|
163
|
-
module.exports = { expandHome, runCommand, readRemoteFile, uploadFile, uploadContent, testConnection, generateKeypair };
|
|
168
|
+
module.exports = { expandHome, sshArgs, scpArgs, sshTarget, runCommand, readRemoteFile, uploadFile, uploadContent, testConnection, generateKeypair };
|
package/src/web/scripts-api.js
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Secrets HTTP API (roadmap A5/A9) — write-only for secret fields: names and change times come back,
|
|
5
|
+
* values only for fields marked plain (a username, a host).
|
|
6
|
+
*
|
|
7
|
+
* GET /she/secrets { status, error, keySource, groups: [{ name, changed, fields: [{ name, changed }] }] }
|
|
8
|
+
* PUT /she/secrets/:group/:field { value, secret? } → { ok, group, field, changed, secret } (secret: kind of a new field, default true; never downgrades)
|
|
9
|
+
* POST /she/secrets/:group/:field/secret → { ok } mark a plain field secret (one-way)
|
|
10
|
+
* DELETE /she/secrets/:group → { ok }
|
|
11
|
+
* DELETE /she/secrets/:group/:field → { ok }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const express = require('express');
|
|
15
|
+
const secrets = require('../lib/secrets');
|
|
16
|
+
|
|
17
|
+
const router = express.Router();
|
|
18
|
+
router.use(express.json({ limit: '128kb' }));
|
|
19
|
+
|
|
20
|
+
function fail(res, err) {
|
|
21
|
+
if (err.code === 'LOCKED') return res.status(409).json({ error: err.message, code: 'LOCKED' });
|
|
22
|
+
if (err.code === 'INVALID_NAME' || err.code === 'INVALID_VALUE') return res.status(400).json({ error: err.message, code: err.code });
|
|
23
|
+
return res.status(500).json({ error: err.message });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
router.get('/', (req, res) => {
|
|
27
|
+
const st = secrets.status();
|
|
28
|
+
res.json({ status: st.status, error: st.error, keySource: st.keySource, file: st.file, keyFile: st.keyFile, groups: secrets.list() });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
router.put('/:group/:field', (req, res) => {
|
|
32
|
+
const value = req.body && req.body.value;
|
|
33
|
+
try {
|
|
34
|
+
const r = secrets.set(req.params.group, req.params.field, value, { secret: !(req.body && req.body.secret === false) });
|
|
35
|
+
res.json({ ok: true, ...r });
|
|
36
|
+
} catch (err) {
|
|
37
|
+
fail(res, err);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
router.post('/:group/:field/secret', (req, res) => {
|
|
42
|
+
try {
|
|
43
|
+
if (!secrets.mark(req.params.group, req.params.field)) return res.status(404).json({ error: 'no such secret' });
|
|
44
|
+
res.json({ ok: true });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
fail(res, err);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function del(req, res) {
|
|
51
|
+
try {
|
|
52
|
+
if (!secrets.remove(req.params.group, req.params.field)) return res.status(404).json({ error: 'no such secret' });
|
|
53
|
+
res.json({ ok: true });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
fail(res, err);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
router.delete('/:group', del);
|
|
59
|
+
router.delete('/:group/:field', del);
|
|
60
|
+
|
|
61
|
+
module.exports = { router };
|
package/src/web/server.js
CHANGED
|
@@ -14,6 +14,8 @@ const { router: depsRouter } = require('./deps-api');
|
|
|
14
14
|
const { router: gitRouter } = require('./git-api');
|
|
15
15
|
const { router: aiRouter } = require('./ai-api');
|
|
16
16
|
const { router: brokerRouter } = require('./broker-api');
|
|
17
|
+
const { router: servicesRouter } = require('./services-api');
|
|
18
|
+
const { router: secretsRouter } = require('./secrets-api');
|
|
17
19
|
const { attachWss, closeWss } = require('./log-ws');
|
|
18
20
|
const { init: initAuth, authMiddleware, checkAuth, router: authRouter } = require('./auth');
|
|
19
21
|
|
|
@@ -28,7 +30,9 @@ app.use('/she/auth', authRouter);
|
|
|
28
30
|
|
|
29
31
|
// Auth middleware for all /she/* routes except the public auth endpoints above.
|
|
30
32
|
// /api/* is intentionally excluded — user scripts control their own auth.
|
|
31
|
-
|
|
33
|
+
// The remote-host bootstrap script and its callback are fetched by curl on the target host: no
|
|
34
|
+
// session, but useless without the one-time token they carry (services-api validates it).
|
|
35
|
+
const OPEN_SHE_PATHS = new Set(['/she/auth/mode', '/she/auth/login', '/she/auth/logout', '/she/services/setup.sh', '/she/services/setup/done']);
|
|
32
36
|
app.use('/she', (req, res, next) => {
|
|
33
37
|
if (OPEN_SHE_PATHS.has(req.originalUrl.split('?')[0])) return next();
|
|
34
38
|
authMiddleware(req, res, next);
|
|
@@ -61,6 +65,12 @@ app.use('/she/ai', aiRouter);
|
|
|
61
65
|
// Broker management: /she/broker/*
|
|
62
66
|
app.use('/she/broker', brokerRouter);
|
|
63
67
|
|
|
68
|
+
// xyz2mqtt service management: /she/services/*
|
|
69
|
+
app.use('/she/services', servicesRouter);
|
|
70
|
+
|
|
71
|
+
// Secrets (roadmap A5): write-only, values never leave the daemon
|
|
72
|
+
app.use('/she/secrets', secretsRouter);
|
|
73
|
+
|
|
64
74
|
// Graceful daemon restart
|
|
65
75
|
// When running under systemd, delegate to `sudo systemctl restart` so the
|
|
66
76
|
// service actually comes back up. Otherwise fall back to exit(0) and let
|
|
@@ -119,6 +129,7 @@ app.get('/she/status', (req, res) => {
|
|
|
119
129
|
const s = _getStats ? _getStats() : { scripts: 0, topics: 0 };
|
|
120
130
|
if (_latestNpmVersion) s.latestVersion = _latestNpmVersion;
|
|
121
131
|
s.dataDir = require('../lib/storage').STORAGE_ROOT;
|
|
132
|
+
s.user = require('os').userInfo().username; // the OS user the daemon runs as (default SSH user for managed hosts)
|
|
122
133
|
s.startedAt = SERVER_START_TIME;
|
|
123
134
|
if (_isDocker) s.docker = true;
|
|
124
135
|
res.json(s);
|