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.
@@ -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 };
@@ -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 || '~/.she/ssh/broker_id_ed25519');
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 || '~/.she/ssh/broker_id_ed25519');
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', 'she-broker'], { timeout: 15000 });
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 };
@@ -1,4 +1,4 @@
1
- 'use strict';
1
+ 'use strict';
2
2
 
3
3
  const express = require('express');
4
4
  const fs = require('fs');
@@ -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
- const OPEN_SHE_PATHS = new Set(['/she/auth/mode', '/she/auth/login', '/she/auth/logout']);
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);