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,1555 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Services API — management of xyz2mqtt adapter instances (roadmap I4).
5
+ * Mounted at /she/services in server.js; behind the auth middleware.
6
+ *
7
+ * Tier 0 (MQTT only):
8
+ * GET /she/services/instances inventory from retained <name>/info + <name>/connected
9
+ * POST /she/services/instances/:name/restart publish <name>/maintenance/set/restart
10
+ * POST /she/services/instances/:name/loglevel { level } → <name>/maintenance/set/loglevel
11
+ * GET /she/services/instances/:name/retained topics a wipe would clear
12
+ * DELETE /she/services/instances/:name/retained clear them (only while connected = 0)
13
+ *
14
+ * Tier 1 (hosts via systemd, through the she-servicectl helper — local driver in I4, ssh in I5):
15
+ * GET /she/services/hosts every configured host: helper list or error
16
+ * GET /she/services/hosts/:host/adapters/:adapter/schema --config-schema (cached)
17
+ * POST /she/services/hosts/:host/adapters/:adapter/install { instance, env } → <adapter> --install
18
+ * POST /she/services/hosts/:host/adapters/:adapter/update { force? } → npm install -g, restart instances
19
+ * POST /she/services/hosts/:host/units/:adapter/:instance/:action start|stop|restart|enable|disable
20
+ * DELETE /she/services/hosts/:host/units/:adapter/:instance <adapter> --uninstall
21
+ * GET /she/services/hosts/:host/units/:adapter/:instance/logs journal tail (?n=)
22
+ * POST /she/services/hosts/:host/units/:adapter/:instance/logs/follow start/renew a journal follower (serviceLog WS)
23
+ * DELETE /she/services/hosts/:host/units/:adapter/:instance/logs/follow stop it
24
+ * GET /she/services/hosts/:host/units/:adapter/:instance/env env file, secrets masked
25
+ * PUT /she/services/hosts/:host/units/:adapter/:instance/env { env, restart? }
26
+ * GET /she/services/hosts/:host/broker-env /etc/mqtt-interfaces/broker.env
27
+ * PUT /she/services/hosts/:host/broker-env { env }
28
+ * GET /she/services/ssh/pubkey public key of the services identity (I5)
29
+ * POST /she/services/ssh/keygen generate it
30
+ * POST /she/services/hosts/:host/test run `she-servicectl version` → ok / code
31
+ * POST /she/services/hosts/:host/helper/deploy scp the helper to a remote host, install it, print the sudoers line
32
+ * POST /she/services/hosts/:host/helper/remove { mode: key|all, force? } remove she from the host (I11), drop the host entry
33
+ * POST /she/services/setup/token mint a one-time token + the curl | bash command (I9)
34
+ * GET /she/services/setup/token/:token its state: pending | fetched | done | expired
35
+ * GET /she/services/setup.sh?token=… (no auth) the bootstrap script, served once
36
+ * POST /she/services/setup/done?token=… (no auth) callback from the script → host entry is added
37
+ * GET /she/services/hosts/:host/units/:adapter/:instance/files file options (x-file / heuristic) + listing of the managed dirs (I10)
38
+ * GET /she/services/hosts/:host/units/:adapter/:instance/file?path= read one of those files
39
+ * PUT /she/services/hosts/:host/units/:adapter/:instance/file { path, content, restart? } write it
40
+ * POST /she/services/hosts/:host/units/:adapter/:instance/file/create { option, path? } create from the example, point the option at it
41
+ * GET /she/services/hosts/:host/adapters/:adapter/asset?path= a file shipped in the adapter package (example, schema)
42
+ * GET /she/services/catalog[?refresh=1] adapters on npm: trusted publishers' packages depending on the core (I7)
43
+ * POST /she/services/hosts/:host/adapters/:adapter/install-package npm install -g a catalog member on a host
44
+ *
45
+ * Call init(store, getMqttClient, {getMqttConfig}) once; getMqttConfig returns she's own broker
46
+ * settings ({url, username, password}) so every managed host's broker.env can be kept in sync.
47
+ */
48
+
49
+ const express = require('express');
50
+ const fs = require('fs');
51
+ const crypto = require('crypto');
52
+ const dns = require('dns');
53
+ const { analyzeServices, wipeTopics, LOG_LEVELS } = require('../lib/services-inventory');
54
+ const npmRegistry = require('../lib/npm-registry');
55
+ const catalog = require('../lib/services-catalog');
56
+ const os = require('os');
57
+ const path = require('path');
58
+ const {
59
+ createLocalDriver,
60
+ createSshDriver,
61
+ parseList,
62
+ parseJournal,
63
+ parseEnvFile,
64
+ formatEnvFile,
65
+ secretEnvVars,
66
+ shellQuote,
67
+ HostError,
68
+ HELPER,
69
+ HELPER_SOURCE,
70
+ } = require('../lib/services-host');
71
+ const sshDeploy = require('../lib/ssh-deploy');
72
+ const { STORAGE_ROOT } = require('../lib/storage');
73
+ const { broadcast } = require('./log-ws');
74
+
75
+ /** One key for all managed hosts, next to the broker key. */
76
+ let SERVICES_IDENTITY = path.join(STORAGE_ROOT, 'ssh', 'services_id_ed25519');
77
+ /** Test hook: use a throw-away identity path. */
78
+ function setIdentityPath(p) {
79
+ SERVICES_IDENTITY = p;
80
+ }
81
+ /** The user the bootstrap script creates on remote hosts (I9). */
82
+ const REMOTE_USER = 'she-services';
83
+
84
+ const router = express.Router();
85
+
86
+ let _store = null;
87
+ let _getMqtt = () => null;
88
+ let _getMqttConfig = () => null;
89
+
90
+ function init(store, getMqttClient, { getMqttConfig } = {}) {
91
+ _store = store;
92
+ _getMqtt = getMqttClient;
93
+ _getMqttConfig = typeof getMqttConfig === 'function' ? getMqttConfig : () => null;
94
+ }
95
+
96
+ /** Live `services` block from config.json (like broker-api's getBrokerConfig). */
97
+ function getServicesConfig(req) {
98
+ const configPath = req.app.locals.configPath;
99
+ if (!configPath) return {};
100
+ try {
101
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
102
+ return cfg.services && typeof cfg.services === 'object' ? cfg.services : {};
103
+ } catch {
104
+ return {};
105
+ }
106
+ }
107
+
108
+ /** Instance names are topic prefixes; the core's instanceName() rule plus no wildcards/slashes. */
109
+ function validInstance(name) {
110
+ return typeof name === 'string' && /^[A-Za-z0-9_.-]+$/.test(name);
111
+ }
112
+ /** The helper's sentinel for a pre-core single-instance unit (<adapter>.service). */
113
+ const LEGACY = '-';
114
+
115
+ function findInstance(name) {
116
+ if (!_store) return null;
117
+ const { instances } = analyzeServices(_store.mqttEntries());
118
+ return instances.find((i) => i.instance === name) || null;
119
+ }
120
+
121
+ function publish(topic, payload) {
122
+ const mqtt = _getMqtt();
123
+ if (!mqtt) return Promise.reject(Object.assign(new Error('MQTT not connected'), { status: 503 }));
124
+ return new Promise((resolve, reject) => {
125
+ mqtt.publish(topic, payload, { retain: false, qos: 0 }, (err) => (err ? reject(err) : resolve()));
126
+ });
127
+ }
128
+
129
+ // GET /she/services/instances
130
+ router.get('/instances', async (req, res) => {
131
+ if (!_store) return res.json({ enabled: false, instances: [], coreCount: 0, legacyCount: 0 });
132
+ const cfg = getServicesConfig(req);
133
+ const result = analyzeServices(_store.mqttEntries());
134
+ // update badge: one registry lookup per adapter (cached 24 h)
135
+ const adapters = [...new Set(result.instances.filter((i) => i.adapter).map((i) => i.adapter))];
136
+ const latest = new Map(await Promise.all(adapters.map(async (a) => [a, await npmRegistry.latestVersion(a)])));
137
+ const instances = await Promise.all(
138
+ result.instances.map(async (i) => {
139
+ if (!i.adapter) return { ...i, latestVersion: null, updateAvailable: null };
140
+ const { updateAvailable } = await npmRegistry.updateInfo(i.adapter, i.version);
141
+ return { ...i, latestVersion: latest.get(i.adapter) ?? null, updateAvailable };
142
+ }),
143
+ );
144
+ res.json({ enabled: cfg.enabled === true, ...result, instances });
145
+ });
146
+
147
+ // POST /she/services/instances/:name/restart
148
+ router.post('/instances/:name/restart', async (req, res) => {
149
+ const name = req.params.name;
150
+ if (!validInstance(name)) return res.status(400).json({ error: 'invalid instance name' });
151
+ const inst = findInstance(name);
152
+ if (!inst) return res.status(404).json({ error: 'unknown instance' });
153
+ if (inst.legacy) return res.status(409).json({ error: 'legacy instance has no maintenance topics' });
154
+ if (!inst.maintenance) return res.status(409).json({ error: 'instance runs with --no-maintenance' });
155
+ try {
156
+ await publish(`${name}/maintenance/set/restart`, '');
157
+ res.json({ ok: true });
158
+ } catch (err) {
159
+ res.status(err.status || 500).json({ error: err.message });
160
+ }
161
+ });
162
+
163
+ // POST /she/services/instances/:name/loglevel { level }
164
+ router.post('/instances/:name/loglevel', async (req, res) => {
165
+ const name = req.params.name;
166
+ if (!validInstance(name)) return res.status(400).json({ error: 'invalid instance name' });
167
+ const level = req.body && req.body.level;
168
+ if (!LOG_LEVELS.includes(level)) return res.status(400).json({ error: `level must be one of ${LOG_LEVELS.join(', ')}` });
169
+ const inst = findInstance(name);
170
+ if (!inst) return res.status(404).json({ error: 'unknown instance' });
171
+ if (inst.legacy) return res.status(409).json({ error: 'legacy instance has no maintenance topics' });
172
+ if (!inst.maintenance) return res.status(409).json({ error: 'instance runs with --no-maintenance' });
173
+ try {
174
+ await publish(`${name}/maintenance/set/loglevel`, level);
175
+ res.json({ ok: true });
176
+ } catch (err) {
177
+ res.status(err.status || 500).json({ error: err.message });
178
+ }
179
+ });
180
+
181
+ // GET /she/services/instances/:name/retained — what a wipe would clear
182
+ router.get('/instances/:name/retained', (req, res) => {
183
+ const name = req.params.name;
184
+ if (!validInstance(name)) return res.status(400).json({ error: 'invalid instance name' });
185
+ if (!_store) return res.json({ own: [], discovery: [] });
186
+ const haPrefix = typeof req.query.haPrefix === 'string' && req.query.haPrefix.trim() ? req.query.haPrefix.trim() : undefined;
187
+ res.json(wipeTopics(_store.mqttEntries(), name, { haPrefix }));
188
+ });
189
+
190
+ // DELETE /she/services/instances/:name/retained — clear retained topics of a gone instance
191
+ router.delete('/instances/:name/retained', async (req, res) => {
192
+ const name = req.params.name;
193
+ if (!validInstance(name)) return res.status(400).json({ error: 'invalid instance name' });
194
+ const mqtt = _getMqtt();
195
+ if (!mqtt) return res.status(503).json({ error: 'MQTT not connected' });
196
+ const inst = findInstance(name);
197
+ if (!inst) return res.status(404).json({ error: 'unknown instance' });
198
+ if (inst.connected !== null && inst.connected > 0) {
199
+ return res.status(409).json({ error: 'instance is connected — stop it first' });
200
+ }
201
+ const haPrefix = req.body && typeof req.body.haPrefix === 'string' && req.body.haPrefix.trim() ? req.body.haPrefix.trim() : undefined;
202
+ const includeDiscovery = !(req.body && req.body.discovery === false);
203
+ const plan = wipeTopics(_store.mqttEntries(), name, { haPrefix });
204
+ const topics = [...plan.own, ...(includeDiscovery ? plan.discovery : [])];
205
+ const errors = [];
206
+ for (const t of topics) {
207
+ await new Promise((resolve) => {
208
+ mqtt.publish(t, '', { retain: true, qos: 0 }, (err) => {
209
+ if (err) errors.push({ topic: t, error: err.message });
210
+ resolve();
211
+ });
212
+ });
213
+ }
214
+ res.json({ ok: errors.length === 0, cleared: topics.length - errors.length, errors });
215
+ });
216
+
217
+ // ── Tier 1: hosts ─────────────────────────────────────────────────────────────
218
+
219
+ const MASK = '***';
220
+ const UNIT_ACTIONS = ['start', 'stop', 'restart', 'enable', 'disable'];
221
+ const SCHEMA_TTL = 10 * 60 * 1000;
222
+ const FOLLOW_TTL = 10 * 60 * 1000;
223
+
224
+ function validAdapter(name) {
225
+ return typeof name === 'string' && /^[a-z0-9][a-z0-9._-]{0,213}$/.test(name);
226
+ }
227
+ function validEnvName(name) {
228
+ return typeof name === 'string' && /^[A-Z][A-Z0-9_]*$/.test(name);
229
+ }
230
+
231
+ /** Drivers are created per request from the live config; tests inject a factory. */
232
+ let _driverFactory = (hostCfg) =>
233
+ hostCfg.ssh && typeof hostCfg.ssh.host === 'string' && hostCfg.ssh.host
234
+ ? createSshDriver(hostCfg, { defaultIdentity: SERVICES_IDENTITY })
235
+ : hostCfg.ssh
236
+ ? null
237
+ : createLocalDriver({ name: hostCfg.name });
238
+ function setDriverFactory(fn) {
239
+ _driverFactory = fn;
240
+ }
241
+
242
+ const HOST_NAME_RE = /^[A-Za-z0-9_.:[\]-]+$/;
243
+
244
+ /**
245
+ * Configured hosts; without any, the she host itself. A remote entry without a name is
246
+ * named after its ssh host.
247
+ */
248
+ function hostEntries(req) {
249
+ const cfg = getServicesConfig(req);
250
+ const list = Array.isArray(cfg.hosts) && cfg.hosts.length > 0 ? cfg.hosts : [{ name: 'local' }];
251
+ const seen = new Set();
252
+ return list
253
+ .filter((h) => h && typeof h === 'object')
254
+ .map((h) => ({ ...h, name: typeof h.name === 'string' && h.name ? h.name : h.ssh && typeof h.ssh.host === 'string' ? h.ssh.host : 'local' }))
255
+ .filter((h) => HOST_NAME_RE.test(h.name) && !seen.has(h.name) && seen.add(h.name))
256
+ .map((h) => ({ cfg: h, driver: _driverFactory(h) }));
257
+ }
258
+
259
+ /** Marker in an instance's env file: she keeps the MQTT_URL/USERNAME/PASSWORD of this instance equal to her own. */
260
+ const SHE_BROKER_MARKER = 'SHE_USE_BROKER';
261
+ /** Marker: the instance logs in with its own dynsec identity (value = client/role name), managed by she (I6). */
262
+ const DYNSEC_MARKER = 'SHE_DYNSEC_CLIENT';
263
+ const BROKER_MODES = ['own', 'she', 'dynsec'];
264
+ const ACL_TYPES = ['publishClientSend', 'publishClientReceive', 'subscribeLiteral', 'subscribePattern', 'unsubscribeLiteral', 'unsubscribePattern'];
265
+
266
+ let _dynsec = require('../lib/dynsec');
267
+ /** Test hook: replace the dynsec client. */
268
+ function setDynsec(d) {
269
+ _dynsec = d;
270
+ }
271
+ function dynsecAvailable() {
272
+ try {
273
+ return _dynsec.getStatus().dynsecReady === true;
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+ /** dynsec client and role name for an instance. */
279
+ function credentialName(instance) {
280
+ return 'svc-' + instance;
281
+ }
282
+ /** What an adapter instance legitimately needs on the broker. */
283
+ function defaultAcl(instance) {
284
+ return [
285
+ { acltype: 'publishClientSend', topic: instance + '/#', allow: true },
286
+ { acltype: 'publishClientReceive', topic: instance + '/#', allow: true },
287
+ { acltype: 'subscribePattern', topic: instance + '/#', allow: true },
288
+ { acltype: 'unsubscribePattern', topic: instance + '/#', allow: true },
289
+ { acltype: 'publishClientSend', topic: 'homeassistant/#', allow: true },
290
+ ];
291
+ }
292
+ function validateAcl(acl) {
293
+ if (!Array.isArray(acl) || acl.length === 0) throw new HostError('HELPER_FAILED', 'acl must be a non-empty array');
294
+ return acl.map((e) => {
295
+ if (!e || !ACL_TYPES.includes(e.acltype)) throw new HostError('HELPER_FAILED', 'invalid acl type');
296
+ if (typeof e.topic !== 'string' || !e.topic.trim() || /\s/.test(e.topic)) throw new HostError('HELPER_FAILED', 'invalid acl topic');
297
+ return { acltype: e.acltype, topic: e.topic.trim(), allow: e.allow !== false };
298
+ });
299
+ }
300
+ function brokerModeOf(env) {
301
+ if (env[DYNSEC_MARKER]) return 'dynsec';
302
+ if (env[SHE_BROKER_MARKER] === '1') return 'she';
303
+ return 'own';
304
+ }
305
+ const isExists = (err) => /exist|already/i.test(String((err && err.message) || err));
306
+
307
+ /**
308
+ * Create (or re-key) the dynsec identity of an instance: role with the ACL, client with a fresh
309
+ * random password, role assigned. Idempotent — an existing role/client is reused, the password
310
+ * is always renewed.
311
+ * @returns {Promise<{username: string, password: string}>}
312
+ */
313
+ async function createInstanceCredentials(instance, acl) {
314
+ const name = credentialName(instance);
315
+ const password = crypto.randomBytes(18).toString('base64url');
316
+ try {
317
+ await _dynsec.createRole(name, { textdescription: 'she: adapter instance ' + instance });
318
+ } catch (err) {
319
+ if (!isExists(err)) throw err;
320
+ }
321
+ for (const e of acl) {
322
+ try {
323
+ await _dynsec.addRoleACL(name, e.acltype, e.topic, e.allow);
324
+ } catch (err) {
325
+ if (!isExists(err)) throw err;
326
+ }
327
+ }
328
+ try {
329
+ await _dynsec.createClient(name, password, { textdescription: 'she: adapter instance ' + instance });
330
+ } catch (err) {
331
+ if (!isExists(err)) throw err;
332
+ await _dynsec.setClientPassword(name, password);
333
+ }
334
+ try {
335
+ await _dynsec.addClientRole(name, name);
336
+ } catch (err) {
337
+ if (!isExists(err)) throw err;
338
+ }
339
+ return { username: name, password };
340
+ }
341
+ /** Remove the dynsec identity again; best effort (the instance may be gone already). */
342
+ async function deleteInstanceCredentials(name) {
343
+ for (const fn of [() => _dynsec.deleteClient(name), () => _dynsec.deleteRole(name)]) {
344
+ try {
345
+ await fn();
346
+ } catch {
347
+ /* not there */
348
+ }
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Apply a broker credentials mode to an instance env.
354
+ * own — nothing managed; leaving dynsec also drops the dead username/password
355
+ * she — she's own settings (SHE_USE_BROKER=1)
356
+ * dynsec — dedicated identity; created on first use / when `rotate`, else kept
357
+ * @returns {Promise<object>} the new env
358
+ */
359
+ async function applyBrokerMode(env, { mode, prefix, local, instance, acl, rotate }) {
360
+ if (!BROKER_MODES.includes(mode)) throw new HostError('HELPER_FAILED', 'brokerMode must be own, she or dynsec');
361
+ const before = brokerModeOf(env);
362
+ let out = { ...env };
363
+ if (mode !== 'dynsec' && before === 'dynsec') {
364
+ await deleteInstanceCredentials(out[DYNSEC_MARKER]);
365
+ delete out[DYNSEC_MARKER];
366
+ delete out[prefix + '_MQTT_USERNAME'];
367
+ delete out[prefix + '_MQTT_PASSWORD'];
368
+ }
369
+ if (mode === 'own') {
370
+ delete out[SHE_BROKER_MARKER];
371
+ return out;
372
+ }
373
+ if (mode === 'she') return applySheBroker(out, prefix, true, local);
374
+ // dynsec
375
+ if (!dynsecAvailable()) throw new HostError('HELPER_FAILED', 'Mosquitto dynamic security is not available — enable Mosquitto management with dynsec first');
376
+ const b = sheBrokerSettings(local);
377
+ if (!b) throw new HostError('HELPER_FAILED', 'she has no MQTT broker configured (Settings → MQTT)');
378
+ delete out[SHE_BROKER_MARKER];
379
+ if (before === 'dynsec' && !rotate) {
380
+ out[prefix + '_MQTT_URL'] = b.url; // url follows she, credentials stay
381
+ return out;
382
+ }
383
+ const cred = await createInstanceCredentials(instance, acl ? validateAcl(acl) : defaultAcl(instance));
384
+ out[DYNSEC_MARKER] = cred.username;
385
+ out[prefix + '_MQTT_URL'] = b.url;
386
+ out[prefix + '_MQTT_USERNAME'] = cred.username;
387
+ out[prefix + '_MQTT_PASSWORD'] = cred.password;
388
+ return out;
389
+ }
390
+ /** brokerMode from a request body, with the old `useSheBroker` boolean as fallback. */
391
+ function requestedMode(body, fallback) {
392
+ if (body && typeof body.brokerMode === 'string') return body.brokerMode;
393
+ if (body && typeof body.useSheBroker === 'boolean') return body.useSheBroker ? 'she' : 'own';
394
+ return fallback;
395
+ }
396
+
397
+ /**
398
+ * she's own broker settings as an adapter on the she host (local) or another host would need them;
399
+ * a loopback URL means "the she host" and is rewritten to she's hostname for remote hosts.
400
+ * @returns {{url: string, username: string, password: string}|null}
401
+ */
402
+ function sheBrokerSettings(local) {
403
+ const mqtt = _getMqttConfig();
404
+ if (!mqtt || typeof mqtt.url !== 'string' || !mqtt.url) return null;
405
+ let url = mqtt.url;
406
+ if (!local) url = url.replace(/\/\/(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)(?=[:/]|$)/, '//' + os.hostname());
407
+ return { url, username: mqtt.username ? String(mqtt.username) : '', password: mqtt.password ? String(mqtt.password) : '' };
408
+ }
409
+
410
+ /** What the UI shows next to the "use she's broker settings" switch (no password). */
411
+ function sheBrokerInfo(local) {
412
+ const b = sheBrokerSettings(local);
413
+ return b ? { url: b.url, username: b.username, hasPassword: b.password !== '' } : null;
414
+ }
415
+
416
+ /** Env prefix of an adapter: from its schema, else the core's default (name upper-cased). */
417
+ function envPrefixOf(schema, adapter) {
418
+ const p = schema && schema['x-adapter'] && schema['x-adapter'].envPrefix;
419
+ return typeof p === 'string' && p ? p : adapter.toUpperCase().replace(/[^A-Z0-9]/g, '_');
420
+ }
421
+
422
+ /**
423
+ * Apply the "use she's broker settings" switch to an instance env: sets/removes the marker and,
424
+ * when on, overwrites the prefixed MQTT_URL/USERNAME/PASSWORD with she's values.
425
+ */
426
+ function applySheBroker(env, prefix, useSheBroker, local) {
427
+ const out = { ...env };
428
+ if (!useSheBroker) {
429
+ delete out[SHE_BROKER_MARKER];
430
+ return out;
431
+ }
432
+ const b = sheBrokerSettings(local);
433
+ if (!b) throw new HostError('HELPER_FAILED', 'she has no MQTT broker configured (Settings → MQTT)');
434
+ out[SHE_BROKER_MARKER] = '1';
435
+ out[prefix + '_MQTT_URL'] = b.url;
436
+ if (b.username) out[prefix + '_MQTT_USERNAME'] = b.username;
437
+ else delete out[prefix + '_MQTT_USERNAME'];
438
+ if (b.password) out[prefix + '_MQTT_PASSWORD'] = b.password;
439
+ else delete out[prefix + '_MQTT_PASSWORD'];
440
+ return out;
441
+ }
442
+
443
+ function hostError(res, err) {
444
+ if (err instanceof HostError) {
445
+ const status = err.code === 'HELPER_MISSING' ? 503 : err.code === 'SUDO_DENIED' ? 403 : err.code === 'HELPER_FAILED' ? 400 : err.code === 'SSH_FAILED' ? 502 : 500;
446
+ return res.status(status).json({ error: err.message, code: err.code, ...(err.stdout ? { output: err.stdout } : {}) });
447
+ }
448
+ return res.status(500).json({ error: err.message });
449
+ }
450
+
451
+ /** Resolve :host, :adapter and (optionally) :instance params; responds and returns null on error. */
452
+ function resolve(req, res, { adapter = false, instance = false } = {}) {
453
+ const entry = hostEntries(req).find((h) => h.cfg.name === req.params.host);
454
+ if (!entry) {
455
+ res.status(404).json({ error: 'unknown host' });
456
+ return null;
457
+ }
458
+ if (!entry.driver) {
459
+ res.status(400).json({ error: 'host entry has an ssh block without a host', code: 'UNSUPPORTED' });
460
+ return null;
461
+ }
462
+ if (adapter && !validAdapter(req.params.adapter)) {
463
+ res.status(400).json({ error: 'invalid adapter name' });
464
+ return null;
465
+ }
466
+ if (instance && !validInstance(req.params.instance)) {
467
+ res.status(400).json({ error: 'invalid instance name' });
468
+ return null;
469
+ }
470
+ return entry;
471
+ }
472
+
473
+ const _schemaCache = new Map(); // host/adapter → {schema, ts}
474
+ async function loadSchema(driver, adapter, { force = false } = {}) {
475
+ const key = driver.name + '/' + adapter;
476
+ const hit = _schemaCache.get(key);
477
+ if (hit && !force && Date.now() - hit.ts < SCHEMA_TTL) return hit.schema;
478
+ const { stdout } = await driver.exec(['schema', adapter]);
479
+ let schema;
480
+ try {
481
+ schema = JSON.parse(stdout);
482
+ } catch {
483
+ throw new HostError('EXEC_FAILED', 'adapter did not print a JSON schema');
484
+ }
485
+ _schemaCache.set(key, { schema, ts: Date.now() });
486
+ return schema;
487
+ }
488
+
489
+ function maskEnv(env, secrets) {
490
+ const out = {};
491
+ for (const [k, v] of Object.entries(env)) out[k] = secrets.has(k) && v !== '' ? MASK : v;
492
+ return out;
493
+ }
494
+
495
+ /** Merge a submitted env (with masked secrets) over the current one; validate names. */
496
+ function mergeEnv(current, submitted) {
497
+ if (!submitted || typeof submitted !== 'object' || Array.isArray(submitted)) throw new HostError('HELPER_FAILED', 'env object required');
498
+ const out = {};
499
+ for (const [k, v] of Object.entries(submitted)) {
500
+ if (!validEnvName(k)) throw new HostError('HELPER_FAILED', 'invalid env variable name: ' + k);
501
+ if (v === undefined || v === null) continue;
502
+ const str = typeof v === 'boolean' || typeof v === 'number' ? String(v) : String(v);
503
+ if (str === MASK) {
504
+ if (current[k] !== undefined) out[k] = current[k];
505
+ continue;
506
+ }
507
+ if (str === '') continue;
508
+ out[k] = str;
509
+ }
510
+ return out;
511
+ }
512
+
513
+ /**
514
+ * SV-14: remember the hostname a host reported so MQTT instances (info.host) can be
515
+ * matched to it; only fills an empty field, best effort.
516
+ */
517
+ function saveHostname(req, name, hostname) {
518
+ const configPath = req.app.locals.configPath;
519
+ if (!configPath || !hostname) return;
520
+ try {
521
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
522
+ const hosts = cfg.services && Array.isArray(cfg.services.hosts) ? cfg.services.hosts : null;
523
+ const entry = hosts && hosts.find((h) => h && (h.name === name || (!h.name && h.ssh && h.ssh.host === name)));
524
+ if (!entry || entry.hostname) return;
525
+ entry.hostname = hostname;
526
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
527
+ } catch {
528
+ /* config not writable — the UI still correlates via the live value */
529
+ }
530
+ }
531
+
532
+ /**
533
+ * The host listing runs the helper (and the broker.env sync) on every host — cached for a
534
+ * minute so tab switches and WS-triggered reloads do not hit the hosts; `?refresh=1` and every
535
+ * mutating route bypass/invalidate it. Keyed by the services config so config edits are seen.
536
+ */
537
+ const HOSTS_TTL = 60 * 1000;
538
+ let _hostsCache = null; // {key, ts, promise}
539
+ function hostsCacheKey(req) {
540
+ return String(req.app.locals.configPath) + ':' + JSON.stringify(getServicesConfig(req));
541
+ }
542
+ function invalidateHosts() {
543
+ _hostsCache = null;
544
+ }
545
+
546
+ // GET /she/services/hosts[?refresh=1]
547
+ router.get('/hosts', async (req, res) => {
548
+ const key = hostsCacheKey(req);
549
+ const fresh = req.query.refresh === '1';
550
+ if (!fresh && _hostsCache && _hostsCache.key === key && Date.now() - _hostsCache.ts < HOSTS_TTL) {
551
+ return res.json({ hosts: await _hostsCache.promise, cached: true });
552
+ }
553
+ const promise = listHosts(req);
554
+ _hostsCache = { key, ts: Date.now(), promise };
555
+ res.json({ hosts: await promise, cached: false });
556
+ });
557
+
558
+ async function listHosts(req) {
559
+ return Promise.all(
560
+ hostEntries(req).map(async ({ cfg, driver }) => {
561
+ const base = {
562
+ name: cfg.name,
563
+ local: !cfg.ssh,
564
+ ssh: cfg.ssh ? { host: cfg.ssh.host, port: cfg.ssh.port || 22, user: cfg.ssh.user || os.userInfo().username } : null,
565
+ hostname: cfg.hostname || null,
566
+ };
567
+ if (!driver) return { ...base, ok: false, code: 'UNSUPPORTED', error: 'host entry has an ssh block without a host' };
568
+ try {
569
+ const { stdout } = await driver.exec(['list']);
570
+ const list = parseList(stdout);
571
+ if (!cfg.hostname && list.hostname) saveHostname(req, cfg.name, list.hostname);
572
+ // update badge per installed adapter: one registry lookup each (cached 24 h)
573
+ const adapters = await Promise.all(
574
+ (list.adapters || []).map(async (a) => {
575
+ try {
576
+ const { latest, updateAvailable } = await npmRegistry.updateInfo(a.name, a.version);
577
+ return { ...a, latestVersion: latest || null, updateAvailable };
578
+ } catch {
579
+ return { ...a, latestVersion: null, updateAvailable: null };
580
+ }
581
+ }),
582
+ );
583
+ return { ...base, ok: true, hostname: base.hostname || list.hostname, ...list, adapters };
584
+ } catch (err) {
585
+ return { ...base, ok: false, code: err.code || 'ERROR', error: err.message };
586
+ }
587
+ }),
588
+ );
589
+ }
590
+
591
+ // every mutating host route changes what the listing would show
592
+ router.use((req, res, next) => {
593
+ if (req.method !== 'GET') {
594
+ res.on('finish', () => {
595
+ if (req.path.startsWith('/hosts/')) invalidateHosts();
596
+ });
597
+ }
598
+ next();
599
+ });
600
+
601
+ // ── I5: ssh identity, connection test, helper deploy ─────────────────────────
602
+
603
+ function identityPath() {
604
+ return sshDeploy.expandHome(SERVICES_IDENTITY);
605
+ }
606
+
607
+ // GET /she/services/ssh/pubkey
608
+ router.get('/ssh/pubkey', (req, res) => {
609
+ try {
610
+ const publicKey = fs.readFileSync(identityPath() + '.pub', 'utf8').trim();
611
+ res.json({ publicKey, identityFile: identityPath() });
612
+ } catch {
613
+ res.json({ publicKey: null, identityFile: identityPath() });
614
+ }
615
+ });
616
+
617
+ // POST /she/services/ssh/keygen
618
+ router.post('/ssh/keygen', async (req, res) => {
619
+ try {
620
+ const publicKey = await sshDeploy.generateKeypair(identityPath(), 'she-services');
621
+ res.json({ publicKey, identityFile: identityPath() });
622
+ } catch (err) {
623
+ res.status(500).json({ error: err.message });
624
+ }
625
+ });
626
+
627
+ // POST /she/services/ssh/test { host, port?, user?, identityFile? } — test unsaved host settings from the Config page
628
+ router.post('/ssh/test', async (req, res) => {
629
+ const b = req.body || {};
630
+ if (typeof b.host !== 'string' || !/^[A-Za-z0-9_.:[\]-]+$/.test(b.host)) return res.status(400).json({ error: 'invalid host' });
631
+ const ssh = { host: b.host };
632
+ if (b.port !== undefined && b.port !== '' && b.port !== null) {
633
+ const port = Number(b.port);
634
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return res.status(400).json({ error: 'invalid port' });
635
+ ssh.port = port;
636
+ }
637
+ if (typeof b.user === 'string' && b.user) {
638
+ if (!/^[A-Za-z0-9_.-]+$/.test(b.user)) return res.status(400).json({ error: 'invalid user' });
639
+ ssh.user = b.user;
640
+ }
641
+ if (typeof b.identityFile === 'string' && b.identityFile) ssh.identityFile = b.identityFile;
642
+ const driver = _driverFactory({ name: b.host, ssh });
643
+ if (!driver) return res.status(400).json({ error: 'no driver' });
644
+ try {
645
+ const { stdout } = await driver.exec(['version'], { timeout: 20000 });
646
+ res.json({ ok: true, helper: Number(String(stdout).trim()) || null });
647
+ } catch (err) {
648
+ res.json({ ok: false, code: err.code || 'ERROR', error: err.message });
649
+ }
650
+ });
651
+
652
+ // POST /she/services/hosts/:host/test — always 200 with ok/code so the UI can explain
653
+ router.post('/hosts/:host/test', async (req, res) => {
654
+ const entry = resolve(req, res);
655
+ if (!entry) return;
656
+ try {
657
+ const { stdout } = await entry.driver.exec(['version'], { timeout: 20000 });
658
+ res.json({ ok: true, helper: Number(String(stdout).trim()) || null });
659
+ } catch (err) {
660
+ res.json({ ok: false, code: err.code || 'ERROR', error: err.message });
661
+ }
662
+ });
663
+
664
+ /** Commands an admin runs when she itself is not allowed to (SV-4: she never edits sudoers remotely). */
665
+ function deployInstructions(user, tmpName) {
666
+ return [
667
+ `sudo install -m 755 -o root -g root ~/${tmpName} ${HELPER} && rm -f ~/${tmpName}`,
668
+ `echo '${user} ALL=(root) NOPASSWD: ${HELPER}' | sudo tee /etc/sudoers.d/she-services >/dev/null && sudo chmod 440 /etc/sudoers.d/she-services`,
669
+ ];
670
+ }
671
+
672
+ // POST /she/services/hosts/:host/helper/deploy
673
+ router.post('/hosts/:host/helper/deploy', async (req, res) => {
674
+ const entry = resolve(req, res);
675
+ if (!entry) return;
676
+ const { driver, cfg } = entry;
677
+ const user = (cfg.ssh && cfg.ssh.user) || os.userInfo().username;
678
+ let helperSource;
679
+ try {
680
+ helperSource = fs.readFileSync(HELPER_SOURCE, 'utf8');
681
+ } catch (err) {
682
+ return res.status(500).json({ error: 'helper source missing: ' + err.message });
683
+ }
684
+ // 1. an installed helper (v4+) replaces itself through the existing sudo rule — no root needed
685
+ try {
686
+ const { stdout } = await driver.exec(['self-update'], { stdin: helperSource, timeout: 30000 });
687
+ invalidateHosts();
688
+ const m = /-> (\d+)/.exec(stdout);
689
+ return res.json({ ok: true, uploaded: true, installed: true, sudoers: true, helper: m ? Number(m[1]) : null, user, method: 'self-update', output: stdout.trim() });
690
+ } catch (err) {
691
+ if (err.code === 'SSH_FAILED') return hostError(res, err);
692
+ // HELPER_MISSING (not installed yet) or HELPER_FAILED (older helper without self-update): fall through
693
+ }
694
+ if (driver.local) {
695
+ return res.status(400).json({ error: 'the helper on the she host is too old to update itself — run: sudo she --install', code: 'LOCAL' });
696
+ }
697
+ const tmpName = 'she-servicectl.tmp';
698
+ const instructions = deployInstructions(user, tmpName);
699
+ try {
700
+ await driver.upload(HELPER_SOURCE, tmpName);
701
+ } catch (err) {
702
+ return hostError(res, err);
703
+ }
704
+ // try to install it right away — works when the ssh user is root or already sudo-capable
705
+ let installed = false;
706
+ try {
707
+ await driver.run(`sudo -n install -m 755 -o root -g root ./${shellQuote(tmpName)} ${shellQuote(HELPER)} && rm -f ./${shellQuote(tmpName)}`, { timeout: 30000 });
708
+ installed = true;
709
+ } catch (err) {
710
+ if (err.code === 'SSH_FAILED') return hostError(res, err);
711
+ }
712
+ if (!installed) {
713
+ return res.json({ ok: false, uploaded: true, installed: false, sudoers: false, code: 'SUDO_DENIED', instructions, user });
714
+ }
715
+ // installed — is the helper callable through sudo for this user?
716
+ try {
717
+ const { stdout } = await driver.exec(['version'], { timeout: 20000 });
718
+ invalidateHosts();
719
+ return res.json({ ok: true, uploaded: true, installed: true, sudoers: true, helper: Number(String(stdout).trim()) || null, user, method: 'install' });
720
+ } catch (err) {
721
+ return res.json({ ok: false, uploaded: true, installed: true, sudoers: false, code: err.code || 'ERROR', error: err.message, instructions: [instructions[1]], user });
722
+ }
723
+ });
724
+
725
+ /** Drop a host entry from config.json (after she removed herself from the host). */
726
+ function removeHostEntry(req, name) {
727
+ const configPath = req.app.locals.configPath;
728
+ if (!configPath) return false;
729
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
730
+ const hosts = cfg.services && Array.isArray(cfg.services.hosts) ? cfg.services.hosts : null;
731
+ if (!hosts) return false;
732
+ const rest = hosts.filter((h) => !(h && (h.name === name || (!h.name && h.ssh && h.ssh.host === name) || (name === 'local' && !h.ssh))));
733
+ if (rest.length === hosts.length) return false;
734
+ cfg.services.hosts = rest;
735
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
736
+ return true;
737
+ }
738
+
739
+ // POST /she/services/hosts/:host/helper/remove { mode: 'key' | 'all', force? } (I11)
740
+ // key: only this she's public key leaves the SSH user's authorized_keys (helper, sudoers rule and
741
+ // user stay for another she instance). all: key, sudoers rule, helper and the she-services user.
742
+ // Adapters and instances are never touched. On success the host entry is removed from config.json.
743
+ router.post('/hosts/:host/helper/remove', async (req, res) => {
744
+ const entry = resolve(req, res);
745
+ if (!entry) return;
746
+ const { driver, cfg } = entry;
747
+ const mode = req.body && req.body.mode === 'all' ? 'all' : 'key';
748
+ const force = !!(req.body && req.body.force === true);
749
+ if (driver.local && mode === 'key') return res.status(400).json({ error: 'the she host has no SSH key to remove — remove everything instead', code: 'LOCAL' });
750
+ let publicKey = '';
751
+ try {
752
+ publicKey = fs.readFileSync(((cfg.ssh && cfg.ssh.identityFile) || identityPath()) + '.pub', 'utf8').trim();
753
+ } catch {
754
+ /* no key: the local host, or a host set up by hand with another identity */
755
+ }
756
+ if (mode === 'key' && !publicKey) return res.status(400).json({ error: "no public key found for this host's SSH identity", code: 'NO_KEY' });
757
+ try {
758
+ const args = mode === 'all' ? ['teardown', ...(force ? ['--force'] : [])] : ['remove-key'];
759
+ const { stdout } = await driver.exec(args, { stdin: publicKey + '\n', timeout: 60000 });
760
+ let removedHost = false;
761
+ try {
762
+ removedHost = removeHostEntry(req, cfg.name);
763
+ } catch (err) {
764
+ return res.json({ ok: true, mode, output: stdout.trim(), removedHost: false, warning: 'cannot update config: ' + err.message });
765
+ }
766
+ invalidateHosts();
767
+ res.json({ ok: true, mode, output: stdout.trim(), removedHost });
768
+ } catch (err) {
769
+ if (err instanceof HostError && err.exitCode === 3) return res.json({ ok: false, code: 'OTHER_KEYS', error: err.message });
770
+ hostError(res, err);
771
+ }
772
+ });
773
+
774
+ // GET /she/services/hosts/:host/adapters/:adapter/schema
775
+ router.get('/hosts/:host/adapters/:adapter/schema', async (req, res) => {
776
+ const entry = resolve(req, res, { adapter: true });
777
+ if (!entry) return;
778
+ try {
779
+ const schema = await loadSchema(entry.driver, req.params.adapter, { force: req.query.refresh === '1' });
780
+ res.json({
781
+ schema,
782
+ secrets: [...secretEnvVars(schema)],
783
+ envPrefix: envPrefixOf(schema, req.params.adapter),
784
+ sheBroker: sheBrokerInfo(entry.driver.local === true),
785
+ dynsec: { available: dynsecAvailable() },
786
+ });
787
+ } catch (err) {
788
+ hostError(res, err);
789
+ }
790
+ });
791
+
792
+ // POST /she/services/hosts/:host/adapters/:adapter/install { instance, env }
793
+ router.post('/hosts/:host/adapters/:adapter/install', async (req, res) => {
794
+ const entry = resolve(req, res, { adapter: true });
795
+ if (!entry) return;
796
+ const { instance, env } = req.body || {};
797
+ if (!validInstance(instance)) return res.status(400).json({ error: 'invalid instance name' });
798
+ try {
799
+ let merged = mergeEnv({}, env || {});
800
+ const mode = requestedMode(req.body, 'own');
801
+ if (mode !== 'own') {
802
+ let schema = null;
803
+ try {
804
+ schema = await loadSchema(entry.driver, req.params.adapter);
805
+ } catch {
806
+ /* default prefix */
807
+ }
808
+ merged = await applyBrokerMode(merged, {
809
+ mode,
810
+ prefix: envPrefixOf(schema, req.params.adapter),
811
+ local: entry.driver.local === true,
812
+ instance,
813
+ acl: req.body.acl,
814
+ rotate: true,
815
+ });
816
+ }
817
+ const { stdout } = await entry.driver.exec(['install', req.params.adapter, instance], { stdin: formatEnvFile(merged), timeout: 120000 });
818
+ res.json({ ok: true, output: stdout });
819
+ } catch (err) {
820
+ hostError(res, err);
821
+ }
822
+ });
823
+
824
+ // POST /she/services/hosts/:host/adapters/:adapter/update { force? }
825
+ router.post('/hosts/:host/adapters/:adapter/update', async (req, res) => {
826
+ const entry = resolve(req, res, { adapter: true });
827
+ if (!entry) return;
828
+ const adapter = req.params.adapter;
829
+ const force = Boolean(req.body && req.body.force);
830
+ try {
831
+ const list = parseList((await entry.driver.exec(['list'])).stdout);
832
+ const known = list.adapters.find((a) => a.name === adapter);
833
+ if (known && known.origin === 'manual' && !force) {
834
+ return res.status(409).json({
835
+ error: adapter + ' was deployed manually on this host (not via npm install -g); updating replaces it with the registry version',
836
+ code: 'MANUAL_DEPLOY',
837
+ origin: 'manual',
838
+ path: known.path || null,
839
+ });
840
+ }
841
+ const { stdout } = await entry.driver.exec(['npm', adapter, 'update'], { timeout: 600000 });
842
+ _schemaCache.delete(entry.driver.name + '/' + adapter);
843
+ const restarted = [];
844
+ const failed = [];
845
+ for (const inst of list.instances.filter((i) => i.adapter === adapter && i.active === 'active')) {
846
+ try {
847
+ await entry.driver.exec(['unit', adapter, inst.instance, 'restart']);
848
+ restarted.push(inst.instance);
849
+ } catch (err) {
850
+ failed.push({ instance: inst.instance, error: err.message });
851
+ }
852
+ }
853
+ res.json({ ok: failed.length === 0, output: stdout, restarted, failed });
854
+ } catch (err) {
855
+ hostError(res, err);
856
+ }
857
+ });
858
+
859
+ // POST /she/services/hosts/:host/units/:adapter/-/migrate { name } — legacy <adapter>.service → <adapter>@<name>
860
+ router.post('/hosts/:host/units/:adapter/' + LEGACY + '/migrate', async (req, res) => {
861
+ const entry = resolve(req, res, { adapter: true });
862
+ if (!entry) return;
863
+ const name = req.body && req.body.name;
864
+ if (!validInstance(name) || name === LEGACY) return res.status(400).json({ error: 'invalid instance name' });
865
+ try {
866
+ const { stdout } = await entry.driver.exec(['migrate', req.params.adapter, name], { timeout: 120000 });
867
+ invalidateHosts();
868
+ res.json({ ok: true, output: stdout, instance: name });
869
+ } catch (err) {
870
+ hostError(res, err);
871
+ }
872
+ });
873
+
874
+ // POST /she/services/hosts/:host/units/:adapter/:instance/:action
875
+ router.post('/hosts/:host/units/:adapter/:instance/:action', async (req, res) => {
876
+ const entry = resolve(req, res, { adapter: true, instance: true });
877
+ if (!entry) return;
878
+ if (!UNIT_ACTIONS.includes(req.params.action)) return res.status(400).json({ error: 'unknown action' });
879
+ try {
880
+ const { stdout } = await entry.driver.exec(['unit', req.params.adapter, req.params.instance, req.params.action]);
881
+ res.json({ ok: true, output: stdout });
882
+ } catch (err) {
883
+ hostError(res, err);
884
+ }
885
+ });
886
+
887
+ // DELETE /she/services/hosts/:host/units/:adapter/:instance — uninstall
888
+ router.delete('/hosts/:host/units/:adapter/:instance', async (req, res) => {
889
+ const entry = resolve(req, res, { adapter: true, instance: true });
890
+ if (!entry) return;
891
+ if (req.params.instance === LEGACY) return res.status(400).json({ error: 'a legacy unit is not uninstalled from here — migrate it to an instance first', code: 'LEGACY' });
892
+ try {
893
+ stopFollower(followKey(entry.driver.name, req.params.adapter, req.params.instance));
894
+ let dynsecClient = null;
895
+ try {
896
+ dynsecClient = parseEnvFile((await entry.driver.exec(['env', req.params.adapter, req.params.instance, 'read'])).stdout)[DYNSEC_MARKER] || null;
897
+ } catch {
898
+ /* no env file */
899
+ }
900
+ const { stdout } = await entry.driver.exec(['uninstall', req.params.adapter, req.params.instance], { timeout: 60000 });
901
+ if (dynsecClient) await deleteInstanceCredentials(dynsecClient);
902
+ res.json({ ok: true, output: stdout, dynsecRemoved: Boolean(dynsecClient) });
903
+ } catch (err) {
904
+ hostError(res, err);
905
+ }
906
+ });
907
+
908
+ // GET /she/services/hosts/:host/units/:adapter/:instance/logs?n=200
909
+ router.get('/hosts/:host/units/:adapter/:instance/logs', async (req, res) => {
910
+ const entry = resolve(req, res, { adapter: true, instance: true });
911
+ if (!entry) return;
912
+ const n = Math.min(5000, Math.max(1, parseInt(req.query.n, 10) || 200));
913
+ try {
914
+ const { stdout } = await entry.driver.exec(['logs', req.params.adapter, req.params.instance, '-n', String(n)]);
915
+ res.json({ entries: parseJournal(stdout) });
916
+ } catch (err) {
917
+ hostError(res, err);
918
+ }
919
+ });
920
+
921
+ // ── journal followers: one per {host, unit}, broadcast as serviceLog, expire without renewal ──
922
+ const _followers = new Map(); // key → {child, expires, host, unit}
923
+
924
+ function followKey(host, adapter, instance) {
925
+ return host + '|' + adapter + '@' + instance;
926
+ }
927
+
928
+ function stopFollower(key) {
929
+ const f = _followers.get(key);
930
+ if (!f) return;
931
+ _followers.delete(key);
932
+ try {
933
+ f.child.kill('SIGTERM');
934
+ } catch {
935
+ /* already gone */
936
+ }
937
+ }
938
+
939
+ function sweepFollowers() {
940
+ const now = Date.now();
941
+ for (const [key, f] of _followers) if (f.expires < now) stopFollower(key);
942
+ }
943
+ const _sweeper = setInterval(sweepFollowers, 60000);
944
+ _sweeper.unref();
945
+
946
+ function stopAllFollowers() {
947
+ for (const key of [..._followers.keys()]) stopFollower(key);
948
+ }
949
+
950
+ // POST /she/services/hosts/:host/units/:adapter/:instance/logs/follow — start or renew
951
+ router.post('/hosts/:host/units/:adapter/:instance/logs/follow', (req, res) => {
952
+ const entry = resolve(req, res, { adapter: true, instance: true });
953
+ if (!entry) return;
954
+ const { adapter, instance } = req.params;
955
+ const host = entry.driver.name;
956
+ const key = followKey(host, adapter, instance);
957
+ const unit = adapter + '@' + instance;
958
+ const existing = _followers.get(key);
959
+ if (existing) {
960
+ existing.expires = Date.now() + FOLLOW_TTL;
961
+ return res.json({ ok: true, following: true, renewed: true });
962
+ }
963
+ let child;
964
+ try {
965
+ child = entry.driver.spawn(['logs', adapter, instance, '-n', '0', '--follow']);
966
+ } catch (err) {
967
+ return hostError(res, err);
968
+ }
969
+ const f = { child, expires: Date.now() + FOLLOW_TTL, host, unit };
970
+ _followers.set(key, f);
971
+ let buf = '';
972
+ child.stdout.on('data', (chunk) => {
973
+ buf += chunk.toString();
974
+ const lines = buf.split('\n');
975
+ buf = lines.pop();
976
+ for (const e of parseJournal(lines.join('\n'))) broadcast({ type: 'serviceLog', host, unit, ...e });
977
+ });
978
+ let errText = '';
979
+ child.stderr.on('data', (c) => {
980
+ errText += c.toString();
981
+ });
982
+ child.on('exit', (code) => {
983
+ if (_followers.get(key) === f) _followers.delete(key);
984
+ if (code && errText.trim()) {
985
+ broadcast({ type: 'serviceLog', host, unit, ts: Date.now(), level: 'error', msg: 'journal follower ended: ' + errText.trim(), pid: null });
986
+ }
987
+ });
988
+ child.on('error', () => {
989
+ if (_followers.get(key) === f) _followers.delete(key);
990
+ });
991
+ res.json({ ok: true, following: true, renewed: false });
992
+ });
993
+
994
+ // DELETE /she/services/hosts/:host/units/:adapter/:instance/logs/follow
995
+ router.delete('/hosts/:host/units/:adapter/:instance/logs/follow', (req, res) => {
996
+ const entry = resolve(req, res, { adapter: true, instance: true });
997
+ if (!entry) return;
998
+ stopFollower(followKey(entry.driver.name, req.params.adapter, req.params.instance));
999
+ res.json({ ok: true, following: false });
1000
+ });
1001
+
1002
+ // GET /she/services/hosts/:host/units/:adapter/:instance/env
1003
+ router.get('/hosts/:host/units/:adapter/:instance/env', async (req, res) => {
1004
+ const entry = resolve(req, res, { adapter: true, instance: true });
1005
+ if (!entry) return;
1006
+ try {
1007
+ const { stdout } = await entry.driver.exec(['env', req.params.adapter, req.params.instance, 'read']);
1008
+ const env = parseEnvFile(stdout);
1009
+ let schema = null;
1010
+ try {
1011
+ schema = await loadSchema(entry.driver, req.params.adapter);
1012
+ } catch {
1013
+ /* form falls back to raw key/value editing */
1014
+ }
1015
+ const secrets = secretEnvVars(schema, Object.keys(env));
1016
+ res.json({
1017
+ env: maskEnv(env, secrets),
1018
+ secrets: [...secrets],
1019
+ schema,
1020
+ envPrefix: envPrefixOf(schema, req.params.adapter),
1021
+ useSheBroker: env[SHE_BROKER_MARKER] === '1',
1022
+ brokerMode: brokerModeOf(env),
1023
+ sheBroker: sheBrokerInfo(entry.driver.local === true),
1024
+ dynsec: { available: dynsecAvailable(), client: env[DYNSEC_MARKER] || credentialName(req.params.instance), acl: defaultAcl(req.params.instance) },
1025
+ });
1026
+ } catch (err) {
1027
+ hostError(res, err);
1028
+ }
1029
+ });
1030
+
1031
+ // PUT /she/services/hosts/:host/units/:adapter/:instance/env { env, restart? }
1032
+ router.put('/hosts/:host/units/:adapter/:instance/env', async (req, res) => {
1033
+ const entry = resolve(req, res, { adapter: true, instance: true });
1034
+ if (!entry) return;
1035
+ const { adapter, instance } = req.params;
1036
+ try {
1037
+ const current = parseEnvFile((await entry.driver.exec(['env', adapter, instance, 'read'])).stdout);
1038
+ let merged = mergeEnv(current, req.body && req.body.env);
1039
+ let schema = null;
1040
+ try {
1041
+ schema = await loadSchema(entry.driver, adapter);
1042
+ } catch {
1043
+ /* default prefix */
1044
+ }
1045
+ merged = await applyBrokerMode(merged, {
1046
+ mode: requestedMode(req.body, brokerModeOf(current)),
1047
+ prefix: envPrefixOf(schema, adapter),
1048
+ local: entry.driver.local === true,
1049
+ instance: instance === LEGACY ? adapter : instance,
1050
+ acl: req.body && req.body.acl,
1051
+ rotate: Boolean(req.body && req.body.rotate),
1052
+ });
1053
+ const header = [
1054
+ adapter + ' instance "' + instance + '" - read by ' + adapter + '@' + instance + '.service.',
1055
+ 'Edited via she. Edit and run: systemctl restart ' + adapter + '@' + instance + '.service',
1056
+ ];
1057
+ await entry.driver.exec(['env', adapter, instance, 'write'], { stdin: formatEnvFile(merged, header) });
1058
+ let restarted = false;
1059
+ if (req.body && req.body.restart === true) {
1060
+ await entry.driver.exec(['unit', adapter, instance, 'restart']);
1061
+ restarted = true;
1062
+ }
1063
+ res.json({ ok: true, restarted });
1064
+ } catch (err) {
1065
+ hostError(res, err);
1066
+ }
1067
+ });
1068
+
1069
+ // ── I10: adapter files (map files & co.) ──────────────────────────────────────
1070
+
1071
+ const FILE_NAME_RE = /(-|^)(file|path|map)$/;
1072
+ const EXT_FORMAT = { json: 'json', yaml: 'yaml', yml: 'yaml', txt: 'text', md: 'text', conf: 'text', ini: 'text', env: 'text' };
1073
+ function formatFor(pathOrName) {
1074
+ const m = /\.([A-Za-z0-9]+)$/.exec(pathOrName || '');
1075
+ return m ? EXT_FORMAT[m[1].toLowerCase()] || null : null;
1076
+ }
1077
+ function managedDirs(adapter, instance) {
1078
+ return ['/etc/' + adapter + '/', '/var/lib/' + adapter + '/' + instance + '/'];
1079
+ }
1080
+ function isManagedPath(p, adapter, instance) {
1081
+ return typeof p === 'string' && p.startsWith('/') && !p.includes('..') && managedDirs(adapter, instance).some((d) => p.startsWith(d));
1082
+ }
1083
+ /**
1084
+ * Options that hold a user-maintained file: declared (`x-file`) or, for older adapters, guessed
1085
+ * from the option name (`…-file`, `…-path`, `…-map`) plus a known extension of the value.
1086
+ */
1087
+ function fileOptions(schema, env, adapter, instance) {
1088
+ const out = [];
1089
+ const props = schema && schema.properties ? schema.properties : {};
1090
+ for (const [key, p] of Object.entries(props)) {
1091
+ if (!p || typeof p['x-env'] !== 'string') continue;
1092
+ const value = env[p['x-env']] || '';
1093
+ let meta = null;
1094
+ if (p['x-file'] && typeof p['x-file'] === 'object') {
1095
+ meta = {
1096
+ declared: true,
1097
+ format: p['x-file'].format || 'text',
1098
+ example: p['x-file'].example || null,
1099
+ schema: p['x-file'].schema || null,
1100
+ describe: p['x-file'].describe || p.description || '',
1101
+ };
1102
+ } else if (p.type === 'string' && FILE_NAME_RE.test(key) && key !== 'mqtt-tls-ca') {
1103
+ const fmt = formatFor(value);
1104
+ if (fmt) meta = { declared: false, format: fmt, example: null, schema: null, describe: p.description || '' };
1105
+ }
1106
+ if (!meta) continue;
1107
+ out.push({ key, envName: p['x-env'], path: value || null, managed: value ? isManagedPath(value, adapter, instance) : false, editable: meta.format !== 'binary', ...meta });
1108
+ }
1109
+ return out;
1110
+ }
1111
+ /** Default location for a file she creates for an option: /etc/<adapter>/<instance>.<key>.<ext> */
1112
+ function defaultFilePath(adapter, instance, key, format) {
1113
+ const base = key.replace(/-(file|path)$/, '');
1114
+ const ext = format === 'json' ? 'json' : format === 'yaml' ? 'yaml' : 'txt';
1115
+ return '/etc/' + adapter + '/' + instance + '.' + base + '.' + ext;
1116
+ }
1117
+ function validFilePath(p) {
1118
+ return typeof p === 'string' && /^\/[^\0\n\r]+$/.test(p) && !p.includes('..');
1119
+ }
1120
+
1121
+ // GET /she/services/hosts/:host/units/:adapter/:instance/files
1122
+ router.get('/hosts/:host/units/:adapter/:instance/files', async (req, res) => {
1123
+ const entry = resolve(req, res, { adapter: true, instance: true });
1124
+ if (!entry) return;
1125
+ if (req.params.instance === LEGACY) return res.status(400).json({ error: 'legacy units have no managed directories — migrate to an instance first', code: 'LEGACY' });
1126
+ const { adapter, instance } = req.params;
1127
+ try {
1128
+ const env = parseEnvFile((await entry.driver.exec(['env', adapter, instance, 'read'])).stdout);
1129
+ let schema = null;
1130
+ try {
1131
+ schema = await loadSchema(entry.driver, adapter);
1132
+ } catch {
1133
+ /* heuristic only */
1134
+ }
1135
+ let files = [];
1136
+ try {
1137
+ files = JSON.parse((await entry.driver.exec(['files', adapter, instance])).stdout);
1138
+ if (!Array.isArray(files)) files = [];
1139
+ } catch (err) {
1140
+ if (err.code !== 'HELPER_FAILED') throw err; // older helper without `files`
1141
+ }
1142
+ const existing = new Set(files.filter((f) => f.kind === 'file').map((f) => f.path));
1143
+ const options = fileOptions(schema, env, adapter, instance).map((o) => ({ ...o, exists: o.path ? existing.has(o.path) : false }));
1144
+ res.json({
1145
+ options,
1146
+ files: files.map((f) => ({
1147
+ ...f,
1148
+ format: f.kind === 'file' ? formatFor(f.path) : null,
1149
+ editable: f.kind === 'file' && formatFor(f.path) !== null && !/\.env(\.bak)?$/.test(f.path),
1150
+ })),
1151
+ dirs: managedDirs(adapter, instance),
1152
+ });
1153
+ } catch (err) {
1154
+ hostError(res, err);
1155
+ }
1156
+ });
1157
+
1158
+ // GET /she/services/hosts/:host/units/:adapter/:instance/file?path=
1159
+ router.get('/hosts/:host/units/:adapter/:instance/file', async (req, res) => {
1160
+ const entry = resolve(req, res, { adapter: true, instance: true });
1161
+ if (!entry) return;
1162
+ const p = req.query.path;
1163
+ if (!validFilePath(p) || !isManagedPath(p, req.params.adapter, req.params.instance))
1164
+ return res.status(400).json({ error: 'path must be inside ' + managedDirs(req.params.adapter, req.params.instance).join(' or ') });
1165
+ try {
1166
+ const { stdout } = await entry.driver.exec(['file', req.params.adapter, req.params.instance, 'read', p], { timeout: 20000 });
1167
+ res.json({ path: p, content: stdout, format: formatFor(p) });
1168
+ } catch (err) {
1169
+ hostError(res, err);
1170
+ }
1171
+ });
1172
+
1173
+ // PUT /she/services/hosts/:host/units/:adapter/:instance/file { path, content, restart? }
1174
+ router.put('/hosts/:host/units/:adapter/:instance/file', async (req, res) => {
1175
+ const entry = resolve(req, res, { adapter: true, instance: true });
1176
+ if (!entry) return;
1177
+ const { adapter, instance } = req.params;
1178
+ const { path: p, content } = req.body || {};
1179
+ if (!validFilePath(p) || !isManagedPath(p, adapter, instance)) return res.status(400).json({ error: 'path must be inside ' + managedDirs(adapter, instance).join(' or ') });
1180
+ if (typeof content !== 'string') return res.status(400).json({ error: 'content (string) required' });
1181
+ if (/\.env$/.test(p)) return res.status(400).json({ error: 'the env file is edited through the config form' });
1182
+ if (content.length > 2 * 1024 * 1024) return res.status(413).json({ error: 'file too large' });
1183
+ try {
1184
+ await entry.driver.exec(['file', adapter, instance, 'write', p], { stdin: content, timeout: 20000 });
1185
+ let restarted = false;
1186
+ if (req.body.restart === true) {
1187
+ await entry.driver.exec(['unit', adapter, instance, 'restart']);
1188
+ restarted = true;
1189
+ }
1190
+ res.json({ ok: true, path: p, restarted });
1191
+ } catch (err) {
1192
+ hostError(res, err);
1193
+ }
1194
+ });
1195
+
1196
+ // GET /she/services/hosts/:host/adapters/:adapter/asset?path=
1197
+ router.get('/hosts/:host/adapters/:adapter/asset', async (req, res) => {
1198
+ const entry = resolve(req, res, { adapter: true });
1199
+ if (!entry) return;
1200
+ const p = req.query.path;
1201
+ if (typeof p !== 'string' || !/^[A-Za-z0-9_][A-Za-z0-9_./-]*$/.test(p) || p.includes('..')) return res.status(400).json({ error: 'invalid asset path' });
1202
+ try {
1203
+ const { stdout } = await entry.driver.exec(['asset', req.params.adapter, p], { timeout: 20000 });
1204
+ res.json({ path: p, content: stdout, format: formatFor(p) });
1205
+ } catch (err) {
1206
+ hostError(res, err);
1207
+ }
1208
+ });
1209
+
1210
+ // POST /she/services/hosts/:host/units/:adapter/:instance/file/create { option, path? }
1211
+ router.post('/hosts/:host/units/:adapter/:instance/file/create', async (req, res) => {
1212
+ const entry = resolve(req, res, { adapter: true, instance: true });
1213
+ if (!entry) return;
1214
+ const { adapter, instance } = req.params;
1215
+ const option = req.body && req.body.option;
1216
+ try {
1217
+ const current = parseEnvFile((await entry.driver.exec(['env', adapter, instance, 'read'])).stdout);
1218
+ let schema = null;
1219
+ try {
1220
+ schema = await loadSchema(entry.driver, adapter);
1221
+ } catch {
1222
+ /* heuristic only */
1223
+ }
1224
+ const opt = fileOptions(schema, current, adapter, instance).find((o) => o.key === option);
1225
+ if (!opt) return res.status(404).json({ error: 'not a file option: ' + option });
1226
+ const p = (req.body && req.body.path) || defaultFilePath(adapter, instance, opt.key, opt.format);
1227
+ if (!validFilePath(p) || !isManagedPath(p, adapter, instance)) return res.status(400).json({ error: 'path must be inside ' + managedDirs(adapter, instance).join(' or ') });
1228
+ let content = opt.format === 'json' ? '{}\n' : '';
1229
+ if (opt.example) {
1230
+ try {
1231
+ content = (await entry.driver.exec(['asset', adapter, opt.example])).stdout;
1232
+ } catch {
1233
+ /* no example shipped — start empty */
1234
+ }
1235
+ }
1236
+ await entry.driver.exec(['file', adapter, instance, 'write', p], { stdin: content, timeout: 20000 });
1237
+ const merged = { ...current, [opt.envName]: p };
1238
+ const header = [
1239
+ adapter + ' instance "' + instance + '" - read by ' + adapter + '@' + instance + '.service.',
1240
+ 'Edited via she. Edit and run: systemctl restart ' + adapter + '@' + instance + '.service',
1241
+ ];
1242
+ await entry.driver.exec(['env', adapter, instance, 'write'], { stdin: formatEnvFile(merged, header) });
1243
+ res.json({ ok: true, path: p, envName: opt.envName });
1244
+ } catch (err) {
1245
+ hostError(res, err);
1246
+ }
1247
+ });
1248
+
1249
+ // ── I7: adapter catalog ───────────────────────────────────────────────────────
1250
+
1251
+ const DEFAULT_PUBLISHERS = ['hobbyquaker'];
1252
+ function trustedPublishers(req) {
1253
+ const cfg = getServicesConfig(req);
1254
+ const list = Array.isArray(cfg.trustedPublishers) ? cfg.trustedPublishers : DEFAULT_PUBLISHERS;
1255
+ return list.filter(catalog.validPublisher);
1256
+ }
1257
+
1258
+ // GET /she/services/catalog[?refresh=1]
1259
+ router.get('/catalog', async (req, res) => {
1260
+ try {
1261
+ const r = await catalog.catalog(trustedPublishers(req), { force: req.query.refresh === '1' });
1262
+ res.json(r);
1263
+ } catch (err) {
1264
+ res.status(502).json({ error: 'npm registry: ' + err.message, packages: [], publishers: trustedPublishers(req), errors: [] });
1265
+ }
1266
+ });
1267
+
1268
+ // POST /she/services/hosts/:host/adapters/:adapter/install-package — only catalog members
1269
+ router.post('/hosts/:host/adapters/:adapter/install-package', async (req, res) => {
1270
+ const entry = resolve(req, res, { adapter: true });
1271
+ if (!entry) return;
1272
+ const adapter = req.params.adapter;
1273
+ let members;
1274
+ try {
1275
+ members = (await catalog.catalog(trustedPublishers(req))).packages;
1276
+ } catch (err) {
1277
+ return res.status(502).json({ error: 'npm registry: ' + err.message });
1278
+ }
1279
+ if (!members.some((p) => p.name === adapter)) {
1280
+ return res.status(403).json({ error: adapter + ' is not in the catalog (not a package of a trusted publisher depending on mqtt-interfaces-core)', code: 'NOT_IN_CATALOG' });
1281
+ }
1282
+ try {
1283
+ const { stdout } = await entry.driver.exec(['npm', adapter, 'install'], { timeout: 600000 });
1284
+ invalidateHosts();
1285
+ res.json({ ok: true, output: stdout });
1286
+ } catch (err) {
1287
+ hostError(res, err);
1288
+ }
1289
+ });
1290
+
1291
+ // GET /she/services/hosts/:host/broker-env
1292
+ router.get('/hosts/:host/broker-env', async (req, res) => {
1293
+ const entry = resolve(req, res);
1294
+ if (!entry) return;
1295
+ try {
1296
+ const env = parseEnvFile((await entry.driver.exec(['broker-env', 'read'])).stdout);
1297
+ const secrets = secretEnvVars(null, Object.keys(env));
1298
+ res.json({ env: maskEnv(env, secrets), secrets: [...secrets] });
1299
+ } catch (err) {
1300
+ hostError(res, err);
1301
+ }
1302
+ });
1303
+
1304
+ // PUT /she/services/hosts/:host/broker-env { env }
1305
+ router.put('/hosts/:host/broker-env', async (req, res) => {
1306
+ const entry = resolve(req, res);
1307
+ if (!entry) return;
1308
+ try {
1309
+ const current = parseEnvFile((await entry.driver.exec(['broker-env', 'read'])).stdout);
1310
+ const merged = mergeEnv(current, req.body && req.body.env);
1311
+ await entry.driver.exec(['broker-env', 'write'], {
1312
+ stdin: formatEnvFile(merged, ['Shared broker settings for all mqtt-interfaces adapters on this host (edited via she).']),
1313
+ });
1314
+ res.json({ ok: true });
1315
+ } catch (err) {
1316
+ hostError(res, err);
1317
+ }
1318
+ });
1319
+
1320
+ // ── I9: remote host bootstrap ─────────────────────────────────────────────────
1321
+ //
1322
+ // Settings → "Remote host setup command" mints a one-time token; the admin runs
1323
+ // curl -fsSL '<she>/she/services/setup.sh?token=…' | sudo bash
1324
+ // on the target. The script (POSIX sh, everything embedded, no downloads) creates the
1325
+ // she-services user, installs she's public key, the helper and the single sudoers rule,
1326
+ // then calls back so she adds the host entry. The script is generated when the token is
1327
+ // minted so its sha256 can be shown next to the command.
1328
+
1329
+ const SETUP_TTL = 15 * 60 * 1000;
1330
+ const _setupTokens = new Map(); // token → {script, sha256, created, fetched, done, host}
1331
+
1332
+ function sweepSetupTokens() {
1333
+ const now = Date.now();
1334
+ for (const [t, s] of _setupTokens) if (now - s.created > SETUP_TTL && !s.done) _setupTokens.delete(t);
1335
+ }
1336
+ const _setupSweeper = setInterval(sweepSetupTokens, 60000);
1337
+ _setupSweeper.unref();
1338
+
1339
+ function setupState(t) {
1340
+ const s = _setupTokens.get(t);
1341
+ if (!s) return { status: 'expired' };
1342
+ if (s.done) return { status: 'done', host: s.host };
1343
+ if (Date.now() - s.created > SETUP_TTL) return { status: 'expired' };
1344
+ return { status: s.fetched ? 'fetched' : 'pending' };
1345
+ }
1346
+
1347
+ /** POSIX sh bootstrap script for one token. */
1348
+ function buildSetupScript({ publicKey, helper, callbackUrl, token, user }) {
1349
+ if (helper.split('\n').some((l) => l === 'SHE_HELPER_EOF')) throw new Error('helper contains the heredoc delimiter');
1350
+ const q = (v) => "'" + String(v).replace(/'/g, "'\\''") + "'";
1351
+ return `#!/bin/sh
1352
+ # she — remote host setup for the Services page (roadmap I9). Generated ${new Date().toISOString()}.
1353
+ # Creates the user ${user}, installs she's SSH public key, the she-servicectl helper and its sudoers rule,
1354
+ # then tells she about this host. Idempotent; run as root.
1355
+ set -eu
1356
+ USER_NAME=${q(user)}
1357
+ PUBKEY=${q(publicKey)}
1358
+ CALLBACK=${q(callbackUrl)}
1359
+ HELPER=/usr/local/bin/she-servicectl
1360
+ SUDOERS=/etc/sudoers.d/she-services
1361
+
1362
+ if [ "$(id -u)" -ne 0 ]; then echo "she setup: run as root (sudo)" >&2; exit 1; fi
1363
+ command -v systemctl >/dev/null 2>&1 || echo "she setup: warning: systemd not found - the helper needs it to manage adapters" >&2
1364
+
1365
+ # 1. user
1366
+ if ! id -u "$USER_NAME" >/dev/null 2>&1; then
1367
+ useradd --system --create-home --home-dir "/home/$USER_NAME" --shell /bin/sh --comment "she service management" "$USER_NAME"
1368
+ echo "she setup: created user $USER_NAME"
1369
+ else
1370
+ echo "she setup: user $USER_NAME exists"
1371
+ fi
1372
+ HOME_DIR=$(getent passwd "$USER_NAME" | cut -d: -f6)
1373
+ [ -n "$HOME_DIR" ] || { echo "she setup: no home directory for $USER_NAME" >&2; exit 1; }
1374
+ [ -d "$HOME_DIR" ] || mkdir -p "$HOME_DIR"
1375
+
1376
+ # 2. ssh key
1377
+ install -d -m 700 -o "$USER_NAME" -g "$(id -gn "$USER_NAME")" "$HOME_DIR/.ssh"
1378
+ AUTH="$HOME_DIR/.ssh/authorized_keys"
1379
+ touch "$AUTH"
1380
+ if ! grep -qF "$PUBKEY" "$AUTH"; then printf '%s\\n' "$PUBKEY" >> "$AUTH"; echo "she setup: added she's public key"; else echo "she setup: public key already present"; fi
1381
+ chmod 600 "$AUTH"; chown "$USER_NAME:$(id -gn "$USER_NAME")" "$AUTH"
1382
+
1383
+ # 3. helper
1384
+ TMP=$(mktemp)
1385
+ cat > "$TMP" <<'SHE_HELPER_EOF'
1386
+ ${helper}
1387
+ SHE_HELPER_EOF
1388
+ install -m 755 -o root -g root "$TMP" "$HELPER"
1389
+ rm -f "$TMP"
1390
+ echo "she setup: installed $HELPER (v$("$HELPER" version))"
1391
+
1392
+ # 4. sudoers: exactly one rule
1393
+ printf '%s ALL=(root) NOPASSWD: %s\\n' "$USER_NAME" "$HELPER" > "$SUDOERS.tmp"
1394
+ chmod 440 "$SUDOERS.tmp"
1395
+ if command -v visudo >/dev/null 2>&1; then visudo -cf "$SUDOERS.tmp" >/dev/null; fi
1396
+ mv "$SUDOERS.tmp" "$SUDOERS"
1397
+ echo "she setup: wrote $SUDOERS"
1398
+
1399
+ # 5. tell she
1400
+ HOST_NAME=$(hostname)
1401
+ BODY=$(printf '{"hostname":"%s","user":"%s"}' "$HOST_NAME" "$USER_NAME")
1402
+ if command -v curl >/dev/null 2>&1; then
1403
+ curl -fsS -X POST -H 'Content-Type: application/json' --data "$BODY" "$CALLBACK" >/dev/null && echo "she setup: registered $HOST_NAME with she" || echo "she setup: could not reach she at $CALLBACK - add the host by hand (user $USER_NAME)" >&2
1404
+ elif command -v wget >/dev/null 2>&1; then
1405
+ wget -q -O /dev/null --header='Content-Type: application/json' --post-data="$BODY" "$CALLBACK" && echo "she setup: registered $HOST_NAME with she" || echo "she setup: could not reach she at $CALLBACK - add the host by hand (user $USER_NAME)" >&2
1406
+ else
1407
+ echo "she setup: neither curl nor wget - add the host by hand (user $USER_NAME)" >&2
1408
+ fi
1409
+ echo "she setup: done"
1410
+ `;
1411
+ }
1412
+
1413
+ // POST /she/services/setup/token { origin } → { token, command, sha256, expires }
1414
+ router.post('/setup/token', async (req, res) => {
1415
+ const origin = req.body && typeof req.body.origin === 'string' && /^https?:\/\/[^/\s]+$/.test(req.body.origin) ? req.body.origin : null;
1416
+ if (!origin) return res.status(400).json({ error: 'origin required (the URL you reach she at, e.g. http://she:8080)' });
1417
+ let publicKey;
1418
+ try {
1419
+ publicKey = fs.readFileSync(identityPath() + '.pub', 'utf8').trim();
1420
+ } catch {
1421
+ try {
1422
+ publicKey = await sshDeploy.generateKeypair(identityPath(), 'she-services');
1423
+ } catch (err) {
1424
+ return res.status(500).json({ error: 'cannot create the services SSH key: ' + err.message });
1425
+ }
1426
+ }
1427
+ let helper;
1428
+ try {
1429
+ helper = fs.readFileSync(HELPER_SOURCE, 'utf8');
1430
+ } catch (err) {
1431
+ return res.status(500).json({ error: 'helper source missing: ' + err.message });
1432
+ }
1433
+ const token = crypto.randomBytes(24).toString('hex');
1434
+ const callbackUrl = origin + '/she/services/setup/done?token=' + token;
1435
+ let script;
1436
+ try {
1437
+ script = buildSetupScript({ publicKey, helper, callbackUrl, token, user: REMOTE_USER });
1438
+ } catch (err) {
1439
+ return res.status(500).json({ error: err.message });
1440
+ }
1441
+ const sha256 = crypto.createHash('sha256').update(script).digest('hex');
1442
+ sweepSetupTokens();
1443
+ _setupTokens.set(token, { script, sha256, created: Date.now(), fetched: false, done: false, host: null });
1444
+ res.json({
1445
+ token,
1446
+ command: `curl -fsSL '${origin}/she/services/setup.sh?token=${token}' | sudo bash`,
1447
+ scriptUrl: `${origin}/she/services/setup.sh?token=${token}`,
1448
+ sha256,
1449
+ expires: Date.now() + SETUP_TTL,
1450
+ user: REMOTE_USER,
1451
+ });
1452
+ });
1453
+
1454
+ // GET /she/services/setup/token/:token → { status, host? }
1455
+ router.get('/setup/token/:token', (req, res) => {
1456
+ res.json(setupState(req.params.token));
1457
+ });
1458
+
1459
+ // GET /she/services/setup.sh?token=… (no auth) — served once
1460
+ router.get('/setup.sh', (req, res) => {
1461
+ const t = typeof req.query.token === 'string' ? req.query.token : '';
1462
+ const s = _setupTokens.get(t);
1463
+ if (!s || s.done || s.fetched || Date.now() - s.created > SETUP_TTL) {
1464
+ return res.status(410).type('text/plain').send('she: setup token unknown, used or expired - mint a new command in Settings -> Services\n');
1465
+ }
1466
+ s.fetched = true;
1467
+ res.type('text/x-shellscript').send(s.script);
1468
+ });
1469
+
1470
+ /** Address the callback came from (first X-Forwarded-For entry behind a proxy), IPv4-mapped prefix stripped. */
1471
+ function callerAddress(req) {
1472
+ const fwd = req.headers['x-forwarded-for'];
1473
+ let addr = typeof fwd === 'string' && fwd.trim() ? fwd.split(',')[0].trim() : req.socket.remoteAddress || '';
1474
+ if (addr.startsWith('::ffff:')) addr = addr.slice(7);
1475
+ return addr;
1476
+ }
1477
+
1478
+ /** Does this name resolve from the she host? Then it is the nicer ssh target than a raw address. */
1479
+ async function resolves(name) {
1480
+ try {
1481
+ await dns.promises.lookup(name);
1482
+ return true;
1483
+ } catch {
1484
+ return false;
1485
+ }
1486
+ }
1487
+
1488
+ // POST /she/services/setup/done?token=… { hostname, user } (no auth) — add the host entry
1489
+ router.post('/setup/done', async (req, res) => {
1490
+ const t = typeof req.query.token === 'string' ? req.query.token : '';
1491
+ const s = _setupTokens.get(t);
1492
+ if (!s || s.done || Date.now() - s.created > SETUP_TTL) return res.status(410).json({ error: 'setup token unknown, used or expired' });
1493
+ const hostname = req.body && typeof req.body.hostname === 'string' && /^[A-Za-z0-9_.-]{1,253}$/.test(req.body.hostname) ? req.body.hostname : null;
1494
+ const caller = callerAddress(req);
1495
+ if (!caller || !HOST_NAME_RE.test(caller)) return res.status(400).json({ error: 'cannot determine the caller address' });
1496
+ // ssh target: the host's own name when she can resolve it (readable, survives address changes), else the caller address
1497
+ const addr = hostname && (await resolves(hostname)) ? hostname : caller;
1498
+ s.done = true;
1499
+ s.host = addr;
1500
+ const configPath = req.app.locals.configPath;
1501
+ let added = false;
1502
+ if (configPath) {
1503
+ try {
1504
+ let cfg = {};
1505
+ try {
1506
+ cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1507
+ } catch {
1508
+ /* new file */
1509
+ }
1510
+ if (!cfg.services || typeof cfg.services !== 'object') cfg.services = {};
1511
+ if (!Array.isArray(cfg.services.hosts)) cfg.services.hosts = [{ name: 'local' }];
1512
+ const exists = cfg.services.hosts.find((h) => h && h.ssh && (h.ssh.host === addr || h.ssh.host === caller || (hostname && h.ssh.host === hostname)));
1513
+ if (exists) {
1514
+ exists.ssh.user = REMOTE_USER;
1515
+ exists.ssh.host = addr; // a re-run upgrades an address-only entry to the name
1516
+ if (hostname) exists.hostname = hostname;
1517
+ } else {
1518
+ cfg.services.hosts.push({ ...(hostname ? { hostname } : {}), ssh: { host: addr, user: REMOTE_USER } });
1519
+ added = true;
1520
+ }
1521
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
1522
+ invalidateHosts();
1523
+ } catch (err) {
1524
+ return res.status(500).json({ error: 'cannot update config: ' + err.message });
1525
+ }
1526
+ }
1527
+ res.json({ ok: true, host: addr, hostname, user: REMOTE_USER, added });
1528
+ });
1529
+
1530
+ module.exports = {
1531
+ router,
1532
+ init,
1533
+ setIdentityPath,
1534
+ setDynsec,
1535
+ defaultAcl,
1536
+ fileOptions,
1537
+ defaultFilePath,
1538
+ DEFAULT_PUBLISHERS,
1539
+ credentialName,
1540
+ DYNSEC_MARKER,
1541
+ buildSetupScript,
1542
+ REMOTE_USER,
1543
+ getServicesConfig,
1544
+ validInstance,
1545
+ validAdapter,
1546
+ setDriverFactory,
1547
+ stopAllFollowers,
1548
+ invalidateHosts,
1549
+ mergeEnv,
1550
+ maskEnv,
1551
+ sheBrokerSettings,
1552
+ applySheBroker,
1553
+ SHE_BROKER_MARKER,
1554
+ SERVICES_IDENTITY,
1555
+ };