iobroker.bee 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/main.js ADDED
@@ -0,0 +1,835 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ const utils = __importStar(require("@iobroker/adapter-core"));
40
+ const node_fs_1 = require("node:fs");
41
+ const node_os_1 = __importDefault(require("node:os"));
42
+ const node_path_1 = require("node:path");
43
+ const commands_1 = require("./lib/commands");
44
+ const console_1 = require("./lib/console");
45
+ const enrollment_1 = require("./lib/enrollment");
46
+ const inventory_1 = require("./lib/inventory");
47
+ const jobs_1 = require("./lib/jobs");
48
+ const packages_1 = require("./lib/packages");
49
+ const planner_1 = require("./lib/planner");
50
+ const preflight_1 = require("./lib/preflight");
51
+ const protocol_1 = require("./lib/protocol");
52
+ const remote_1 = require("./lib/remote");
53
+ const transport_1 = require("./lib/transport");
54
+ const tunnel_1 = require("./lib/tunnel");
55
+ const util_1 = require("./lib/util");
56
+ class BeeAdapter extends utils.Adapter {
57
+ dataDir = '';
58
+ creds = null;
59
+ transport = null;
60
+ jobs = null;
61
+ desired = null;
62
+ control = null;
63
+ persisted = { results: {}, lastResult: null, lastBackup: null, notified: {} };
64
+ timers = [];
65
+ enrollTimer = null;
66
+ enrollBackoff = 30_000;
67
+ seq = 0;
68
+ lastFlat = null;
69
+ uuid = '';
70
+ errorLog = [];
71
+ errorsSinceHeartbeat = 0;
72
+ reconciling = false;
73
+ /** A command of the fleet management is being carried out (one at a time) */
74
+ commandRunning = false;
75
+ lastWaitReason = '';
76
+ unloading = false;
77
+ /** Open remote access sessions by session id */
78
+ tunnels = new Map();
79
+ /** Running shells of the console per session */
80
+ shells = new Map();
81
+ constructor(options = {}) {
82
+ super({ ...options, name: 'bee' });
83
+ this.on('ready', () => this.onReady());
84
+ this.on('stateChange', (id, state) => this.onStateChange(id, state));
85
+ this.on('unload', callback => void this.onUnload(callback));
86
+ this.on('log', (msg) => this.onLog(msg));
87
+ }
88
+ // ------------------------------------------------------------------ lifecycle
89
+ async onReady() {
90
+ this.dataDir = utils.getAbsoluteInstanceDataDir(this);
91
+ (0, node_fs_1.mkdirSync)(this.dataDir, { recursive: true });
92
+ await this.setState('info.connection', false, true);
93
+ const meta = await this.getForeignObjectAsync('system.meta.uuid');
94
+ this.uuid = meta?.native?.uuid || '';
95
+ this.persisted = { ...this.persisted, ...(await (0, util_1.readJson)((0, node_path_1.join)(this.dataDir, 'state.json'))) };
96
+ this.desired = await (0, util_1.readJson)((0, node_path_1.join)(this.dataDir, 'desired.json'));
97
+ this.control = await (0, util_1.readJson)((0, node_path_1.join)(this.dataDir, 'control.json'));
98
+ this.jobs = new jobs_1.JobManager({
99
+ dataDir: this.dataDir,
100
+ iobrokerJs: (0, node_path_1.join)(utils.controllerDir, 'iobroker.js'),
101
+ cwd: (0, node_path_1.join)(utils.controllerDir, '..', '..'),
102
+ log: this.log,
103
+ verifySeconds: this.config.verifySeconds || 120,
104
+ startTimeoutSeconds: this.config.startTimeoutSeconds || 300,
105
+ report: job => this.reportJob(job),
106
+ installed: async () => (0, inventory_1.installedVersions)(await this.inventory()),
107
+ instancesOf: adapter => this.instancesOf(adapter),
108
+ errorsSince: (ts, adapters) => this.errorLog.filter(e => e.ts >= ts && adapters.some(a => e.from.startsWith(`${a}.`))),
109
+ onFinished: (job, result) => this.onJobFinished(job, result),
110
+ });
111
+ await this.jobs.load();
112
+ await this.jobs.setHalt(!!this.control?.halt);
113
+ // count error log lines of all instances (verification and heartbeat)
114
+ await this.requireLog?.(true);
115
+ await this.subscribeStatesAsync('control.*');
116
+ await this.updateInfoStates();
117
+ this.creds = await (0, util_1.readJson)((0, node_path_1.join)(this.dataDir, 'device.json'));
118
+ await this.updateAccessStates();
119
+ if (this.creds) {
120
+ this.startTransport();
121
+ // sessions that were open before a restart (e.g. after a change of the settings)
122
+ for (const t of (await (0, util_1.readJson)((0, node_path_1.join)(this.dataDir, 'tunnels.json'))) || []) {
123
+ await this.openTunnel(t, true);
124
+ }
125
+ }
126
+ else {
127
+ await this.tryEnroll();
128
+ }
129
+ this.timers.push(setInterval(() => void this.jobs?.poll(), 2000));
130
+ this.timers.push(setInterval(() => void this.heartbeat(), (this.config.heartbeatMinutes || 60) * 60_000));
131
+ this.timers.push(setInterval(() => {
132
+ void this.sendInventory(false);
133
+ void this.reconcile();
134
+ }, (this.config.checkMinutes || 5) * 60_000));
135
+ }
136
+ async onUnload(callback) {
137
+ this.unloading = true;
138
+ try {
139
+ this.timers.forEach(t => clearInterval(t));
140
+ if (this.enrollTimer) {
141
+ clearTimeout(this.enrollTimer);
142
+ }
143
+ // the sessions stay in tunnels.json and are resumed after the restart
144
+ for (const client of [...this.tunnels.values()]) {
145
+ client.stop('agent stops');
146
+ }
147
+ // js-controller kills slow adapters; the Last Will covers a disconnect that does not finish in time
148
+ await Promise.race([this.transport?.stop(), new Promise(resolve => setTimeout(resolve, 800))]);
149
+ await this.setState('info.connection', false, true);
150
+ }
151
+ finally {
152
+ callback();
153
+ }
154
+ }
155
+ async savePersisted() {
156
+ await (0, util_1.writeJsonAtomic)((0, node_path_1.join)(this.dataDir, 'state.json'), this.persisted);
157
+ }
158
+ // ------------------------------------------------------------------ enrollment
159
+ async stopWithReason(reason) {
160
+ this.log.error(`Beekeeper rejected this device: ${reason}. The agent stops.`);
161
+ await this.setState('info.status', `Abgewiesen: ${reason}`, true);
162
+ await this.notify('rejected', `Die Flottenverwaltung hat dieses Gerät abgewiesen: ${reason}`);
163
+ // explicit rejection: stop instead of knocking again; js-controller does not restart us
164
+ this.terminate(reason, utils.EXIT_CODES.ADAPTER_REQUESTED_TERMINATION);
165
+ }
166
+ async tryEnroll() {
167
+ if (!this.config.enrollUrl || !this.config.enrollToken) {
168
+ await this.setState('info.status', 'Nicht konfiguriert: Enrollment-URL und Token fehlen', true);
169
+ this.log.warn('Enrollment URL or token missing, please configure the adapter');
170
+ return;
171
+ }
172
+ await this.setState('info.status', 'Anmeldung beim Server …', true);
173
+ const result = await (0, enrollment_1.enroll)(this.config.enrollUrl, {
174
+ token: this.config.enrollToken,
175
+ uuid: this.uuid,
176
+ hostname: node_os_1.default.hostname(),
177
+ agentVersion: this.version,
178
+ comment: (0, protocol_1.normalizeComment)(this.config.comment),
179
+ });
180
+ if (result.kind === 'ok') {
181
+ this.creds = result.creds;
182
+ await (0, util_1.writeJsonAtomic)((0, node_path_1.join)(this.dataDir, 'device.json'), result.creds);
183
+ this.log.info(`Enrolled as ${result.creds.deviceId} (asset ${result.creds.assetId})`);
184
+ await this.updateInfoStates();
185
+ this.startTransport();
186
+ }
187
+ else if (result.kind === 'rejected') {
188
+ await this.stopWithReason(result.reason);
189
+ }
190
+ else {
191
+ // network or server trouble is no reason to stop: retry with growing distance
192
+ const delay = this.enrollBackoff;
193
+ this.enrollBackoff = Math.min(this.enrollBackoff * 2, 24 * 3600_000);
194
+ this.log.warn(`Enrollment failed (${result.reason}), retry in ${Math.round(delay / 1000)} s`);
195
+ await this.setState('info.status', `Anmeldung fehlgeschlagen, neuer Versuch in ${Math.round(delay / 60000)} min`, true);
196
+ this.enrollTimer = setTimeout(() => {
197
+ this.enrollTimer = null;
198
+ void this.tryEnroll();
199
+ }, delay);
200
+ }
201
+ }
202
+ // ------------------------------------------------------------------ transport
203
+ startTransport() {
204
+ const transport = new transport_1.Transport(this.creds, { keepalive: this.config.keepaliveSeconds || 300, log: this.log });
205
+ this.transport = transport;
206
+ transport.on('connected', () => void this.onConnected());
207
+ transport.on('disconnected', () => {
208
+ void this.setState('info.connection', false, true);
209
+ });
210
+ transport.on('desired', msg => void this.onDesired(msg));
211
+ transport.on('control', msg => void this.onControl(msg));
212
+ transport.on('job', msg => void this.onJob(msg));
213
+ transport.on('rejected', reason => void this.stopWithReason(reason));
214
+ transport.start();
215
+ }
216
+ async onConnected() {
217
+ this.log.info('Connected to beekeeper');
218
+ await this.setState('info.connection', true, true);
219
+ await this.setState('info.status', 'Verbunden', true);
220
+ await this.heartbeat();
221
+ await this.sendInventory(true);
222
+ // results produced while offline
223
+ if (this.jobs?.job) {
224
+ await this.reportJob(this.jobs.job);
225
+ }
226
+ else if (this.persisted.lastResult) {
227
+ await this.transport?.publish('result', this.persisted.lastResult);
228
+ }
229
+ }
230
+ async onDesired(msg) {
231
+ this.desired = msg;
232
+ await (0, util_1.writeJsonAtomic)((0, node_path_1.join)(this.dataDir, 'desired.json'), msg);
233
+ this.log.info(msg?.profile
234
+ ? `Desired state: ${msg.profile.name} (${msg.mode}${msg.retry ? `, retry ${msg.retry}` : ''})`
235
+ : 'No desired state');
236
+ await this.updateInfoStates();
237
+ this.lastWaitReason = '';
238
+ await this.reconcile();
239
+ }
240
+ async onControl(msg) {
241
+ // log only real changes, not the retained state delivered on every connect
242
+ const changed = msg.halt !== !!this.control?.halt;
243
+ this.control = msg;
244
+ await (0, util_1.writeJsonAtomic)((0, node_path_1.join)(this.dataDir, 'control.json'), msg);
245
+ await this.jobs?.setHalt(msg.halt);
246
+ await this.setState('control.halted', msg.halt, true);
247
+ if (changed) {
248
+ this.log.warn(msg.halt ? `Emergency stop: ${msg.reason || 'no reason given'}` : 'Emergency stop released');
249
+ if (!msg.halt) {
250
+ await this.reconcile();
251
+ }
252
+ }
253
+ }
254
+ async onJob(msg) {
255
+ if (msg.cmd === 'resync' || msg.cmd === 'refresh') {
256
+ await this.sendInventory(true);
257
+ }
258
+ else if (msg.cmd === 'tunnel' && msg.tunnel) {
259
+ await this.openTunnel(msg.tunnel, false);
260
+ }
261
+ else if (msg.ref && (msg.cmd === 'instance' || msg.cmd === 'restart' || msg.cmd === 'osUpgrade')) {
262
+ await this.runCommand(msg);
263
+ }
264
+ }
265
+ // ------------------------------------------------------------------ commands of the fleet management
266
+ async reply(ref, state, message) {
267
+ const msg = { v: protocol_1.PROTOCOL_VERSION, ts: Date.now(), ref, state, ...(message ? { message } : {}) };
268
+ await this.transport?.publish('reply', msg);
269
+ }
270
+ /** Sends the inventory again a while after a command, when the instances show its effect */
271
+ inventorySoon() {
272
+ for (const ms of [5_000, 30_000]) {
273
+ this.timers.push(setTimeout(() => void this.sendInventory(false), ms));
274
+ }
275
+ }
276
+ async runCommand(msg) {
277
+ const ref = msg.ref;
278
+ const by = msg.by || 'Flottenverwaltung';
279
+ if (this.reconciling || this.jobs?.isActive()) {
280
+ await this.reply(ref, 'error', 'Während eines Auftrags (Update) nicht möglich');
281
+ return;
282
+ }
283
+ if (this.commandRunning) {
284
+ await this.reply(ref, 'error', 'Es läuft schon ein Befehl');
285
+ return;
286
+ }
287
+ this.commandRunning = true;
288
+ try {
289
+ if (msg.cmd === 'instance') {
290
+ await this.instanceCommand(ref, by, msg.instance);
291
+ }
292
+ else if (msg.cmd === 'restart') {
293
+ this.log.warn(`Restart of ioBroker on request of ${by} (fleet management)`);
294
+ // the answer goes out first, the restart takes the agent down too
295
+ await this.reply(ref, 'done', 'ioBroker startet neu');
296
+ this.sendToHost(`system.host.${this.host}`, 'restartController', {});
297
+ }
298
+ else if (msg.cmd === 'osUpgrade') {
299
+ await this.osUpgradeCommand(ref, by, msg.osUpgrade);
300
+ }
301
+ }
302
+ catch (e) {
303
+ this.log.warn(`Command ${msg.cmd} of ${by} failed: ${e.message}`);
304
+ await this.reply(ref, 'error', e.message);
305
+ }
306
+ finally {
307
+ this.commandRunning = false;
308
+ }
309
+ }
310
+ /** Start, stop or restart an instance on this host, the same way admin does it */
311
+ async instanceCommand(ref, by, req) {
312
+ const id = req?.id;
313
+ const action = req?.action;
314
+ const objId = `system.adapter.${id}`;
315
+ const obj = typeof id === 'string' && /^[\w.-]+$/.test(id) ? await this.getForeignObjectAsync(objId) : null;
316
+ const refusal = (0, commands_1.instanceRefusal)(id, action, this.namespace, obj, this.host || '');
317
+ if (refusal || !req) {
318
+ await this.reply(ref, 'error', refusal || 'Instanz fehlt');
319
+ return;
320
+ }
321
+ this.log.info(`Instance ${req.id}: ${req.action} on request of ${by} (fleet management)`);
322
+ const own = req.id === this.namespace;
323
+ if (own) {
324
+ // a restart of this instance ends this process: answer first
325
+ await this.reply(ref, 'done', `${req.id} startet neu`);
326
+ }
327
+ await this.extendForeignObjectAsync(objId, req.action === 'restart' ? {} : { common: { enabled: req.action === 'start' } });
328
+ if (!own) {
329
+ await this.reply(ref, 'done', `${req.id} ${commands_1.ACTION_DONE[req.action]}`);
330
+ this.inventorySoon();
331
+ }
332
+ }
333
+ /** Upgrade OS packages through js-controller, only those it listed itself */
334
+ async osUpgradeCommand(ref, by, req) {
335
+ const host = `system.host.${this.host}`;
336
+ const hostObj = await this.getForeignObjectAsync(host);
337
+ if (!(0, commands_1.supportedCommands)(String(hostObj?.common?.installedVersion || '')).includes('osUpgrade')) {
338
+ await this.reply(ref, 'error', 'OS-Updates brauchen Linux und js-controller ab 6');
339
+ return;
340
+ }
341
+ const listed = (0, commands_1.osUpdatesFrom)(await this.getForeignStateAsync(`${host}.osPackageUpdates`), false)?.packages ?? [];
342
+ const { packages, unknown } = (0, commands_1.chooseOsPackages)(listed, req?.packages);
343
+ if (!packages.length || unknown.length) {
344
+ await this.reply(ref, 'error', unknown.length ? `Nicht als aktualisierbar gelistet: ${unknown.join(', ')}` : 'Keine Pakete angegeben');
345
+ return;
346
+ }
347
+ const names = packages.map(p => p.name).join(', ');
348
+ this.log.warn(`Upgrade of OS packages ${names} on request of ${by} (fleet management)`);
349
+ await this.reply(ref, 'running', `apt: ${names}`);
350
+ // js-controller runs apt (with sudo if it is not root) and answers when it is through
351
+ const answer = (await Promise.race([
352
+ this.sendToHostAsync(host, 'upgradeOsPackages', {
353
+ packages: packages.map(p => ({ name: p.name })),
354
+ restart: !!req?.restart,
355
+ }),
356
+ new Promise(resolve => {
357
+ this.timers.push(setTimeout(() => resolve({ error: 'Keine Antwort von js-controller nach 30 Minuten' }), 30 * 60_000));
358
+ }),
359
+ ]));
360
+ if (answer?.success) {
361
+ this.log.info(`OS packages upgraded: ${names}`);
362
+ await this.reply(ref, 'done', `${packages.length} ${packages.length === 1 ? 'Paket' : 'Pakete'} aktualisiert${req?.restart ? ', ioBroker startet neu' : ''}`);
363
+ this.inventorySoon();
364
+ }
365
+ else {
366
+ const error = answer?.error || 'js-controller meldet einen Fehler';
367
+ this.log.warn(`OS upgrade failed: ${error}`);
368
+ await this.reply(ref, 'error', error);
369
+ }
370
+ }
371
+ // ------------------------------------------------------------------ remote access
372
+ /** Kinds of remote access the owner allows in the instance settings */
373
+ allowedAccess() {
374
+ const kinds = [];
375
+ if (this.config.allowAdmin !== false) {
376
+ kinds.push('admin');
377
+ }
378
+ if (this.config.allowVis !== false) {
379
+ kinds.push('vis');
380
+ }
381
+ if (this.config.allowConsole === true && (0, console_1.findScript)()) {
382
+ kinds.push('console');
383
+ }
384
+ return kinds;
385
+ }
386
+ /** One shell for a stream of a console session, at most MAX_SHELLS at the same time */
387
+ openShell(t, stream) {
388
+ const running = this.shells.get(t.id) ?? 0;
389
+ if (running >= console_1.MAX_SHELLS) {
390
+ stream.end(Buffer.concat([
391
+ (0, tunnel_1.consoleMessage)(tunnel_1.CONSOLE.DATA, Buffer.from(`Höchstens ${console_1.MAX_SHELLS} Shells je Sitzung.\r\n`)),
392
+ (0, tunnel_1.consoleMessage)(tunnel_1.CONSOLE.EXIT, Buffer.from('zu viele Shells')),
393
+ ]));
394
+ return;
395
+ }
396
+ this.shells.set(t.id, running + 1);
397
+ void this.updateAccessStates();
398
+ (0, console_1.startShell)(stream, {
399
+ cwd: (0, node_path_1.join)(utils.controllerDir, '..', '..'),
400
+ log: this.log,
401
+ by: t.by,
402
+ onEnd: () => {
403
+ const n = (this.shells.get(t.id) ?? 1) - 1;
404
+ if (n > 0) {
405
+ this.shells.set(t.id, n);
406
+ }
407
+ else {
408
+ this.shells.delete(t.id);
409
+ }
410
+ if (!this.unloading) {
411
+ void this.updateAccessStates();
412
+ }
413
+ },
414
+ });
415
+ }
416
+ /**
417
+ * Opens the tunnel of an access session, or refuses it with a reason the operator sees.
418
+ *
419
+ * @param t request of the server
420
+ * @param resumed the session was open before a restart of the agent
421
+ */
422
+ async openTunnel(t, resumed) {
423
+ if (this.tunnels.has(t.id) || t.expiresAt <= Date.now() || this.unloading) {
424
+ // repeated delivery of the job, or over
425
+ return;
426
+ }
427
+ if (!(0, remote_1.trustedTunnelUrl)(t.url, [this.config.enrollUrl, this.creds?.mqttUrl || ''])) {
428
+ this.log.warn(`Remote access: tunnel URL ${t.url} does not belong to the fleet server, ignored`);
429
+ return;
430
+ }
431
+ let refusal = null;
432
+ let target = null;
433
+ const isConsole = t.kind === 'console';
434
+ if (!this.allowedAccess().includes(t.kind)) {
435
+ refusal = !isConsole
436
+ ? `Fernzugriff auf ${remote_1.ACCESS_LABEL[t.kind]} ist am Gerät abgeschaltet`
437
+ : this.config.allowConsole === true
438
+ ? 'Die Konsole braucht Linux mit script (util-linux)'
439
+ : 'Die Konsole ist am Gerät nicht freigegeben';
440
+ }
441
+ else if (isConsole) {
442
+ if (this.tunnels.size >= 4) {
443
+ refusal = 'Zu viele gleichzeitige Fernzugriffe auf dem Gerät';
444
+ }
445
+ }
446
+ else {
447
+ // only an instance of this kind on this host, whatever the server asks for
448
+ target = (0, remote_1.chooseTarget)(await (0, remote_1.findTargets)(this, t.kind), t.instance);
449
+ if (!target) {
450
+ refusal = t.instance
451
+ ? `Instanz ${t.instance} ist auf dem Gerät nicht aktiv`
452
+ : `Keine aktive ${t.kind === 'admin' ? 'admin' : 'web'}-Instanz auf dem Gerät`;
453
+ }
454
+ else if (this.tunnels.size >= 4) {
455
+ refusal = 'Zu viele gleichzeitige Fernzugriffe auf dem Gerät';
456
+ }
457
+ }
458
+ const client = new remote_1.TunnelClient(t, {
459
+ target,
460
+ ...(isConsole && !refusal ? { handler: (stream) => this.openShell(t, stream) } : {}),
461
+ refusal,
462
+ ca: (0, transport_1.trustedCas)(this.creds?.ca || ''),
463
+ log: this.log,
464
+ onEnd: (c, reason) => {
465
+ if (this.tunnels.get(t.id) === c) {
466
+ this.tunnels.delete(t.id);
467
+ this.shells.delete(t.id);
468
+ this.log.info(`Remote access to ${remote_1.ACCESS_LABEL[t.kind]} by ${t.by} ended (${reason})`);
469
+ }
470
+ if (!this.unloading) {
471
+ void this.saveTunnels();
472
+ void this.updateAccessStates();
473
+ }
474
+ },
475
+ });
476
+ if (refusal) {
477
+ this.log.warn(`Remote access to ${remote_1.ACCESS_LABEL[t.kind]} by ${t.by} refused: ${refusal}`);
478
+ }
479
+ else {
480
+ this.tunnels.set(t.id, client);
481
+ this.log.info(`Remote access to ${remote_1.ACCESS_LABEL[t.kind]}${target ? ` (${target.instance})` : ''} ${resumed ? 'resumed' : 'opened'} by ${t.by} until ${new Date(t.expiresAt).toLocaleTimeString()}: ${t.purpose}`);
482
+ }
483
+ client.start();
484
+ await this.saveTunnels();
485
+ await this.updateAccessStates();
486
+ }
487
+ async saveTunnels() {
488
+ await (0, util_1.writeJsonAtomic)((0, node_path_1.join)(this.dataDir, 'tunnels.json'), [...this.tunnels.values()].map(c => c.req));
489
+ }
490
+ /** " (2 Shells offen)" for a console */
491
+ shellText(t) {
492
+ if (t.kind !== 'console') {
493
+ return '';
494
+ }
495
+ const n = this.shells.get(t.id) ?? 0;
496
+ return n ? ` (${n} ${n === 1 ? 'Shell' : 'Shells'} offen)` : '';
497
+ }
498
+ /** Shows the owner who is connected right now, and why */
499
+ async updateAccessStates() {
500
+ const active = [...this.tunnels.values()].map(c => c.req);
501
+ await this.setState('info.remoteAccess', active.length > 0, true);
502
+ await this.setState('info.remoteAccessBy', active
503
+ .map(t => `${remote_1.ACCESS_LABEL[t.kind]}${t.instance ? ` (${t.instance})` : ''}${this.shellText(t)}: ${t.by}, ${t.purpose} (bis ${new Date(t.expiresAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`)
504
+ .join('; '), true);
505
+ }
506
+ // ------------------------------------------------------------------ inventory and heartbeat
507
+ async inventory() {
508
+ const inv = await (0, inventory_1.collectInventory)(this, {
509
+ agentVersion: this.version || '',
510
+ dataDir: this.dataDir,
511
+ lastBackup: this.persisted.lastBackup,
512
+ });
513
+ inv.uuid = this.uuid;
514
+ const comment = (0, protocol_1.normalizeComment)(this.config.comment);
515
+ if (comment) {
516
+ inv.comment = comment;
517
+ }
518
+ // also tells the server that this agent can open a tunnel at all
519
+ inv.remoteAccess = this.allowedAccess();
520
+ inv.accessTargets = {
521
+ admin: (0, remote_1.describeTargets)(await (0, remote_1.findTargets)(this, 'admin')),
522
+ vis: (0, remote_1.describeTargets)(await (0, remote_1.findTargets)(this, 'vis')),
523
+ };
524
+ if (inv.remoteAccess.includes('console')) {
525
+ inv.consoleUser = (0, console_1.consoleUser)();
526
+ }
527
+ inv.commands = (0, commands_1.supportedCommands)(inv.software.jsController);
528
+ if (inv.commands.includes('osUpgrade')) {
529
+ const updates = (0, commands_1.osUpdatesFrom)(await this.getForeignStateAsync(`system.host.${this.host}.osPackageUpdates`), (0, node_fs_1.existsSync)(commands_1.REBOOT_REQUIRED));
530
+ if (updates) {
531
+ inv.osUpdates = updates;
532
+ }
533
+ }
534
+ return inv;
535
+ }
536
+ async sendInventory(full) {
537
+ if (!this.transport?.connected) {
538
+ return;
539
+ }
540
+ const inv = await this.inventory();
541
+ const flat = (0, util_1.flatten)(inv);
542
+ if (full || !this.lastFlat) {
543
+ this.seq++;
544
+ await this.transport.publish('inventory', {
545
+ v: protocol_1.PROTOCOL_VERSION,
546
+ ts: Date.now(),
547
+ seq: this.seq,
548
+ type: 'full',
549
+ data: inv,
550
+ });
551
+ }
552
+ else {
553
+ const { set, unset } = (0, util_1.diffFlat)(this.lastFlat, flat);
554
+ if (!Object.keys(set).length && !unset.length) {
555
+ return;
556
+ }
557
+ this.seq++;
558
+ await this.transport.publish('inventory', {
559
+ v: protocol_1.PROTOCOL_VERSION,
560
+ ts: Date.now(),
561
+ seq: this.seq,
562
+ type: 'diff',
563
+ baseSeq: this.seq - 1,
564
+ set,
565
+ unset,
566
+ });
567
+ }
568
+ this.lastFlat = flat;
569
+ }
570
+ async heartbeat() {
571
+ const disk = await (0, inventory_1.diskInfo)(this.dataDir);
572
+ let signal = null;
573
+ if (this.config.signalStateId) {
574
+ const s = await this.getForeignStateAsync(this.config.signalStateId);
575
+ signal = typeof s?.val === 'number' ? s.val : null;
576
+ }
577
+ const msg = {
578
+ v: protocol_1.PROTOCOL_VERSION,
579
+ ts: Date.now(),
580
+ online: true,
581
+ agentVersion: this.version,
582
+ uptime: Math.round(node_os_1.default.uptime()),
583
+ load: Math.round(node_os_1.default.loadavg()[0] * 100) / 100,
584
+ memFree: node_os_1.default.freemem(),
585
+ diskFree: disk?.free,
586
+ temperature: (await (0, inventory_1.cpuTemperature)()) ?? undefined,
587
+ logErrors: this.errorsSinceHeartbeat,
588
+ jobState: this.jobs?.job?.state,
589
+ signal,
590
+ sdWrites: await (0, inventory_1.sdWrites)(),
591
+ };
592
+ this.errorsSinceHeartbeat = 0;
593
+ await this.transport?.publish('state', msg);
594
+ }
595
+ onLog(msg) {
596
+ if (msg.severity !== 'error' || msg.from?.startsWith(`${this.namespace}`)) {
597
+ return;
598
+ }
599
+ this.errorsSinceHeartbeat++;
600
+ this.errorLog.push({ ts: Date.now(), from: msg.from, message: String(msg.message) });
601
+ if (this.errorLog.length > 500) {
602
+ this.errorLog.splice(0, this.errorLog.length - 500);
603
+ }
604
+ }
605
+ async instancesOf(adapter) {
606
+ const res = await this.getObjectViewAsync('system', 'instance', {
607
+ startkey: `system.adapter.${adapter}.`,
608
+ endkey: `system.adapter.${adapter}.香`,
609
+ });
610
+ const list = [];
611
+ for (const row of res.rows) {
612
+ const obj = row.value;
613
+ if (!obj?.common.enabled || obj.common.host !== this.host) {
614
+ continue;
615
+ }
616
+ // scheduled instances are not running permanently
617
+ if (obj.common.mode !== 'daemon') {
618
+ continue;
619
+ }
620
+ const alive = await this.getForeignStateAsync(`${obj._id}.alive`);
621
+ list.push({ id: obj._id.replace('system.adapter.', ''), alive: !!alive?.val });
622
+ }
623
+ return list;
624
+ }
625
+ // ------------------------------------------------------------------ reconciliation
626
+ async report(state, detail, steps) {
627
+ const d = this.desired;
628
+ if (!d?.profile) {
629
+ return;
630
+ }
631
+ const msg = {
632
+ v: protocol_1.PROTOCOL_VERSION,
633
+ ts: Date.now(),
634
+ profileId: d.profile.id,
635
+ profileHash: d.profile.hash,
636
+ retry: d.retry,
637
+ state,
638
+ detail,
639
+ steps,
640
+ };
641
+ this.persisted.lastResult = msg;
642
+ await this.savePersisted();
643
+ await this.setState('job.state', state, true);
644
+ await this.setState('job.detail', detail, true);
645
+ await this.transport?.publish('result', msg);
646
+ }
647
+ async reportJob(job) {
648
+ const msg = {
649
+ v: protocol_1.PROTOCOL_VERSION,
650
+ ts: Date.now(),
651
+ profileId: job.profileId,
652
+ profileHash: job.profileHash,
653
+ retry: job.retry,
654
+ state: job.state,
655
+ detail: job.detail,
656
+ steps: job.steps,
657
+ };
658
+ this.persisted.lastResult = msg;
659
+ await this.savePersisted();
660
+ await this.setState('job.state', job.state, true);
661
+ await this.setState('job.detail', job.detail, true);
662
+ await this.setState('job.steps', JSON.stringify(job.steps), true);
663
+ await this.transport?.publish('result', msg);
664
+ }
665
+ async onJobFinished(job, result) {
666
+ if (result !== 'waiting') {
667
+ this.persisted.results[job.key] = result;
668
+ }
669
+ const backup = job.steps.find(s => s.action === 'backup' && s.status === 'ok');
670
+ if (backup?.detail) {
671
+ this.persisted.lastBackup = { ts: backup.ts || Date.now(), file: backup.detail, size: 0 };
672
+ }
673
+ await this.savePersisted();
674
+ this.log.info(`Job ${job.profileName}: ${result} (${job.detail})`);
675
+ if (result === 'done') {
676
+ await this.notify('updated', `Das System wurde auf den Stand ${job.profileName} gebracht.`);
677
+ }
678
+ await this.sendInventory(false);
679
+ await this.updateInfoStates();
680
+ // the desired state may have changed while the job was running
681
+ setTimeout(() => void this.reconcile(), 1000);
682
+ }
683
+ /** Converges on the desired state if possible. Never runs two jobs at once. */
684
+ async reconcile() {
685
+ if (this.reconciling || this.unloading || !this.jobs || this.jobs.isActive()) {
686
+ return;
687
+ }
688
+ this.reconciling = true;
689
+ try {
690
+ await this.reconcileInner();
691
+ }
692
+ catch (e) {
693
+ this.log.error(`Reconcile: ${e.stack || e.message}`);
694
+ }
695
+ finally {
696
+ this.reconciling = false;
697
+ }
698
+ }
699
+ async reconcileInner() {
700
+ const d = this.desired;
701
+ if (!d?.profile) {
702
+ return;
703
+ }
704
+ const inv = await this.inventory();
705
+ const installed = (0, inventory_1.installedVersions)(inv);
706
+ const key = `${d.profile.hash}:${d.retry}`;
707
+ // a package with the version number of what is installed: once per job, never in a loop
708
+ const from = this.persisted.results[key] === 'done'
709
+ ? undefined
710
+ : Object.fromEntries(Object.entries(inv.software.adapters).map(([n, a]) => [n, a.installedFrom]));
711
+ const plan = (0, planner_1.computePlan)(d, installed, this.name, from);
712
+ await this.setState('info.onTarget', plan.length === 0, true);
713
+ if (d.mode === 'self') {
714
+ // self-managed: no automatic rollout, only a hint about the new state
715
+ if (plan.length && !this.persisted.notified[`update:${d.profile.hash}`]) {
716
+ this.persisted.notified[`update:${d.profile.hash}`] = Date.now();
717
+ await this.savePersisted();
718
+ await this.notify('updateAvailable', `Neuer Softwarestand ${d.profile.name} verfügbar (${plan.length} Änderungen). Die Anlage ist eigenverantwortlich und wird nicht automatisch aktualisiert.`);
719
+ }
720
+ return;
721
+ }
722
+ const foreign = (0, planner_1.foreignAdapters)(d, installed);
723
+ if (foreign.length) {
724
+ // own installations: do not touch anything; the server switches the mode and informs the dealer
725
+ const key = `foreign:${foreign.join(',')}`;
726
+ if (!this.persisted.notified[key]) {
727
+ this.persisted.notified[key] = Date.now();
728
+ await this.savePersisted();
729
+ await this.notify('modeChanged', `Selbst installierte Adapter erkannt (${foreign.join(', ')}). Die Anlage erhält keine automatischen Updates mehr, bis sie in den verwalteten Modus zurückgeführt wird.`);
730
+ }
731
+ await this.setState('info.status', `Eigeninstallation erkannt: ${foreign.join(', ')}`, true);
732
+ return;
733
+ }
734
+ if (this.persisted.results[key] === 'blocked') {
735
+ return;
736
+ }
737
+ if (!plan.length) {
738
+ if (this.persisted.results[key] !== 'done') {
739
+ this.persisted.results[key] = 'done';
740
+ await this.report('done', 'Bereits auf Sollstand');
741
+ }
742
+ return;
743
+ }
744
+ // drift after an earlier success (e.g. manual change): converge again
745
+ delete this.persisted.results[key];
746
+ const pf = await (0, preflight_1.preflight)({
747
+ now: new Date(),
748
+ halted: !!this.control?.halt,
749
+ haltReason: this.control?.reason,
750
+ window: {
751
+ start: this.config.windowStart || '02:00',
752
+ end: this.config.windowEnd || '05:00',
753
+ ignore: !!this.config.ignoreWindow,
754
+ },
755
+ conditions: (this.config.conditions || []).filter(c => c?.stateId),
756
+ readState: async (id) => (await this.getForeignStateAsync(id))?.val ?? undefined,
757
+ diskPath: this.dataDir,
758
+ minFreeMb: this.config.minFreeMb || 1024,
759
+ dependencyErrors: (0, planner_1.checkDependencies)(d, installed, {
760
+ jsController: inv.software.jsController,
761
+ node: process.version,
762
+ }),
763
+ });
764
+ if (!pf.ok) {
765
+ if (pf.kind === 'block') {
766
+ this.persisted.results[key] = 'blocked';
767
+ await this.report('blocked', pf.reason, plan);
768
+ }
769
+ else if (pf.reason !== this.lastWaitReason) {
770
+ this.lastWaitReason = pf.reason;
771
+ await this.report('waiting', pf.reason, plan);
772
+ }
773
+ return;
774
+ }
775
+ // packages of the server for single assets: loaded before anything changes
776
+ const files = {};
777
+ for (const [adapter, tarball] of Object.entries((0, packages_1.packagesOf)(d, plan))) {
778
+ try {
779
+ files[adapter] = await (0, packages_1.ensurePackage)((0, node_path_1.join)(this.dataDir, 'packages'), tarball, {
780
+ serverUrls: [this.config.enrollUrl, this.creds?.mqttUrl || ''],
781
+ ca: (0, transport_1.trustedCas)(this.creds?.ca || ''),
782
+ });
783
+ }
784
+ catch (e) {
785
+ const reason = `Paket für ${adapter} nicht ladbar: ${e.message}`;
786
+ this.log.warn(reason);
787
+ if (reason !== this.lastWaitReason) {
788
+ this.lastWaitReason = reason;
789
+ await this.report('waiting', reason, plan);
790
+ }
791
+ return;
792
+ }
793
+ }
794
+ this.lastWaitReason = '';
795
+ this.log.info(`Starting job for ${d.profile.name}: ${plan.map(s => `${s.action} ${s.adapter}`).join(', ')}`);
796
+ await this.report('preflight', `${plan.length} Schritte geplant, Vorprüfung bestanden`, plan);
797
+ await this.jobs.start(d, plan, this.config.backupBeforeUpdate !== false, files);
798
+ }
799
+ // ------------------------------------------------------------------ states, notifications
800
+ async updateInfoStates() {
801
+ await this.setState('info.deviceId', this.creds?.deviceId || '', true);
802
+ await this.setState('info.assetId', this.creds?.assetId || '', true);
803
+ await this.setState('info.mode', this.desired?.mode || '', true);
804
+ await this.setState('info.profile', this.desired?.profile?.name || '', true);
805
+ }
806
+ async notify(category, message) {
807
+ try {
808
+ await this.registerNotification('bee', category, message);
809
+ }
810
+ catch (e) {
811
+ this.log.debug(`Notification not possible: ${e.message}`);
812
+ }
813
+ }
814
+ async onStateChange(id, state) {
815
+ if (!state || state.ack) {
816
+ return;
817
+ }
818
+ if (id === `${this.namespace}.control.refresh`) {
819
+ await this.sendInventory(true);
820
+ await this.setState('control.refresh', false, true);
821
+ }
822
+ else if (id === `${this.namespace}.control.check`) {
823
+ this.lastWaitReason = '';
824
+ await this.reconcile();
825
+ await this.setState('control.check', false, true);
826
+ }
827
+ }
828
+ }
829
+ if (require.main !== module) {
830
+ module.exports = (options) => new BeeAdapter(options);
831
+ }
832
+ else {
833
+ (() => new BeeAdapter())();
834
+ }
835
+ //# sourceMappingURL=main.js.map