iobroker.hassemu 1.3.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # ioBroker.hassemu
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/iobroker.hassemu)](https://www.npmjs.com/package/iobroker.hassemu)
4
- ![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)
4
+ ![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen)
5
5
  ![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![npm downloads](https://img.shields.io/npm/dt/iobroker.hassemu)](https://www.npmjs.com/package/iobroker.hassemu)
@@ -33,7 +33,7 @@ ioBroker adapter that emulates a [Home Assistant](https://www.home-assistant.io)
33
33
 
34
34
  ## Requirements
35
35
 
36
- - **Node.js >= 20**
36
+ - **Node.js >= 22**
37
37
  - **ioBroker js-controller >= 7.0.7**
38
38
  - **ioBroker Admin >= 7.7.22**
39
39
  - **ioBroker web >= 8.0.0**
@@ -147,27 +147,29 @@ Reverse DNS on a home LAN depends on your router/DHCP server and often fails. Th
147
147
  ---
148
148
 
149
149
  ## Changelog
150
+ ### 1.5.0 (2026-05-05)
151
+
152
+ - **Sicherheit**: `/health` liefert keine Konfigurations-Flags mehr (`mdns`/`auth` waren ohne Auth einsehbar — Reconnaissance-Risiko für netzexponierte Instanzen).
153
+ - **Sicherheit**: `requires_api_password` in `/api/discovery_info` und im mDNS-TXT spiegelt jetzt die tatsächliche `authRequired`-Konfiguration (vorher hartkodiert auf `true` — strikte HA-Clients haben den Auth-Flow auch bei deaktivierter Auth ausgelöst).
154
+ - **Speicher**: Brute-Force-Lockout-Map ist jetzt FIFO-gedeckelt (1000 IPs) und prunt veraltete Fail-Counts (älter als das Lockout-Fenster) — bisher wuchs die Map auf netzexponierten Instanzen langsam unbegrenzt.
155
+
156
+ ### 1.4.1 (2026-05-05)
157
+
158
+ - CI: Deploy-Schritt nutzt jetzt Node 24 (Node 22 + `npm@latest` hatte einen `MODULE_NOT_FOUND`-Bug für `promise-retry`, dadurch kam v1.4.0 nicht auf npm).
159
+
160
+ ### 1.4.0 (2026-05-05)
161
+
162
+ - Neuer Datenpunkt `info.refresh_urls` (Button) — auf `true` setzen lädt VIS/VIS-2-Projekte und Admin-Tile-URLs neu, ohne den Adapter neu zu starten. Praktisch nach einer neuen VIS-Seite, die im Mode-Dropdown erscheinen soll.
163
+ - `/auth/token` akzeptiert jetzt auch `application/x-www-form-urlencoded` Bodies (OAuth2-Spec) — manche HA-Clients senden den Token-Request urlencoded statt JSON, das Login lief sonst ins Leere.
164
+ - mDNS: Bei einem Bonjour-Startfehler wird die Service-Instanz jetzt sauber freigegeben (vorher leakte der UDP-Socket über die Adapter-Lifetime).
165
+ - Legacy-Migration (1.0.x/1.1.0 → 1.1.1) härter: ungültige Legacy-URLs werden nicht mehr durchgereicht, und Native-Cleanup passiert nur nach erfolgreichem State-Write — verhindert silent URL-Verlust auf Edge-Cases.
166
+
150
167
  ### 1.3.3 (2026-05-01)
151
168
  - Documentation: rewrote release notes for v1.1.4–v1.3.2 in user-friendly style across all languages.
152
169
 
153
170
  ### 1.3.2 (2026-04-30)
154
171
  - Fix: dropdown default `---` now applied correctly on upgrades from older v1.1.x clients (was empty after migration).
155
172
 
156
- ### 1.3.1 (2026-04-30)
157
- - Fix: legacy v1.1.x clients without `mode`/`manualUrl` objects now get migrated correctly on first start.
158
- - Mode dropdown gains a `0 = "---"` no-choice fallback — new displays start without a target until a real choice is made.
159
-
160
- ### 1.3.0 (2026-04-30)
161
- - Security: brute-force lockout on login (5 failed attempts → IP blocked for 15 min, successful login resets the counter).
162
- - Emulated Home Assistant version bumped to 2026.4.0.
163
-
164
- ### 1.2.0 (2026-04-29)
165
- - **Breaking:** Redirect target now configured via `mode` (dropdown) + `manualUrl` (free text) instead of the old `visUrl`. Existing setups auto-migrated.
166
- - Master switch `global.enabled` syncs every display (on → all follow global URL, off → each display picks up its own).
167
- - Idle displays without auth token are auto-removed after 30 days.
168
- - Security hardening of the auth flow.
169
- - `web` adapter declared as dependency.
170
-
171
173
  Older entries are in [CHANGELOG_OLD.md](CHANGELOG_OLD.md).
172
174
 
173
175
  ## Support
package/build/lib/mdns.js CHANGED
@@ -59,6 +59,7 @@ class MDNSService {
59
59
  }
60
60
  /** Start mDNS broadcasting via bonjour-service */
61
61
  start() {
62
+ var _a;
62
63
  const localIP = (0, import_network.getLocalIp)();
63
64
  const baseUrl = `http://${localIP}:${this.config.port}`;
64
65
  const serviceName = this.config.serviceName || "ioBroker";
@@ -70,7 +71,10 @@ class MDNSService {
70
71
  version: import_constants.HA_VERSION,
71
72
  uuid: this.uuid,
72
73
  location_name: serviceName,
73
- requires_api_password: "True"
74
+ // mDNS-TXT ist string-only — boolean explizit zu „True"/„False" mappen.
75
+ // Vorher hardcoded 'True' unabhängig von authRequired → Spec-Drift (HA-Clients
76
+ // mit strict-mode triggerten Auth-Flow auch bei authRequired=false).
77
+ requires_api_password: this.config.authRequired ? "True" : "False"
74
78
  };
75
79
  this.published = this.bonjour.publish({
76
80
  name: serviceName,
@@ -87,6 +91,12 @@ class MDNSService {
87
91
  } catch (error) {
88
92
  const err = error;
89
93
  this.adapter.log.warn(`mDNS: Failed to start: ${err.message}`);
94
+ try {
95
+ (_a = this.bonjour) == null ? void 0 : _a.destroy();
96
+ } catch {
97
+ }
98
+ this.bonjour = null;
99
+ this.published = null;
90
100
  }
91
101
  }
92
102
  /** Stop mDNS broadcasting */
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/mdns.ts"],
4
- "sourcesContent": ["import Bonjour, { type Service } from 'bonjour-service';\nimport { HA_VERSION } from './constants';\nimport { getLocalIp } from './network';\nimport type { AdapterConfig, AdapterInterface } from './types';\n\n/** mDNS service for Home Assistant discovery via bonjour-service */\nexport class MDNSService {\n private readonly adapter: AdapterInterface;\n private readonly config: AdapterConfig;\n public readonly uuid: string;\n public active = false;\n private bonjour: Bonjour | null = null;\n private published: Service | null = null;\n\n /**\n * Creates a new MDNSService instance\n *\n * @param adapter - Adapter interface for logging\n * @param config - Adapter configuration\n * @param uuid - Shared UUID for consistent identity across WebServer and mDNS\n */\n constructor(adapter: AdapterInterface, config: AdapterConfig, uuid: string) {\n this.adapter = adapter;\n this.config = config;\n this.uuid = uuid;\n }\n\n /** First non-internal IPv4 address (wraps shared helper for backwards-compat). */\n getLocalIP(): string {\n return getLocalIp();\n }\n\n /** Start mDNS broadcasting via bonjour-service */\n start(): void {\n const localIP = getLocalIp();\n const baseUrl = `http://${localIP}:${this.config.port}`;\n const serviceName = this.config.serviceName || 'ioBroker';\n\n try {\n this.bonjour = new Bonjour();\n\n // Empty TXT records are dropped \u2014 bonjour-service publishes them as\n // empty strings otherwise, which clutters the discovery payload.\n const txt: Record<string, string> = {\n base_url: baseUrl,\n internal_url: baseUrl,\n version: HA_VERSION,\n uuid: this.uuid,\n location_name: serviceName,\n requires_api_password: 'True',\n };\n\n this.published = this.bonjour.publish({\n name: serviceName,\n type: 'home-assistant',\n protocol: 'tcp',\n port: this.config.port,\n txt,\n });\n\n this.active = true;\n\n this.adapter.log.debug(\n `mDNS: Broadcasting ${serviceName}._home-assistant._tcp.local on ${localIP}:${this.config.port}`,\n );\n this.adapter.log.debug(`mDNS: UUID: ${this.uuid}`);\n } catch (error) {\n const err = error as Error;\n this.adapter.log.warn(`mDNS: Failed to start: ${err.message}`);\n }\n }\n\n /** Stop mDNS broadcasting */\n stop(): void {\n if (!this.active) {\n return;\n }\n\n try {\n if (this.published) {\n this.published.stop?.();\n this.published = null;\n }\n if (this.bonjour) {\n this.bonjour.destroy();\n this.bonjour = null;\n }\n this.adapter.log.debug('mDNS: Service stopped');\n } catch (error) {\n const err = error as Error;\n this.adapter.log.warn(`mDNS: Could not stop cleanly: ${err.message}`);\n }\n\n this.active = false;\n }\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,6BAAsC;AACtC,uBAA2B;AAC3B,qBAA2B;AAIpB,MAAM,YAAY;AAAA,EACJ;AAAA,EACA;AAAA,EACD;AAAA,EACT,SAAS;AAAA,EACR,UAA0B;AAAA,EAC1B,YAA4B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASpC,YAAY,SAA2B,QAAuB,MAAc;AACxE,SAAK,UAAU;AACf,SAAK,SAAS;AACd,SAAK,OAAO;AAAA,EAChB;AAAA;AAAA,EAGA,aAAqB;AACjB,eAAO,2BAAW;AAAA,EACtB;AAAA;AAAA,EAGA,QAAc;AACV,UAAM,cAAU,2BAAW;AAC3B,UAAM,UAAU,UAAU,OAAO,IAAI,KAAK,OAAO,IAAI;AACrD,UAAM,cAAc,KAAK,OAAO,eAAe;AAE/C,QAAI;AACA,WAAK,UAAU,IAAI,uBAAAA,QAAQ;AAI3B,YAAM,MAA8B;AAAA,QAChC,UAAU;AAAA,QACV,cAAc;AAAA,QACd,SAAS;AAAA,QACT,MAAM,KAAK;AAAA,QACX,eAAe;AAAA,QACf,uBAAuB;AAAA,MAC3B;AAEA,WAAK,YAAY,KAAK,QAAQ,QAAQ;AAAA,QAClC,MAAM;AAAA,QACN,MAAM;AAAA,QACN,UAAU;AAAA,QACV,MAAM,KAAK,OAAO;AAAA,QAClB;AAAA,MACJ,CAAC;AAED,WAAK,SAAS;AAEd,WAAK,QAAQ,IAAI;AAAA,QACb,sBAAsB,WAAW,kCAAkC,OAAO,IAAI,KAAK,OAAO,IAAI;AAAA,MAClG;AACA,WAAK,QAAQ,IAAI,MAAM,eAAe,KAAK,IAAI,EAAE;AAAA,IACrD,SAAS,OAAO;AACZ,YAAM,MAAM;AACZ,WAAK,QAAQ,IAAI,KAAK,0BAA0B,IAAI,OAAO,EAAE;AAAA,IACjE;AAAA,EACJ;AAAA;AAAA,EAGA,OAAa;AAzEjB;AA0EQ,QAAI,CAAC,KAAK,QAAQ;AACd;AAAA,IACJ;AAEA,QAAI;AACA,UAAI,KAAK,WAAW;AAChB,yBAAK,WAAU,SAAf;AACA,aAAK,YAAY;AAAA,MACrB;AACA,UAAI,KAAK,SAAS;AACd,aAAK,QAAQ,QAAQ;AACrB,aAAK,UAAU;AAAA,MACnB;AACA,WAAK,QAAQ,IAAI,MAAM,uBAAuB;AAAA,IAClD,SAAS,OAAO;AACZ,YAAM,MAAM;AACZ,WAAK,QAAQ,IAAI,KAAK,iCAAiC,IAAI,OAAO,EAAE;AAAA,IACxE;AAEA,SAAK,SAAS;AAAA,EAClB;AACJ;",
4
+ "sourcesContent": ["import Bonjour, { type Service } from 'bonjour-service';\nimport { HA_VERSION } from './constants';\nimport { getLocalIp } from './network';\nimport type { AdapterConfig, AdapterInterface } from './types';\n\n/** mDNS service for Home Assistant discovery via bonjour-service */\nexport class MDNSService {\n private readonly adapter: AdapterInterface;\n private readonly config: AdapterConfig;\n public readonly uuid: string;\n public active = false;\n private bonjour: Bonjour | null = null;\n private published: Service | null = null;\n\n /**\n * Creates a new MDNSService instance\n *\n * @param adapter - Adapter interface for logging\n * @param config - Adapter configuration\n * @param uuid - Shared UUID for consistent identity across WebServer and mDNS\n */\n constructor(adapter: AdapterInterface, config: AdapterConfig, uuid: string) {\n this.adapter = adapter;\n this.config = config;\n this.uuid = uuid;\n }\n\n /** First non-internal IPv4 address (wraps shared helper for backwards-compat). */\n getLocalIP(): string {\n return getLocalIp();\n }\n\n /** Start mDNS broadcasting via bonjour-service */\n start(): void {\n const localIP = getLocalIp();\n const baseUrl = `http://${localIP}:${this.config.port}`;\n const serviceName = this.config.serviceName || 'ioBroker';\n\n try {\n this.bonjour = new Bonjour();\n\n // Empty TXT records are dropped \u2014 bonjour-service publishes them as\n // empty strings otherwise, which clutters the discovery payload.\n const txt: Record<string, string> = {\n base_url: baseUrl,\n internal_url: baseUrl,\n version: HA_VERSION,\n uuid: this.uuid,\n location_name: serviceName,\n // mDNS-TXT ist string-only \u2014 boolean explizit zu \u201ETrue\"/\u201EFalse\" mappen.\n // Vorher hardcoded 'True' unabh\u00E4ngig von authRequired \u2192 Spec-Drift (HA-Clients\n // mit strict-mode triggerten Auth-Flow auch bei authRequired=false).\n requires_api_password: this.config.authRequired ? 'True' : 'False',\n };\n\n this.published = this.bonjour.publish({\n name: serviceName,\n type: 'home-assistant',\n protocol: 'tcp',\n port: this.config.port,\n txt,\n });\n\n this.active = true;\n\n this.adapter.log.debug(\n `mDNS: Broadcasting ${serviceName}._home-assistant._tcp.local on ${localIP}:${this.config.port}`,\n );\n this.adapter.log.debug(`mDNS: UUID: ${this.uuid}`);\n } catch (error) {\n const err = error as Error;\n this.adapter.log.warn(`mDNS: Failed to start: ${err.message}`);\n // Wichtig: bonjour-instance freigeben sonst leakt der UDP-Socket\n // \u00FCber die Adapter-Lifetime. `stop()` short-circuit'd auf\n // `!this.active` und w\u00FCrde nichts cleanen.\n try {\n this.bonjour?.destroy();\n } catch {\n /* destroy darf re-throwen \u2014 wir wollen nur die Resource lossen */\n }\n this.bonjour = null;\n this.published = null;\n }\n }\n\n /** Stop mDNS broadcasting */\n stop(): void {\n if (!this.active) {\n return;\n }\n\n try {\n if (this.published) {\n this.published.stop?.();\n this.published = null;\n }\n if (this.bonjour) {\n this.bonjour.destroy();\n this.bonjour = null;\n }\n this.adapter.log.debug('mDNS: Service stopped');\n } catch (error) {\n const err = error as Error;\n this.adapter.log.warn(`mDNS: Could not stop cleanly: ${err.message}`);\n }\n\n this.active = false;\n }\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,6BAAsC;AACtC,uBAA2B;AAC3B,qBAA2B;AAIpB,MAAM,YAAY;AAAA,EACJ;AAAA,EACA;AAAA,EACD;AAAA,EACT,SAAS;AAAA,EACR,UAA0B;AAAA,EAC1B,YAA4B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASpC,YAAY,SAA2B,QAAuB,MAAc;AACxE,SAAK,UAAU;AACf,SAAK,SAAS;AACd,SAAK,OAAO;AAAA,EAChB;AAAA;AAAA,EAGA,aAAqB;AACjB,eAAO,2BAAW;AAAA,EACtB;AAAA;AAAA,EAGA,QAAc;AAjClB;AAkCQ,UAAM,cAAU,2BAAW;AAC3B,UAAM,UAAU,UAAU,OAAO,IAAI,KAAK,OAAO,IAAI;AACrD,UAAM,cAAc,KAAK,OAAO,eAAe;AAE/C,QAAI;AACA,WAAK,UAAU,IAAI,uBAAAA,QAAQ;AAI3B,YAAM,MAA8B;AAAA,QAChC,UAAU;AAAA,QACV,cAAc;AAAA,QACd,SAAS;AAAA,QACT,MAAM,KAAK;AAAA,QACX,eAAe;AAAA;AAAA;AAAA;AAAA,QAIf,uBAAuB,KAAK,OAAO,eAAe,SAAS;AAAA,MAC/D;AAEA,WAAK,YAAY,KAAK,QAAQ,QAAQ;AAAA,QAClC,MAAM;AAAA,QACN,MAAM;AAAA,QACN,UAAU;AAAA,QACV,MAAM,KAAK,OAAO;AAAA,QAClB;AAAA,MACJ,CAAC;AAED,WAAK,SAAS;AAEd,WAAK,QAAQ,IAAI;AAAA,QACb,sBAAsB,WAAW,kCAAkC,OAAO,IAAI,KAAK,OAAO,IAAI;AAAA,MAClG;AACA,WAAK,QAAQ,IAAI,MAAM,eAAe,KAAK,IAAI,EAAE;AAAA,IACrD,SAAS,OAAO;AACZ,YAAM,MAAM;AACZ,WAAK,QAAQ,IAAI,KAAK,0BAA0B,IAAI,OAAO,EAAE;AAI7D,UAAI;AACA,mBAAK,YAAL,mBAAc;AAAA,MAClB,QAAQ;AAAA,MAER;AACA,WAAK,UAAU;AACf,WAAK,YAAY;AAAA,IACrB;AAAA,EACJ;AAAA;AAAA,EAGA,OAAa;AAtFjB;AAuFQ,QAAI,CAAC,KAAK,QAAQ;AACd;AAAA,IACJ;AAEA,QAAI;AACA,UAAI,KAAK,WAAW;AAChB,yBAAK,WAAU,SAAf;AACA,aAAK,YAAY;AAAA,MACrB;AACA,UAAI,KAAK,SAAS;AACd,aAAK,QAAQ,QAAQ;AACrB,aAAK,UAAU;AAAA,MACnB;AACA,WAAK,QAAQ,IAAI,MAAM,uBAAuB;AAAA,IAClD,SAAS,OAAO;AACZ,YAAM,MAAM;AACZ,WAAK,QAAQ,IAAI,KAAK,iCAAiC,IAAI,OAAO,EAAE;AAAA,IACxE;AAEA,SAAK,SAAS;AAAA,EAClB;AACJ;",
6
6
  "names": ["Bonjour"]
7
7
  }
@@ -35,12 +35,14 @@ module.exports = __toCommonJS(webserver_exports);
35
35
  var import_node_crypto = __toESM(require("node:crypto"));
36
36
  var import_promises = __toESM(require("node:dns/promises"));
37
37
  var import_cookie = __toESM(require("@fastify/cookie"));
38
+ var import_formbody = __toESM(require("@fastify/formbody"));
38
39
  var import_fastify = __toESM(require("fastify"));
39
40
  var import_constants = require("./constants");
40
41
  var import_coerce = require("./coerce");
41
42
  var import_landing_page = require("./landing-page");
42
43
  const SESSIONS_CAP = 100;
43
44
  const REFRESH_TOKENS_CAP = 200;
45
+ const LOGIN_ATTEMPTS_CAP = 1e3;
44
46
  function safeStringEqual(a, b) {
45
47
  const ab = Buffer.from(a, "utf8");
46
48
  const bb = Buffer.from(b, "utf8");
@@ -65,9 +67,13 @@ class WebServer {
65
67
  refreshTokens = /* @__PURE__ */ new Map();
66
68
  /**
67
69
  * Brute-force lockout state per remote IP. Each entry tracks failed login
68
- * attempts; once {@link LOGIN_LOCKOUT_THRESHOLD} is reached, `lockedUntil`
69
- * is set and further attempts from that IP are rejected with HTTP 429
70
- * until the window passes. Expired entries are pruned in {@link cleanupSessions}.
70
+ * attempts and the timestamp of the last failure; once
71
+ * {@link LOGIN_LOCKOUT_THRESHOLD} is reached, `lockedUntil` is set and
72
+ * further attempts from that IP are rejected with HTTP 429 until the
73
+ * window passes. The map is FIFO-capped at {@link LOGIN_ATTEMPTS_CAP}
74
+ * (else internet-exposed instances leak slowly via stray failure counts
75
+ * with `lockedUntil=0`); expired and stale entries are pruned in
76
+ * {@link cleanupSessions}.
71
77
  */
72
78
  loginAttempts = /* @__PURE__ */ new Map();
73
79
  cleanupTimer = null;
@@ -110,6 +116,7 @@ class WebServer {
110
116
  async start() {
111
117
  var _a;
112
118
  await this.app.register(import_cookie.default);
119
+ await this.app.register(import_formbody.default);
113
120
  this.setupErrorHandler();
114
121
  this.setupRoutes();
115
122
  const bindAddress = this.config.bindAddress || "0.0.0.0";
@@ -159,10 +166,15 @@ class WebServer {
159
166
  if (entry.lockedUntil > 0 && entry.lockedUntil <= now) {
160
167
  this.loginAttempts.delete(ip);
161
168
  cleanedLockouts++;
169
+ continue;
170
+ }
171
+ if (entry.lockedUntil === 0 && entry.failedCount > 0 && now - entry.lastSeen > import_constants.LOGIN_LOCKOUT_WINDOW_MS) {
172
+ this.loginAttempts.delete(ip);
173
+ cleanedLockouts++;
162
174
  }
163
175
  }
164
176
  if (cleanedLockouts > 0) {
165
- this.adapter.log.debug(`Lockout cleanup: cleared ${cleanedLockouts} expired IP lockouts`);
177
+ this.adapter.log.debug(`Lockout cleanup: cleared ${cleanedLockouts} expired/stale IP entries`);
166
178
  }
167
179
  }
168
180
  /**
@@ -233,14 +245,19 @@ class WebServer {
233
245
  if (!ip) {
234
246
  return;
235
247
  }
236
- const entry = (_a = this.loginAttempts.get(ip)) != null ? _a : { failedCount: 0, lockedUntil: 0 };
248
+ const now = Date.now();
249
+ const entry = (_a = this.loginAttempts.get(ip)) != null ? _a : { failedCount: 0, lockedUntil: 0, lastSeen: now };
237
250
  entry.failedCount += 1;
251
+ entry.lastSeen = now;
238
252
  if (entry.failedCount >= import_constants.LOGIN_LOCKOUT_THRESHOLD) {
239
- entry.lockedUntil = Date.now() + import_constants.LOGIN_LOCKOUT_WINDOW_MS;
253
+ entry.lockedUntil = now + import_constants.LOGIN_LOCKOUT_WINDOW_MS;
240
254
  this.adapter.log.warn(
241
255
  `Login lockout: IP ${ip} reached ${import_constants.LOGIN_LOCKOUT_THRESHOLD} failed attempts \u2014 locked for ${Math.round(import_constants.LOGIN_LOCKOUT_WINDOW_MS / 6e4)} min`
242
256
  );
243
257
  }
258
+ if (!this.loginAttempts.has(ip)) {
259
+ WebServer.evictOldest(this.loginAttempts, LOGIN_ATTEMPTS_CAP);
260
+ }
244
261
  this.loginAttempts.set(ip, entry);
245
262
  }
246
263
  /**
@@ -338,7 +355,9 @@ class WebServer {
338
355
  external_url: null,
339
356
  internal_url: baseUrl,
340
357
  location_name: this.serviceName,
341
- requires_api_password: true,
358
+ // Vorher hardcoded `true` unabhängig von authRequired — strict HA-Clients
359
+ // versuchten Auth auch bei authRequired=false und scheiterten am leeren Login-Flow.
360
+ requires_api_password: this.config.authRequired,
342
361
  uuid: this.instanceUuid,
343
362
  version: import_constants.HA_VERSION
344
363
  };
@@ -474,11 +493,7 @@ class WebServer {
474
493
  this.app.get("/health", () => ({
475
494
  status: "ok",
476
495
  adapter: "hassemu",
477
- version: import_constants.HA_VERSION,
478
- config: {
479
- mdns: this.config.mdnsEnabled,
480
- auth: this.config.authRequired
481
- }
496
+ version: import_constants.HA_VERSION
482
497
  }));
483
498
  this.app.get("/manifest.json", () => ({
484
499
  name: this.serviceName,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/webserver.ts"],
4
- "sourcesContent": ["import crypto from 'node:crypto';\nimport dns from 'node:dns/promises';\nimport fastifyCookie from '@fastify/cookie';\nimport Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';\nimport {\n HA_VERSION,\n SESSION_TTL_MS,\n CLEANUP_INTERVAL_MS,\n LOGIN_SCHEMA,\n OAUTH_ACCESS_TOKEN_TTL_S,\n LOGIN_LOCKOUT_THRESHOLD,\n LOGIN_LOCKOUT_WINDOW_MS,\n} from './constants';\nimport { coerceString, coerceUuid } from './coerce';\nimport type { ClientRegistry } from './client-registry';\nimport type { GlobalConfig } from './global-config';\nimport { renderLandingPage } from './landing-page';\nimport type { AdapterConfig, AdapterInterface, ClientRecord, SessionData } from './types';\n\n/** Hard cap on in-flight auth flow sessions. Older entries are dropped FIFO when full. */\nconst SESSIONS_CAP = 100;\n/** Hard cap on remembered refresh tokens. Older entries are dropped FIFO when full. */\nconst REFRESH_TOKENS_CAP = 200;\n\n/**\n * Constant-time string comparison for credential checks. Returns false for length mismatch.\n *\n * @param a First string to compare.\n * @param b Second string to compare.\n */\nfunction safeStringEqual(a: string, b: string): boolean {\n const ab = Buffer.from(a, 'utf8');\n const bb = Buffer.from(b, 'utf8');\n if (ab.length !== bb.length) {\n return false;\n }\n return crypto.timingSafeEqual(ab, bb);\n}\n\n/** Adapter surface the WebServer depends on \u2014 adds `namespace` for the setup page. */\nexport type WebServerAdapter = AdapterInterface & Pick<ioBroker.Adapter, 'namespace'>;\n\n/** Browser cookie name. Client identity lives here \u2014 auto-sent on every page navigation. */\nexport const CLIENT_COOKIE = 'hassemu_client';\n/** Cookie lifetime (10 years). Clients stay identified essentially forever unless removed. */\nconst COOKIE_MAX_AGE_S = 10 * 365 * 24 * 60 * 60;\n\n/**\n * Fastify web server emulating the HA REST API.\n *\n * Each incoming request is identified by cookie \u2192 {@link ClientRegistry} entry; new clients\n * get a channel created on first hit. Express was swapped for Fastify in 1.1.0 for first-party\n * cookie support, schema validation and a lighter runtime.\n */\nexport class WebServer {\n private readonly adapter: WebServerAdapter;\n private readonly config: AdapterConfig;\n private readonly registry: ClientRegistry;\n private readonly globalConfig: GlobalConfig;\n private readonly app: FastifyInstance;\n public readonly sessions: Map<string, SessionData> = new Map();\n /**\n * Issued refresh tokens \u2192 owning clientId. Validated on every refresh-grant \u2014\n * unknown tokens are rejected (was: any string accepted).\n */\n public readonly refreshTokens: Map<string, string> = new Map();\n /**\n * Brute-force lockout state per remote IP. Each entry tracks failed login\n * attempts; once {@link LOGIN_LOCKOUT_THRESHOLD} is reached, `lockedUntil`\n * is set and further attempts from that IP are rejected with HTTP 429\n * until the window passes. Expired entries are pruned in {@link cleanupSessions}.\n */\n public readonly loginAttempts: Map<string, { failedCount: number; lockedUntil: number }> = new Map();\n private cleanupTimer: ioBroker.Interval | null = null;\n public readonly instanceUuid: string;\n /** ioBroker system language for the setup page \u2014 resolved on startup. */\n public readonly systemLanguage: string;\n /** Set of IPs whose reverse DNS lookup is already in-flight \u2014 prevents duplicate work. */\n private readonly dnsInFlight = new Set<string>();\n\n /**\n * @param adapter Adapter instance used for logging, timers and namespace.\n * @param config Resolved runtime config.\n * @param registry Multi-client registry.\n * @param globalConfig Global redirect override.\n * @param instanceUuid Stable UUID shared with the mDNS advert.\n * @param systemLanguage ioBroker system language (`en`, `de`, \u2026) used for the setup page.\n */\n constructor(\n adapter: WebServerAdapter,\n config: AdapterConfig,\n registry: ClientRegistry,\n globalConfig: GlobalConfig,\n instanceUuid: string,\n systemLanguage: string = 'en',\n ) {\n this.adapter = adapter;\n this.config = config;\n this.registry = registry;\n this.globalConfig = globalConfig;\n this.instanceUuid = instanceUuid;\n this.systemLanguage = systemLanguage;\n this.app = Fastify({ logger: false, trustProxy: false });\n }\n\n /** Human-readable service name advertised in responses and mDNS. */\n get serviceName(): string {\n return this.config.serviceName || 'ioBroker';\n }\n\n /** Resolved listener address once `start()` has completed, or null otherwise. */\n get boundAddress(): { address: string; port: number } | null {\n const addr = this.app.server.address();\n if (!addr || typeof addr === 'string') {\n return null;\n }\n return { address: addr.address, port: addr.port };\n }\n\n // --- lifecycle ---\n\n /** Registers plugins and starts the HTTP listener. */\n async start(): Promise<void> {\n await this.app.register(fastifyCookie);\n this.setupErrorHandler();\n this.setupRoutes();\n\n const bindAddress = this.config.bindAddress || '0.0.0.0';\n try {\n await this.app.listen({ port: this.config.port, host: bindAddress });\n } catch (err) {\n const e = err as NodeJS.ErrnoException;\n const msg =\n e.code === 'EADDRINUSE' ? `Port ${this.config.port} is already in use!` : `Server error: ${e.message}`;\n this.adapter.log.error(msg);\n throw err;\n }\n this.adapter.log.debug(`Web server listening on ${bindAddress}:${this.config.port}`);\n\n this.cleanupTimer = this.adapter.setInterval(() => this.cleanupSessions(), CLEANUP_INTERVAL_MS) ?? null;\n }\n\n /** Stops the listener and cancels the session cleanup timer. */\n async stop(): Promise<void> {\n if (this.cleanupTimer) {\n this.adapter.clearInterval(this.cleanupTimer);\n this.cleanupTimer = null;\n }\n try {\n await this.app.close();\n this.adapter.log.debug('Web server stopped');\n } catch (err) {\n this.adapter.log.error(`Web server stop error: ${String(err)}`);\n }\n }\n\n /** Exposed for testing \u2014 fires injected requests without a real socket. */\n get inject(): FastifyInstance['inject'] {\n return this.app.inject.bind(this.app);\n }\n\n /** Periodic cleanup of expired in-flight auth sessions and stale lockouts. */\n public cleanupSessions(): void {\n const now = Date.now();\n let cleanedSessions = 0;\n for (const [key, session] of this.sessions) {\n if (now - session.created > SESSION_TTL_MS) {\n this.sessions.delete(key);\n cleanedSessions++;\n }\n }\n if (cleanedSessions > 0) {\n this.adapter.log.debug(`Session cleanup: removed ${cleanedSessions} expired sessions`);\n }\n let cleanedLockouts = 0;\n for (const [ip, entry] of this.loginAttempts) {\n if (entry.lockedUntil > 0 && entry.lockedUntil <= now) {\n this.loginAttempts.delete(ip);\n cleanedLockouts++;\n }\n }\n if (cleanedLockouts > 0) {\n this.adapter.log.debug(`Lockout cleanup: cleared ${cleanedLockouts} expired IP lockouts`);\n }\n }\n\n /**\n * Drops the oldest entry of a Map if it would exceed `cap` after the next insert.\n * Map iteration order in JS is insertion order, so `keys().next()` is the oldest.\n *\n * @param map Map to evict from.\n * @param cap Hard cap; when `map.size >= cap`, the oldest entry is removed.\n */\n private static evictOldest<V>(map: Map<string, V>, cap: number): void {\n if (map.size < cap) {\n return;\n }\n const oldest = map.keys().next().value;\n if (oldest !== undefined) {\n map.delete(oldest);\n }\n }\n\n /**\n * Inserts a session, dropping the oldest entry if {@link SESSIONS_CAP} is exceeded.\n *\n * @param key Session key (flow id or auth code).\n * @param data Session payload.\n */\n private storeSession(key: string, data: SessionData): void {\n WebServer.evictOldest(this.sessions, SESSIONS_CAP);\n this.sessions.set(key, data);\n }\n\n /**\n * Inserts a refresh token mapping, dropping the oldest if cap exceeded.\n *\n * @param token Refresh token issued in `/auth/token`.\n * @param clientId Owning client id.\n */\n private storeRefreshToken(token: string, clientId: string): void {\n WebServer.evictOldest(this.refreshTokens, REFRESH_TOKENS_CAP);\n this.refreshTokens.set(token, clientId);\n }\n\n /**\n * Brute-force lockout: returns true if `ip` is currently in the timeout window.\n * Lazy-resets entries whose lockout already expired (caller can immediately try again).\n *\n * @param ip Remote IP, or null when unavailable.\n */\n private isIpLocked(ip: string | null): boolean {\n if (!ip) {\n return false;\n }\n const entry = this.loginAttempts.get(ip);\n if (!entry || entry.lockedUntil === 0) {\n return false;\n }\n if (entry.lockedUntil > Date.now()) {\n return true;\n }\n // Lockout window passed \u2014 drop the entry, IP gets a fresh budget.\n this.loginAttempts.delete(ip);\n return false;\n }\n\n /**\n * Records a failed login attempt for `ip`. When the running count reaches\n * {@link LOGIN_LOCKOUT_THRESHOLD}, the IP is locked for\n * {@link LOGIN_LOCKOUT_WINDOW_MS}.\n *\n * @param ip Remote IP that failed authentication.\n */\n private recordLoginFailure(ip: string | null): void {\n if (!ip) {\n return;\n }\n const entry = this.loginAttempts.get(ip) ?? { failedCount: 0, lockedUntil: 0 };\n entry.failedCount += 1;\n if (entry.failedCount >= LOGIN_LOCKOUT_THRESHOLD) {\n entry.lockedUntil = Date.now() + LOGIN_LOCKOUT_WINDOW_MS;\n this.adapter.log.warn(\n `Login lockout: IP ${ip} reached ${LOGIN_LOCKOUT_THRESHOLD} failed attempts \u2014 ` +\n `locked for ${Math.round(LOGIN_LOCKOUT_WINDOW_MS / 60000)} min`,\n );\n }\n this.loginAttempts.set(ip, entry);\n }\n\n /**\n * Resets the failure counter and any active lockout for `ip`. Called after\n * a successful credential check so legit clients don't accumulate counts\n * across long-lived sessions.\n *\n * @param ip Remote IP that just authenticated successfully.\n */\n private clearLoginAttempts(ip: string | null): void {\n if (ip) {\n this.loginAttempts.delete(ip);\n }\n }\n\n // --- client identification ---\n\n private async identify(req: FastifyRequest, reply: FastifyReply): Promise<ClientRecord> {\n const cookie = coerceUuid(req.cookies?.[CLIENT_COOKIE]);\n const ip = coerceString(req.ip);\n const record = await this.registry.identifyOrCreate(cookie, ip, null);\n if (cookie !== record.cookie) {\n reply.setCookie(CLIENT_COOKIE, record.cookie, {\n path: '/',\n httpOnly: true,\n sameSite: 'lax',\n maxAge: COOKIE_MAX_AGE_S,\n });\n }\n if (ip) {\n this.resolveHostnameAsync(record, ip);\n }\n return record;\n }\n\n private resolveHostnameAsync(record: ClientRecord, ip: string): void {\n if (record.hostname || this.dnsInFlight.has(ip)) {\n return;\n }\n this.dnsInFlight.add(ip);\n dns.reverse(ip)\n .then(names => {\n const name = names[0];\n if (name) {\n this.registry.identifyOrCreate(record.cookie, ip, name).catch(() => {\n /* registry itself logs */\n });\n }\n })\n .catch(() => {\n // Reverse DNS often fails on LAN \u2014 intentionally silent.\n })\n .finally(() => {\n this.dnsInFlight.delete(ip);\n });\n }\n\n // --- error handling ---\n\n private setupErrorHandler(): void {\n this.app.setErrorHandler((err, _req, reply) => {\n const error = err as Error & { validation?: unknown; statusCode?: number };\n if (error.validation) {\n this.adapter.log.debug(`Validation error: ${error.message}`);\n reply.status(400).send({ error: 'Invalid request', details: error.message });\n return;\n }\n // Fastify body-parsing / client errors already set statusCode in 4xx range\n const code = typeof error.statusCode === 'number' ? error.statusCode : 500;\n if (code >= 400 && code < 500) {\n this.adapter.log.debug(`Client error ${code}: ${error.message}`);\n reply.status(code).send({ error: error.message });\n return;\n }\n this.adapter.log.warn(`Request error: ${error.message}`);\n reply.status(500).send({ error: 'Internal server error' });\n });\n }\n\n // --- routes ---\n\n private setupRoutes(): void {\n this.setupApiRoutes();\n this.setupAuthRoutes();\n this.setupMiscRoutes();\n this.setupNotFound();\n }\n\n private setupApiRoutes(): void {\n // CRITICAL: trailing slash \u2014 HA clients check this endpoint for discovery\n this.app.get('/api/', () => ({ message: 'API running.' }));\n\n this.app.get('/api/config', () => ({\n components: ['http', 'api', 'frontend', 'homeassistant'],\n config_dir: '/config',\n elevation: 0,\n latitude: 0,\n longitude: 0,\n location_name: this.serviceName,\n time_zone: 'UTC',\n unit_system: { length: 'km', mass: 'g', temperature: '\u00B0C', volume: 'L' },\n version: HA_VERSION,\n whitelist_external_dirs: [],\n }));\n\n this.app.get('/api/discovery_info', req => {\n const host = req.hostname || this.config.bindAddress || '0.0.0.0';\n const baseUrl = `http://${host}:${this.config.port}`;\n return {\n base_url: baseUrl,\n external_url: null,\n internal_url: baseUrl,\n location_name: this.serviceName,\n requires_api_password: true,\n uuid: this.instanceUuid,\n version: HA_VERSION,\n };\n });\n\n for (const path of ['/api/states', '/api/services', '/api/events']) {\n this.app.get(path, () => []);\n }\n this.app.get('/api/error_log', () => '');\n }\n\n private setupAuthRoutes(): void {\n this.app.get('/auth/providers', () => [{ name: 'Home Assistant Local', type: 'homeassistant', id: null }]);\n\n this.app.post('/auth/login_flow', async (req, reply) => {\n const client = await this.identify(req, reply);\n const flowId = crypto.randomUUID();\n this.storeSession(flowId, { created: Date.now(), clientId: client.id });\n this.adapter.log.debug(`Auth flow created: ${flowId} for client ${client.id}`);\n\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n description_placeholders: null,\n errors: null,\n };\n });\n\n this.app.post<{\n Params: { flowId: string };\n Body: { username?: string; password?: string };\n }>(\n '/auth/login_flow/:flowId',\n {\n schema: {\n params: {\n type: 'object',\n properties: { flowId: { type: 'string', minLength: 1 } },\n required: ['flowId'],\n },\n },\n },\n async (req, reply) => {\n const flowId = req.params.flowId;\n const session = this.sessions.get(flowId);\n if (!session) {\n this.adapter.log.warn(`Unknown flow_id: ${flowId}`);\n reply.status(400);\n return { type: 'abort', flow_id: flowId, reason: 'unknown_flow' };\n }\n\n if (this.config.authRequired) {\n const ip = coerceString(req.ip);\n if (this.isIpLocked(ip)) {\n this.adapter.log.warn(`Login rejected: IP ${ip} is currently locked out`);\n reply.status(429);\n return { type: 'abort', flow_id: flowId, reason: 'too_many_failed_attempts' };\n }\n const { username, password } = req.body ?? {};\n const userOk = typeof username === 'string' && safeStringEqual(username, this.config.username);\n const passOk = typeof password === 'string' && safeStringEqual(password, this.config.password);\n if (!userOk || !passOk) {\n this.recordLoginFailure(ip);\n this.adapter.log.warn('Invalid credentials');\n reply.status(400);\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n errors: { base: 'invalid_auth' },\n description_placeholders: null,\n };\n }\n this.clearLoginAttempts(ip);\n }\n\n this.sessions.delete(flowId);\n const code = crypto.randomUUID();\n this.storeSession(code, { created: Date.now(), clientId: session.clientId });\n this.adapter.log.debug('Auth flow completed \u2014 code issued');\n\n return {\n version: 1,\n type: 'create_entry',\n flow_id: flowId,\n handler: ['homeassistant', null],\n result: code,\n description: null,\n description_placeholders: null,\n };\n },\n );\n\n this.app.post<{ Body: { code?: string; grant_type?: string; refresh_token?: string } }>(\n '/auth/token',\n async (req, reply) => {\n const { code, grant_type, refresh_token } = req.body ?? {};\n\n if (grant_type === 'authorization_code' && code && this.sessions.has(code)) {\n const session = this.sessions.get(code)!;\n this.sessions.delete(code);\n const token = crypto.randomUUID();\n const refreshToken = crypto.randomUUID();\n if (session.clientId) {\n await this.registry.setToken(session.clientId, token);\n this.storeRefreshToken(refreshToken, session.clientId);\n this.adapter.log.debug(`Display authenticated \u2014 client ${session.clientId}`);\n }\n return {\n access_token: token,\n token_type: 'Bearer',\n refresh_token: refreshToken,\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n if (grant_type === 'refresh_token') {\n // Validate the refresh token against issued ones \u2014 was previously\n // accepting any string and minting a new access_token (security fix v1.2.0).\n const incoming = typeof refresh_token === 'string' ? refresh_token : '';\n const ownerId = incoming ? this.refreshTokens.get(incoming) : undefined;\n if (!ownerId) {\n this.adapter.log.warn('Refresh token rejected \u2014 unknown or missing');\n reply.status(400);\n return { error: 'invalid_grant', error_description: 'Invalid refresh token' };\n }\n const newAccess = crypto.randomUUID();\n await this.registry.setToken(ownerId, newAccess);\n return {\n access_token: newAccess,\n token_type: 'Bearer',\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n this.adapter.log.warn(`Token exchange failed: grant_type=${String(grant_type)}`);\n reply.status(400);\n return { error: 'invalid_request', error_description: 'Invalid or expired code' };\n },\n );\n }\n\n private setupMiscRoutes(): void {\n // Liveness only \u2014 no config leak. Earlier versions exposed the global\n // redirect URL via /health which is unauthenticated; removed in v1.2.0.\n this.app.get('/health', () => ({\n status: 'ok',\n adapter: 'hassemu',\n version: HA_VERSION,\n config: {\n mdns: this.config.mdnsEnabled,\n auth: this.config.authRequired,\n },\n }));\n\n this.app.get('/manifest.json', () => ({\n name: this.serviceName,\n short_name: this.serviceName,\n start_url: '/',\n display: 'standalone',\n background_color: '#ffffff',\n theme_color: '#03a9f4',\n }));\n\n // Root \u2014 302 redirect, or landing page when no URL is configured\n this.app.get('/', async (req, reply) => {\n const client = await this.identify(req, reply);\n const url = this.globalConfig.resolveUrlFor(client);\n if (!url) {\n this.adapter.log.debug(`No redirect URL for client ${client.id} \u2014 serving landing page`);\n return reply\n .status(200)\n .type('text/html; charset=utf-8')\n .send(renderLandingPage(client.id, this.adapter.namespace, this.systemLanguage, client.ip));\n }\n this.adapter.log.debug(`Redirecting client ${client.id} \u2192 ${url}`);\n return reply.redirect(url, 302);\n });\n }\n\n private setupNotFound(): void {\n this.app.setNotFoundHandler((req, reply) => {\n this.adapter.log.debug(`404: ${req.method} ${req.url}`);\n reply.status(404).send({ error: 'Not Found', path: req.url });\n });\n }\n}\n"],
5
- "mappings": "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",
6
- "names": ["crypto", "Fastify", "fastifyCookie", "dns"]
4
+ "sourcesContent": ["import crypto from 'node:crypto';\nimport dns from 'node:dns/promises';\nimport fastifyCookie from '@fastify/cookie';\nimport fastifyFormbody from '@fastify/formbody';\nimport Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';\nimport {\n HA_VERSION,\n SESSION_TTL_MS,\n CLEANUP_INTERVAL_MS,\n LOGIN_SCHEMA,\n OAUTH_ACCESS_TOKEN_TTL_S,\n LOGIN_LOCKOUT_THRESHOLD,\n LOGIN_LOCKOUT_WINDOW_MS,\n} from './constants';\nimport { coerceString, coerceUuid } from './coerce';\nimport type { ClientRegistry } from './client-registry';\nimport type { GlobalConfig } from './global-config';\nimport { renderLandingPage } from './landing-page';\nimport type { AdapterConfig, AdapterInterface, ClientRecord, SessionData } from './types';\n\n/** Hard cap on in-flight auth flow sessions. Older entries are dropped FIFO when full. */\nconst SESSIONS_CAP = 100;\n/** Hard cap on remembered refresh tokens. Older entries are dropped FIFO when full. */\nconst REFRESH_TOKENS_CAP = 200;\n/**\n * Hard cap on tracked login-attempt entries. Internet-exposed instances see\n * carrier-grade-NAT IPs accumulate over time \u2014 without a cap, the map leaks.\n * Older entries are dropped FIFO when full.\n */\nconst LOGIN_ATTEMPTS_CAP = 1000;\n\n/**\n * Constant-time string comparison for credential checks. Returns false for length mismatch.\n *\n * @param a First string to compare.\n * @param b Second string to compare.\n */\nfunction safeStringEqual(a: string, b: string): boolean {\n const ab = Buffer.from(a, 'utf8');\n const bb = Buffer.from(b, 'utf8');\n if (ab.length !== bb.length) {\n return false;\n }\n return crypto.timingSafeEqual(ab, bb);\n}\n\n/** Adapter surface the WebServer depends on \u2014 adds `namespace` for the setup page. */\nexport type WebServerAdapter = AdapterInterface & Pick<ioBroker.Adapter, 'namespace'>;\n\n/** Browser cookie name. Client identity lives here \u2014 auto-sent on every page navigation. */\nexport const CLIENT_COOKIE = 'hassemu_client';\n/** Cookie lifetime (10 years). Clients stay identified essentially forever unless removed. */\nconst COOKIE_MAX_AGE_S = 10 * 365 * 24 * 60 * 60;\n\n/**\n * Fastify web server emulating the HA REST API.\n *\n * Each incoming request is identified by cookie \u2192 {@link ClientRegistry} entry; new clients\n * get a channel created on first hit. Express was swapped for Fastify in 1.1.0 for first-party\n * cookie support, schema validation and a lighter runtime.\n */\nexport class WebServer {\n private readonly adapter: WebServerAdapter;\n private readonly config: AdapterConfig;\n private readonly registry: ClientRegistry;\n private readonly globalConfig: GlobalConfig;\n private readonly app: FastifyInstance;\n public readonly sessions: Map<string, SessionData> = new Map();\n /**\n * Issued refresh tokens \u2192 owning clientId. Validated on every refresh-grant \u2014\n * unknown tokens are rejected (was: any string accepted).\n */\n public readonly refreshTokens: Map<string, string> = new Map();\n /**\n * Brute-force lockout state per remote IP. Each entry tracks failed login\n * attempts and the timestamp of the last failure; once\n * {@link LOGIN_LOCKOUT_THRESHOLD} is reached, `lockedUntil` is set and\n * further attempts from that IP are rejected with HTTP 429 until the\n * window passes. The map is FIFO-capped at {@link LOGIN_ATTEMPTS_CAP}\n * (else internet-exposed instances leak slowly via stray failure counts\n * with `lockedUntil=0`); expired and stale entries are pruned in\n * {@link cleanupSessions}.\n */\n public readonly loginAttempts: Map<string, { failedCount: number; lockedUntil: number; lastSeen: number }> =\n new Map();\n private cleanupTimer: ioBroker.Interval | null = null;\n public readonly instanceUuid: string;\n /** ioBroker system language for the setup page \u2014 resolved on startup. */\n public readonly systemLanguage: string;\n /** Set of IPs whose reverse DNS lookup is already in-flight \u2014 prevents duplicate work. */\n private readonly dnsInFlight = new Set<string>();\n\n /**\n * @param adapter Adapter instance used for logging, timers and namespace.\n * @param config Resolved runtime config.\n * @param registry Multi-client registry.\n * @param globalConfig Global redirect override.\n * @param instanceUuid Stable UUID shared with the mDNS advert.\n * @param systemLanguage ioBroker system language (`en`, `de`, \u2026) used for the setup page.\n */\n constructor(\n adapter: WebServerAdapter,\n config: AdapterConfig,\n registry: ClientRegistry,\n globalConfig: GlobalConfig,\n instanceUuid: string,\n systemLanguage: string = 'en',\n ) {\n this.adapter = adapter;\n this.config = config;\n this.registry = registry;\n this.globalConfig = globalConfig;\n this.instanceUuid = instanceUuid;\n this.systemLanguage = systemLanguage;\n this.app = Fastify({ logger: false, trustProxy: false });\n }\n\n /** Human-readable service name advertised in responses and mDNS. */\n get serviceName(): string {\n return this.config.serviceName || 'ioBroker';\n }\n\n /** Resolved listener address once `start()` has completed, or null otherwise. */\n get boundAddress(): { address: string; port: number } | null {\n const addr = this.app.server.address();\n if (!addr || typeof addr === 'string') {\n return null;\n }\n return { address: addr.address, port: addr.port };\n }\n\n // --- lifecycle ---\n\n /** Registers plugins and starts the HTTP listener. */\n async start(): Promise<void> {\n await this.app.register(fastifyCookie);\n // OAuth2-Spec verlangt `application/x-www-form-urlencoded` f\u00FCr `/auth/token`.\n // Echte HA-Reference-Clients (frontend/Wall Display SDK) folgen dem.\n // Fastify hat by-default nur einen JSON-Bodyparser \u2014 ohne diesen Plugin\n // beantwortet `/auth/token` mit form-Body 415 und der Login bleibt komplett\n // h\u00E4ngen. Tests via `app.inject({payload:{...}})` serialisieren zu JSON\n // und maskieren das.\n await this.app.register(fastifyFormbody);\n this.setupErrorHandler();\n this.setupRoutes();\n\n const bindAddress = this.config.bindAddress || '0.0.0.0';\n try {\n await this.app.listen({ port: this.config.port, host: bindAddress });\n } catch (err) {\n const e = err as NodeJS.ErrnoException;\n const msg =\n e.code === 'EADDRINUSE' ? `Port ${this.config.port} is already in use!` : `Server error: ${e.message}`;\n this.adapter.log.error(msg);\n throw err;\n }\n this.adapter.log.debug(`Web server listening on ${bindAddress}:${this.config.port}`);\n\n this.cleanupTimer = this.adapter.setInterval(() => this.cleanupSessions(), CLEANUP_INTERVAL_MS) ?? null;\n }\n\n /** Stops the listener and cancels the session cleanup timer. */\n async stop(): Promise<void> {\n if (this.cleanupTimer) {\n this.adapter.clearInterval(this.cleanupTimer);\n this.cleanupTimer = null;\n }\n try {\n await this.app.close();\n this.adapter.log.debug('Web server stopped');\n } catch (err) {\n this.adapter.log.error(`Web server stop error: ${String(err)}`);\n }\n }\n\n /** Exposed for testing \u2014 fires injected requests without a real socket. */\n get inject(): FastifyInstance['inject'] {\n return this.app.inject.bind(this.app);\n }\n\n /** Periodic cleanup of expired in-flight auth sessions and stale lockouts. */\n public cleanupSessions(): void {\n const now = Date.now();\n let cleanedSessions = 0;\n for (const [key, session] of this.sessions) {\n if (now - session.created > SESSION_TTL_MS) {\n this.sessions.delete(key);\n cleanedSessions++;\n }\n }\n if (cleanedSessions > 0) {\n this.adapter.log.debug(`Session cleanup: removed ${cleanedSessions} expired sessions`);\n }\n let cleanedLockouts = 0;\n for (const [ip, entry] of this.loginAttempts) {\n // Expired lockout \u2014 IP gets a fresh budget on next attempt.\n if (entry.lockedUntil > 0 && entry.lockedUntil <= now) {\n this.loginAttempts.delete(ip);\n cleanedLockouts++;\n continue;\n }\n // Stale failure-count without active lockout (failedCount<threshold,\n // lockedUntil=0). Without this prune, every distinct IP that ever\n // typed a wrong password leaves a row behind forever.\n if (entry.lockedUntil === 0 && entry.failedCount > 0 && now - entry.lastSeen > LOGIN_LOCKOUT_WINDOW_MS) {\n this.loginAttempts.delete(ip);\n cleanedLockouts++;\n }\n }\n if (cleanedLockouts > 0) {\n this.adapter.log.debug(`Lockout cleanup: cleared ${cleanedLockouts} expired/stale IP entries`);\n }\n }\n\n /**\n * Drops the oldest entry of a Map if it would exceed `cap` after the next insert.\n * Map iteration order in JS is insertion order, so `keys().next()` is the oldest.\n *\n * @param map Map to evict from.\n * @param cap Hard cap; when `map.size >= cap`, the oldest entry is removed.\n */\n private static evictOldest<V>(map: Map<string, V>, cap: number): void {\n if (map.size < cap) {\n return;\n }\n const oldest = map.keys().next().value;\n if (oldest !== undefined) {\n map.delete(oldest);\n }\n }\n\n /**\n * Inserts a session, dropping the oldest entry if {@link SESSIONS_CAP} is exceeded.\n *\n * @param key Session key (flow id or auth code).\n * @param data Session payload.\n */\n private storeSession(key: string, data: SessionData): void {\n WebServer.evictOldest(this.sessions, SESSIONS_CAP);\n this.sessions.set(key, data);\n }\n\n /**\n * Inserts a refresh token mapping, dropping the oldest if cap exceeded.\n *\n * @param token Refresh token issued in `/auth/token`.\n * @param clientId Owning client id.\n */\n private storeRefreshToken(token: string, clientId: string): void {\n WebServer.evictOldest(this.refreshTokens, REFRESH_TOKENS_CAP);\n this.refreshTokens.set(token, clientId);\n }\n\n /**\n * Brute-force lockout: returns true if `ip` is currently in the timeout window.\n * Lazy-resets entries whose lockout already expired (caller can immediately try again).\n *\n * @param ip Remote IP, or null when unavailable.\n */\n private isIpLocked(ip: string | null): boolean {\n if (!ip) {\n return false;\n }\n const entry = this.loginAttempts.get(ip);\n if (!entry || entry.lockedUntil === 0) {\n return false;\n }\n if (entry.lockedUntil > Date.now()) {\n return true;\n }\n // Lockout window passed \u2014 drop the entry, IP gets a fresh budget.\n this.loginAttempts.delete(ip);\n return false;\n }\n\n /**\n * Records a failed login attempt for `ip`. When the running count reaches\n * {@link LOGIN_LOCKOUT_THRESHOLD}, the IP is locked for\n * {@link LOGIN_LOCKOUT_WINDOW_MS}.\n *\n * @param ip Remote IP that failed authentication.\n */\n private recordLoginFailure(ip: string | null): void {\n if (!ip) {\n return;\n }\n const now = Date.now();\n const entry = this.loginAttempts.get(ip) ?? { failedCount: 0, lockedUntil: 0, lastSeen: now };\n entry.failedCount += 1;\n entry.lastSeen = now;\n if (entry.failedCount >= LOGIN_LOCKOUT_THRESHOLD) {\n entry.lockedUntil = now + LOGIN_LOCKOUT_WINDOW_MS;\n this.adapter.log.warn(\n `Login lockout: IP ${ip} reached ${LOGIN_LOCKOUT_THRESHOLD} failed attempts \u2014 ` +\n `locked for ${Math.round(LOGIN_LOCKOUT_WINDOW_MS / 60000)} min`,\n );\n }\n // Cap loginAttempts before insert to avoid unbounded growth from stray\n // attempts. evictOldest is a no-op when this ip already exists in map\n // (size won't change on re-set).\n if (!this.loginAttempts.has(ip)) {\n WebServer.evictOldest(this.loginAttempts, LOGIN_ATTEMPTS_CAP);\n }\n this.loginAttempts.set(ip, entry);\n }\n\n /**\n * Resets the failure counter and any active lockout for `ip`. Called after\n * a successful credential check so legit clients don't accumulate counts\n * across long-lived sessions.\n *\n * @param ip Remote IP that just authenticated successfully.\n */\n private clearLoginAttempts(ip: string | null): void {\n if (ip) {\n this.loginAttempts.delete(ip);\n }\n }\n\n // --- client identification ---\n\n private async identify(req: FastifyRequest, reply: FastifyReply): Promise<ClientRecord> {\n const cookie = coerceUuid(req.cookies?.[CLIENT_COOKIE]);\n const ip = coerceString(req.ip);\n const record = await this.registry.identifyOrCreate(cookie, ip, null);\n if (cookie !== record.cookie) {\n reply.setCookie(CLIENT_COOKIE, record.cookie, {\n path: '/',\n httpOnly: true,\n sameSite: 'lax',\n maxAge: COOKIE_MAX_AGE_S,\n });\n }\n if (ip) {\n this.resolveHostnameAsync(record, ip);\n }\n return record;\n }\n\n private resolveHostnameAsync(record: ClientRecord, ip: string): void {\n if (record.hostname || this.dnsInFlight.has(ip)) {\n return;\n }\n this.dnsInFlight.add(ip);\n dns.reverse(ip)\n .then(names => {\n const name = names[0];\n if (name) {\n this.registry.identifyOrCreate(record.cookie, ip, name).catch(() => {\n /* registry itself logs */\n });\n }\n })\n .catch(() => {\n // Reverse DNS often fails on LAN \u2014 intentionally silent.\n })\n .finally(() => {\n this.dnsInFlight.delete(ip);\n });\n }\n\n // --- error handling ---\n\n private setupErrorHandler(): void {\n this.app.setErrorHandler((err, _req, reply) => {\n const error = err as Error & { validation?: unknown; statusCode?: number };\n if (error.validation) {\n this.adapter.log.debug(`Validation error: ${error.message}`);\n reply.status(400).send({ error: 'Invalid request', details: error.message });\n return;\n }\n // Fastify body-parsing / client errors already set statusCode in 4xx range\n const code = typeof error.statusCode === 'number' ? error.statusCode : 500;\n if (code >= 400 && code < 500) {\n this.adapter.log.debug(`Client error ${code}: ${error.message}`);\n reply.status(code).send({ error: error.message });\n return;\n }\n this.adapter.log.warn(`Request error: ${error.message}`);\n reply.status(500).send({ error: 'Internal server error' });\n });\n }\n\n // --- routes ---\n\n private setupRoutes(): void {\n this.setupApiRoutes();\n this.setupAuthRoutes();\n this.setupMiscRoutes();\n this.setupNotFound();\n }\n\n private setupApiRoutes(): void {\n // CRITICAL: trailing slash \u2014 HA clients check this endpoint for discovery\n this.app.get('/api/', () => ({ message: 'API running.' }));\n\n this.app.get('/api/config', () => ({\n components: ['http', 'api', 'frontend', 'homeassistant'],\n config_dir: '/config',\n elevation: 0,\n latitude: 0,\n longitude: 0,\n location_name: this.serviceName,\n time_zone: 'UTC',\n unit_system: { length: 'km', mass: 'g', temperature: '\u00B0C', volume: 'L' },\n version: HA_VERSION,\n whitelist_external_dirs: [],\n }));\n\n this.app.get('/api/discovery_info', req => {\n const host = req.hostname || this.config.bindAddress || '0.0.0.0';\n const baseUrl = `http://${host}:${this.config.port}`;\n return {\n base_url: baseUrl,\n external_url: null,\n internal_url: baseUrl,\n location_name: this.serviceName,\n // Vorher hardcoded `true` unabh\u00E4ngig von authRequired \u2014 strict HA-Clients\n // versuchten Auth auch bei authRequired=false und scheiterten am leeren Login-Flow.\n requires_api_password: this.config.authRequired,\n uuid: this.instanceUuid,\n version: HA_VERSION,\n };\n });\n\n for (const path of ['/api/states', '/api/services', '/api/events']) {\n this.app.get(path, () => []);\n }\n this.app.get('/api/error_log', () => '');\n }\n\n private setupAuthRoutes(): void {\n this.app.get('/auth/providers', () => [{ name: 'Home Assistant Local', type: 'homeassistant', id: null }]);\n\n this.app.post('/auth/login_flow', async (req, reply) => {\n const client = await this.identify(req, reply);\n const flowId = crypto.randomUUID();\n this.storeSession(flowId, { created: Date.now(), clientId: client.id });\n this.adapter.log.debug(`Auth flow created: ${flowId} for client ${client.id}`);\n\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n description_placeholders: null,\n errors: null,\n };\n });\n\n this.app.post<{\n Params: { flowId: string };\n Body: { username?: string; password?: string };\n }>(\n '/auth/login_flow/:flowId',\n {\n schema: {\n params: {\n type: 'object',\n properties: { flowId: { type: 'string', minLength: 1 } },\n required: ['flowId'],\n },\n },\n },\n async (req, reply) => {\n const flowId = req.params.flowId;\n const session = this.sessions.get(flowId);\n if (!session) {\n this.adapter.log.warn(`Unknown flow_id: ${flowId}`);\n reply.status(400);\n return { type: 'abort', flow_id: flowId, reason: 'unknown_flow' };\n }\n\n if (this.config.authRequired) {\n const ip = coerceString(req.ip);\n if (this.isIpLocked(ip)) {\n this.adapter.log.warn(`Login rejected: IP ${ip} is currently locked out`);\n reply.status(429);\n return { type: 'abort', flow_id: flowId, reason: 'too_many_failed_attempts' };\n }\n const { username, password } = req.body ?? {};\n const userOk = typeof username === 'string' && safeStringEqual(username, this.config.username);\n const passOk = typeof password === 'string' && safeStringEqual(password, this.config.password);\n if (!userOk || !passOk) {\n this.recordLoginFailure(ip);\n this.adapter.log.warn('Invalid credentials');\n reply.status(400);\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n errors: { base: 'invalid_auth' },\n description_placeholders: null,\n };\n }\n this.clearLoginAttempts(ip);\n }\n\n this.sessions.delete(flowId);\n const code = crypto.randomUUID();\n this.storeSession(code, { created: Date.now(), clientId: session.clientId });\n this.adapter.log.debug('Auth flow completed \u2014 code issued');\n\n return {\n version: 1,\n type: 'create_entry',\n flow_id: flowId,\n handler: ['homeassistant', null],\n result: code,\n description: null,\n description_placeholders: null,\n };\n },\n );\n\n this.app.post<{ Body: { code?: string; grant_type?: string; refresh_token?: string } }>(\n '/auth/token',\n async (req, reply) => {\n const { code, grant_type, refresh_token } = req.body ?? {};\n\n if (grant_type === 'authorization_code' && code && this.sessions.has(code)) {\n const session = this.sessions.get(code)!;\n this.sessions.delete(code);\n const token = crypto.randomUUID();\n const refreshToken = crypto.randomUUID();\n if (session.clientId) {\n await this.registry.setToken(session.clientId, token);\n this.storeRefreshToken(refreshToken, session.clientId);\n this.adapter.log.debug(`Display authenticated \u2014 client ${session.clientId}`);\n }\n return {\n access_token: token,\n token_type: 'Bearer',\n refresh_token: refreshToken,\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n if (grant_type === 'refresh_token') {\n // Validate the refresh token against issued ones \u2014 was previously\n // accepting any string and minting a new access_token (security fix v1.2.0).\n const incoming = typeof refresh_token === 'string' ? refresh_token : '';\n const ownerId = incoming ? this.refreshTokens.get(incoming) : undefined;\n if (!ownerId) {\n this.adapter.log.warn('Refresh token rejected \u2014 unknown or missing');\n reply.status(400);\n return { error: 'invalid_grant', error_description: 'Invalid refresh token' };\n }\n const newAccess = crypto.randomUUID();\n await this.registry.setToken(ownerId, newAccess);\n return {\n access_token: newAccess,\n token_type: 'Bearer',\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n this.adapter.log.warn(`Token exchange failed: grant_type=${String(grant_type)}`);\n reply.status(400);\n return { error: 'invalid_request', error_description: 'Invalid or expired code' };\n },\n );\n }\n\n private setupMiscRoutes(): void {\n // Liveness only \u2014 no config leak. Earlier versions exposed the global\n // redirect URL via /health which is unauthenticated; removed in v1.2.0.\n // v1.5.0: auch der `config: { mdns, auth }`-Block raus \u2014 Auth-Status leakte\n // unauthenticated und lie\u00DF sich von einem Network-Attacker zur Reconnaissance\n // nutzen (auth-disabled Instances quickly mappen).\n this.app.get('/health', () => ({\n status: 'ok',\n adapter: 'hassemu',\n version: HA_VERSION,\n }));\n\n this.app.get('/manifest.json', () => ({\n name: this.serviceName,\n short_name: this.serviceName,\n start_url: '/',\n display: 'standalone',\n background_color: '#ffffff',\n theme_color: '#03a9f4',\n }));\n\n // Root \u2014 302 redirect, or landing page when no URL is configured\n this.app.get('/', async (req, reply) => {\n const client = await this.identify(req, reply);\n const url = this.globalConfig.resolveUrlFor(client);\n if (!url) {\n this.adapter.log.debug(`No redirect URL for client ${client.id} \u2014 serving landing page`);\n return reply\n .status(200)\n .type('text/html; charset=utf-8')\n .send(renderLandingPage(client.id, this.adapter.namespace, this.systemLanguage, client.ip));\n }\n this.adapter.log.debug(`Redirecting client ${client.id} \u2192 ${url}`);\n return reply.redirect(url, 302);\n });\n }\n\n private setupNotFound(): void {\n this.app.setNotFoundHandler((req, reply) => {\n this.adapter.log.debug(`404: ${req.method} ${req.url}`);\n reply.status(404).send({ error: 'Not Found', path: req.url });\n });\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["crypto", "Fastify", "fastifyCookie", "fastifyFormbody", "dns"]
7
7
  }
package/build/main.js CHANGED
@@ -84,6 +84,7 @@ class HassEmu extends utils.Adapter {
84
84
  await this.subscribeForeignObjectsAsync("system.adapter.*");
85
85
  await this.subscribeStatesAsync("clients.*");
86
86
  await this.subscribeStatesAsync("global.*");
87
+ await this.subscribeStatesAsync("info.refresh_urls");
87
88
  const systemLanguage = await this.readSystemLanguage();
88
89
  try {
89
90
  this.webServer = new import_webserver.WebServer(
@@ -154,9 +155,46 @@ class HassEmu extends utils.Adapter {
154
155
  if (!url) {
155
156
  return;
156
157
  }
158
+ const safe = (0, import_coerce.coerceSafeUrl)(url);
159
+ if (!safe) {
160
+ this.log.warn(
161
+ `Legacy URL rejected as unsafe \u2014 dropping native.defaultVisUrl/visUrl without migration: ${String(url)}`
162
+ );
163
+ try {
164
+ const id = `system.adapter.${this.namespace}`;
165
+ const obj = await this.getForeignObjectAsync(id);
166
+ if (obj == null ? void 0 : obj.native) {
167
+ delete obj.native.defaultVisUrl;
168
+ delete obj.native.visUrl;
169
+ await this.setForeignObjectAsync(id, obj);
170
+ }
171
+ } catch (err) {
172
+ this.log.warn(`Legacy config cleanup failed: ${String(err)}`);
173
+ }
174
+ return;
175
+ }
157
176
  this.log.info("Migrating legacy native.defaultVisUrl/visUrl \u2192 global.visUrl");
158
- await this.setStateAsync("global.visUrl", { val: url, ack: true }).catch(() => {
159
- });
177
+ let stateWritten = false;
178
+ try {
179
+ await this.setStateAsync("global.visUrl", { val: safe, ack: true });
180
+ stateWritten = true;
181
+ } catch {
182
+ try {
183
+ if (this.globalConfig) {
184
+ await this.globalConfig.migrationSet(import_global_config.MODE_MANUAL, safe);
185
+ this.log.info(
186
+ `Migration shortcut: global.visUrl-state missing \u2192 wrote directly to global.mode='manual', manualUrl='${safe}'`
187
+ );
188
+ stateWritten = true;
189
+ }
190
+ } catch (err) {
191
+ this.log.warn(`Legacy URL migration failed at fallback: ${String(err)}`);
192
+ }
193
+ }
194
+ if (!stateWritten) {
195
+ this.log.warn("Legacy URL preserved in native \u2014 neither global.visUrl nor global.mode write succeeded");
196
+ return;
197
+ }
160
198
  try {
161
199
  const id = `system.adapter.${this.namespace}`;
162
200
  const obj = await this.getForeignObjectAsync(id);
@@ -357,6 +395,30 @@ class HassEmu extends utils.Adapter {
357
395
  } else if (globalParsed === "enabled") {
358
396
  await this.globalConfig.handleEnabledWrite(state.val);
359
397
  await this.applyMasterSwitch(this.globalConfig.isEnabled());
398
+ return;
399
+ }
400
+ if (id === `${this.namespace}.info.refresh_urls` && state.val === true) {
401
+ await this.handleRefreshUrlsWrite();
402
+ }
403
+ }
404
+ /**
405
+ * Handler for the `info.refresh_urls` button.
406
+ * Triggert eine sofortige `urlDiscovery.collect()` (statt Debounce-Schedule),
407
+ * damit der User nicht 2s warten muss. Schreibt anschließend `false ack` damit
408
+ * der Button in der Admin-UI wieder „klickbar" wird.
409
+ */
410
+ async handleRefreshUrlsWrite() {
411
+ if (!this.urlDiscovery) {
412
+ return;
413
+ }
414
+ try {
415
+ await this.urlDiscovery.collect();
416
+ this.log.info("URL discovery refreshed on user request");
417
+ } catch (err) {
418
+ this.log.warn(`URL refresh failed: ${err instanceof Error ? err.message : String(err)}`);
419
+ } finally {
420
+ await this.setStateAsync("info.refresh_urls", { val: false, ack: true }).catch(() => {
421
+ });
360
422
  }
361
423
  }
362
424
  onUnload(callback) {
package/build/main.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/main.ts"],
4
- "sourcesContent": ["import crypto from 'node:crypto';\nimport * as utils from '@iobroker/adapter-core';\nimport { ClientRegistry, parseClientStateId } from './lib/client-registry';\nimport { coerceSafeUrl } from './lib/coerce';\nimport { GlobalConfig, MODE_GLOBAL, MODE_MANUAL, parseGlobalStateId } from './lib/global-config';\nimport { MDNSService } from './lib/mdns';\nimport { UrlDiscovery } from './lib/url-discovery';\nimport { WebServer } from './lib/webserver';\nimport type { AdapterConfig } from './lib/types';\n\n/** Stale-Client-GC threshold: clients without token + lastSeen older are auto-removed. */\nconst STALE_CLIENT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days\n\nclass HassEmu extends utils.Adapter {\n private mdnsService: MDNSService | null = null;\n private webServer: WebServer | null = null;\n private registry: ClientRegistry | null = null;\n private globalConfig: GlobalConfig | null = null;\n private urlDiscovery: UrlDiscovery | null = null;\n private unhandledRejectionHandler: ((reason: unknown) => void) | null = null;\n private uncaughtExceptionHandler: ((err: Error) => void) | null = null;\n\n declare config: AdapterConfig;\n\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({ ...options, name: 'hassemu' });\n\n this.on('ready', () => {\n this.onReady().catch(err => this.log.error(`onReady unhandled: ${String(err)}`));\n });\n this.on('stateChange', (id, state) => {\n this.onStateChange(id, state).catch(err => this.log.error(`stateChange unhandled: ${String(err)}`));\n });\n this.on('objectChange', () => {\n // Foreign object changed \u2014 refresh URL dropdown (debounced inside discovery)\n this.urlDiscovery?.scheduleRefresh();\n });\n this.on('unload', this.onUnload.bind(this));\n\n // Last-line-of-defence against unhandled rejections / sync throws from\n // fire-and-forget paths. The per-handler wrappers cover documented async\n // paths; this catches anything that slips past during refactors.\n this.unhandledRejectionHandler = (reason: unknown) => {\n this.log.error(`Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);\n };\n this.uncaughtExceptionHandler = (err: Error) => {\n this.log.error(`Uncaught exception: ${err.message}`);\n };\n process.on('unhandledRejection', this.unhandledRejectionHandler);\n process.on('uncaughtException', this.uncaughtExceptionHandler);\n }\n\n private async onReady(): Promise<void> {\n await this.setState('info.connection', { val: false, ack: true });\n\n this.globalConfig = new GlobalConfig(this);\n await this.globalConfig.restore();\n\n this.registry = new ClientRegistry(this);\n await this.registry.restore();\n\n // Migrations run before subscriptions / webserver \u2014 first the legacy\n // 1.0.x-style native config, then the visUrl \u2192 mode/manualUrl move,\n // then a defensive schema repair for users upgrading from v1.2.0+\n // (where the partial-formed mode-object from the v1.2.0 extend-bug\n // persists since `legacy.visUrl` is already gone and migrate doesn't trigger).\n await this.migrateLegacyDefaultVisUrl();\n await this.migrateVisUrlToMode();\n await this.repairGlobalSchemas();\n\n // Garbage-collect stale clients (no token + lastSeen older than 30 days).\n await this.gcStaleClients();\n\n const instanceUuid = crypto.randomUUID();\n this.log.debug(\n `Config: port=${this.config.port}, auth=${this.config.authRequired}, mdns=${this.config.mdnsEnabled}`,\n );\n\n this.urlDiscovery = new UrlDiscovery(this, async states => {\n await this.globalConfig?.syncUrlDropdown(states);\n await this.registry?.syncUrlDropdown(states);\n });\n await this.urlDiscovery.collect();\n\n // After discovery: wire the default-mode provider for new clients.\n // - global.enabled=true \u2192 new clients default to 'global' (follow master)\n // - global.enabled=false \u2192 first discovered URL, fallback 'manual'\n this.registry.setNewClientModeProvider(() => this.computeNewClientMode());\n\n // Watch broker state for new/removed instances, VIS projects and client/global writes\n await this.subscribeForeignObjectsAsync('system.adapter.*');\n await this.subscribeStatesAsync('clients.*');\n await this.subscribeStatesAsync('global.*');\n\n const systemLanguage = await this.readSystemLanguage();\n\n try {\n this.webServer = new WebServer(\n this,\n this.config,\n this.registry,\n this.globalConfig,\n instanceUuid,\n systemLanguage,\n );\n await this.webServer.start();\n } catch (err) {\n this.log.error(`Web server failed to start: ${String(err)}`);\n return;\n }\n\n if (this.config.mdnsEnabled) {\n this.mdnsService = new MDNSService(this, this.config, instanceUuid);\n this.mdnsService.start();\n } else {\n this.log.debug('mDNS disabled \u2014 clients must enter the URL manually.');\n }\n\n await this.setState('info.connection', { val: true, ack: true });\n const bindAddr = this.config.bindAddress || '0.0.0.0';\n this.log.info(\n `HA emulation running on ${bindAddr}:${this.config.port}${this.config.mdnsEnabled ? ', mDNS active' : ''}`,\n );\n }\n\n /**\n * Default mode for newly registered clients. Respects the master switch:\n * - `global.enabled=true` \u2192 `'global'` (follow master)\n * - `global.enabled=false` \u2192 first discovered URL, fallback `'manual'`\n */\n private computeNewClientMode(): string {\n if (this.globalConfig?.isEnabled()) {\n return MODE_GLOBAL;\n }\n const first = this.urlDiscovery?.getFirstDiscoveredUrl();\n return first ?? MODE_MANUAL;\n }\n\n /**\n * Read the ioBroker system language (set in Admin \u2192 Main Settings).\n * Used for the landing page so the end-user sees the same language as\n * their admin UI. Falls back to `en` when `system.config` can't be read\n * or holds a language we don't translate. Read once on startup \u2014 a\n * language switch at runtime only takes effect after an adapter restart,\n * which is fine for a setup-hint page that most users see once.\n */\n private async readSystemLanguage(): Promise<string> {\n try {\n const cfg = await this.getForeignObjectAsync('system.config');\n const lang = (cfg?.common as { language?: string } | undefined)?.language;\n return typeof lang === 'string' && lang.length > 0 ? lang : 'en';\n } catch {\n return 'en';\n }\n }\n\n /**\n * 1.0.x / 1.1.0 \u2192 1.1.1 migration \u2014 move the legacy `defaultVisUrl` from\n * instance native into `global.visUrl` + `global.enabled=true` and drop it\n * from native. Subsequent migrations (`migrateVisUrlToMode`) then move\n * `global.visUrl` into the mode/manualUrl model.\n */\n private async migrateLegacyDefaultVisUrl(): Promise<void> {\n const legacy = this.config as AdapterConfig & { defaultVisUrl?: string; visUrl?: string };\n const url = legacy.defaultVisUrl || legacy.visUrl;\n if (!url) {\n return;\n }\n this.log.info('Migrating legacy native.defaultVisUrl/visUrl \u2192 global.visUrl');\n // We cannot call globalConfig.handleVisUrlWrite \u2014 that method is gone in\n // v1.2.0. Write the legacy state directly so migrateVisUrlToMode picks it up.\n await this.setStateAsync('global.visUrl', { val: url, ack: true }).catch(() => {\n // global.visUrl object may not exist anymore (v1.2.0 instanceObjects);\n // create it transparently for the migration step.\n });\n try {\n const id = `system.adapter.${this.namespace}`;\n const obj = await this.getForeignObjectAsync(id);\n if (obj?.native) {\n delete obj.native.defaultVisUrl;\n delete obj.native.visUrl;\n await this.setForeignObjectAsync(id, obj);\n }\n } catch (err) {\n this.log.warn(`Legacy config cleanup failed: ${String(err)}`);\n }\n }\n\n /**\n * 1.x \u2192 1.2.0 migration \u2014 move legacy per-client `visUrl`-states to the\n * `mode`/`manualUrl` model, plus the global `visUrl` to `global.mode` +\n * `global.manualUrl`. Old datapoints are removed, type of mode-states\n * upgraded to 'mixed'. Idempotent \u2014 does nothing on subsequent starts.\n */\n private async migrateVisUrlToMode(): Promise<void> {\n // 1) Global visUrl \u2192 mode + manualUrl\n try {\n const legacyGlobal = await this.getStateAsync('global.visUrl');\n if (\n legacyGlobal &&\n legacyGlobal.val !== undefined &&\n legacyGlobal.val !== null &&\n legacyGlobal.val !== ''\n ) {\n const safe = coerceSafeUrl(legacyGlobal.val);\n if (safe) {\n await this.globalConfig!.migrationSet(MODE_MANUAL, safe);\n this.log.info(`Migration: global.visUrl \u2192 mode='manual', manualUrl='${safe}'`);\n } else {\n await this.globalConfig!.migrationSet(MODE_MANUAL, null);\n this.log.warn(`Migration: legacy global.visUrl rejected as unsafe \u2014 set global.manualUrl manually`);\n }\n }\n } catch {\n /* state didn't exist \u2014 fresh install or already migrated */\n }\n try {\n await this.delObjectAsync('global.visUrl');\n } catch {\n /* didn't exist */\n }\n\n // 2) Per-client visUrl \u2192 mode='manual' + manualUrl\n const records = this.registry?.listAll() ?? [];\n for (const record of records) {\n try {\n const legacy = await this.getStateAsync(`clients.${record.id}.visUrl`);\n if (legacy && legacy.val !== undefined && legacy.val !== null && legacy.val !== '') {\n const safe = coerceSafeUrl(legacy.val);\n if (safe) {\n record.mode = MODE_MANUAL;\n record.manualUrl = safe;\n await this.setStateAsync(`clients.${record.id}.mode`, { val: MODE_MANUAL, ack: true });\n await this.setStateAsync(`clients.${record.id}.manualUrl`, { val: safe, ack: true });\n this.log.info(\n `Migration: client ${record.id} visUrl='${safe}' \u2192 mode='manual', manualUrl='${safe}'`,\n );\n } else {\n this.log.warn(\n `Migration: client ${record.id} legacy visUrl rejected as unsafe \u2014 set clients.${record.id}.manualUrl manually`,\n );\n }\n }\n } catch {\n /* state didn't exist for this client */\n }\n try {\n await this.delObjectAsync(`clients.${record.id}.visUrl`);\n } catch {\n /* didn't exist */\n }\n }\n\n // 3) global.mode + global.manualUrl repair handled by repairGlobalSchemas()\n // (called separately in onReady so it ALSO runs for users upgrading from\n // v1.2.0/v1.3.0/v1.3.1 where the legacy visUrl is already gone but the\n // partial-formed mode-object from the v1.2.0 extendObject-bug persists).\n }\n\n /**\n * Repairs partial-formed `global.mode` / `global.manualUrl` objects from\n * the v1.2.0 migration bug (extendObjectAsync was called with only\n * `common.type:'mixed'` \u2014 leaving the object without top-level `type`,\n * name, role, read, write, def). `extendObjectAsync` here merges the full\n * instanceObjects schema onto the existing partial object so js-controller\n * stops warning \"obj.type has to exist\" and the dropdown renders correctly.\n *\n * Idempotent \u2014 extending an already-complete object is a no-op write.\n */\n private async repairGlobalSchemas(): Promise<void> {\n try {\n await this.extendObjectAsync('global.mode', {\n type: 'state',\n common: {\n name: 'Global redirect mode',\n type: 'mixed',\n role: 'value',\n read: true,\n write: true,\n def: 0,\n },\n native: {},\n });\n } catch (err) {\n this.log.debug(`repair global.mode failed: ${String(err)}`);\n }\n try {\n await this.extendObjectAsync('global.manualUrl', {\n type: 'state',\n common: {\n name: \"Global manual URL (used when mode='manual')\",\n type: 'string',\n role: 'url',\n read: true,\n write: true,\n def: '',\n },\n native: {},\n });\n } catch (err) {\n this.log.debug(`repair global.manualUrl failed: ${String(err)}`);\n }\n }\n\n /**\n * Removes clients that are clearly stale: no auth token (= never authenticated\n * or revoked) AND `native.lastSeen` older than {@link STALE_CLIENT_TTL_MS}.\n * Clients without `lastSeen` (pre-1.2.0) get the timestamp seeded on this run\n * \u2014 GC kicks in only on subsequent restarts.\n */\n private async gcStaleClients(): Promise<void> {\n const now = Date.now();\n const records = this.registry?.listAll() ?? [];\n let removed = 0;\n for (const record of records) {\n if (record.token) {\n continue;\n }\n try {\n const obj = await this.getObjectAsync(`clients.${record.id}`);\n const native = (obj?.native as { lastSeen?: number } | undefined) ?? {};\n const lastSeen = typeof native.lastSeen === 'number' ? native.lastSeen : 0;\n if (lastSeen === 0) {\n // Pre-v1.2.0 client \u2014 seed timestamp, GC waits one cycle.\n await this.extendObjectAsync(`clients.${record.id}`, { native: { lastSeen: now } });\n continue;\n }\n if (now - lastSeen > STALE_CLIENT_TTL_MS) {\n await this.registry!.remove(record.id);\n removed++;\n }\n } catch (err) {\n this.log.debug(`Stale-GC: failed for ${record.id}: ${String(err)}`);\n }\n }\n if (removed > 0) {\n this.log.info(`Stale-Client-GC: removed ${removed} client(s) (no token + idle >30 days)`);\n }\n }\n\n /**\n * Master-switch action: when `global.enabled` flips, propagate to every\n * client's `mode`. true \u2192 all clients follow `'global'`. false \u2192 fall back\n * to the first discovered URL, or `'manual'` if discovery is empty.\n *\n * @param enabled New value of `global.enabled`.\n */\n private async applyMasterSwitch(enabled: boolean): Promise<void> {\n if (!this.registry) {\n return;\n }\n if (enabled) {\n await this.registry.bulkSetMode(MODE_GLOBAL);\n return;\n }\n const first = this.urlDiscovery?.getFirstDiscoveredUrl();\n if (first) {\n await this.registry.bulkSetMode(first);\n } else {\n await this.registry.bulkSetMode(MODE_MANUAL);\n this.log.warn(\n \"global.enabled=false but no discovered VIS URL \u2014 clients set to 'manual'; \" +\n 'fill clients.<id>.manualUrl per client',\n );\n }\n }\n\n private async onStateChange(id: string, state: ioBroker.State | null | undefined): Promise<void> {\n if (!state || state.ack) {\n return;\n }\n const clientParsed = this.registry ? parseClientStateId(id, this.namespace) : null;\n if (clientParsed) {\n if (clientParsed.kind === 'mode') {\n await this.registry!.handleModeWrite(clientParsed.id, state.val);\n // B4: if the user picked 'global' but global resolves to nothing,\n // give them a one-shot heads-up so the cause of the empty redirect\n // is obvious without digging through the resolver code.\n const record = this.registry!.getById(clientParsed.id);\n if (record?.mode === MODE_GLOBAL && this.globalConfig!.resolveUrlFor(record) === null) {\n this.log.warn(\n `Client ${record.id}: mode='global' but global has no resolvable URL \u2014 ` +\n 'fill global.mode/manualUrl, or pick a different client mode',\n );\n }\n } else if (clientParsed.kind === 'manualUrl') {\n await this.registry!.handleManualUrlWrite(clientParsed.id, state.val);\n } else if (clientParsed.kind === 'remove' && state.val === true) {\n await this.registry!.remove(clientParsed.id);\n }\n return;\n }\n const globalParsed = this.globalConfig ? parseGlobalStateId(id, this.namespace) : null;\n if (globalParsed === 'mode') {\n await this.globalConfig!.handleModeWrite(state.val);\n } else if (globalParsed === 'manualUrl') {\n await this.globalConfig!.handleManualUrlWrite(state.val);\n } else if (globalParsed === 'enabled') {\n await this.globalConfig!.handleEnabledWrite(state.val);\n await this.applyMasterSwitch(this.globalConfig!.isEnabled());\n }\n }\n\n private onUnload(callback: () => void): void {\n try {\n this.urlDiscovery?.cancelRefresh();\n this.urlDiscovery = null;\n\n if (this.mdnsService) {\n this.mdnsService.stop();\n this.mdnsService = null;\n }\n\n if (this.webServer) {\n this.webServer.stop().catch((err: Error) => this.log.error(`Server stop error: ${err.message}`));\n this.webServer = null;\n }\n\n this.registry = null;\n this.globalConfig = null;\n\n // Detach process-level last-line-of-defence handlers\n if (this.unhandledRejectionHandler) {\n process.off('unhandledRejection', this.unhandledRejectionHandler);\n this.unhandledRejectionHandler = null;\n }\n if (this.uncaughtExceptionHandler) {\n process.off('uncaughtException', this.uncaughtExceptionHandler);\n this.uncaughtExceptionHandler = null;\n }\n\n void this.setState('info.connection', { val: false, ack: true });\n } catch (error) {\n const err = error as Error;\n this.log.error(`Shutdown error: ${err.message}`);\n } finally {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new HassEmu(options);\n} else {\n (() => new HassEmu())();\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import crypto from 'node:crypto';\nimport * as utils from '@iobroker/adapter-core';\nimport { ClientRegistry, parseClientStateId } from './lib/client-registry';\nimport { coerceSafeUrl } from './lib/coerce';\nimport { GlobalConfig, MODE_GLOBAL, MODE_MANUAL, parseGlobalStateId } from './lib/global-config';\nimport { MDNSService } from './lib/mdns';\nimport { UrlDiscovery } from './lib/url-discovery';\nimport { WebServer } from './lib/webserver';\nimport type { AdapterConfig } from './lib/types';\n\n/** Stale-Client-GC threshold: clients without token + lastSeen older are auto-removed. */\nconst STALE_CLIENT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days\n\nclass HassEmu extends utils.Adapter {\n private mdnsService: MDNSService | null = null;\n private webServer: WebServer | null = null;\n private registry: ClientRegistry | null = null;\n private globalConfig: GlobalConfig | null = null;\n private urlDiscovery: UrlDiscovery | null = null;\n private unhandledRejectionHandler: ((reason: unknown) => void) | null = null;\n private uncaughtExceptionHandler: ((err: Error) => void) | null = null;\n\n declare config: AdapterConfig;\n\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({ ...options, name: 'hassemu' });\n\n this.on('ready', () => {\n this.onReady().catch(err => this.log.error(`onReady unhandled: ${String(err)}`));\n });\n this.on('stateChange', (id, state) => {\n this.onStateChange(id, state).catch(err => this.log.error(`stateChange unhandled: ${String(err)}`));\n });\n this.on('objectChange', () => {\n // Foreign object changed \u2014 refresh URL dropdown (debounced inside discovery)\n this.urlDiscovery?.scheduleRefresh();\n });\n this.on('unload', this.onUnload.bind(this));\n\n // Last-line-of-defence against unhandled rejections / sync throws from\n // fire-and-forget paths. The per-handler wrappers cover documented async\n // paths; this catches anything that slips past during refactors.\n this.unhandledRejectionHandler = (reason: unknown) => {\n this.log.error(`Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);\n };\n this.uncaughtExceptionHandler = (err: Error) => {\n this.log.error(`Uncaught exception: ${err.message}`);\n };\n process.on('unhandledRejection', this.unhandledRejectionHandler);\n process.on('uncaughtException', this.uncaughtExceptionHandler);\n }\n\n private async onReady(): Promise<void> {\n await this.setState('info.connection', { val: false, ack: true });\n\n this.globalConfig = new GlobalConfig(this);\n await this.globalConfig.restore();\n\n this.registry = new ClientRegistry(this);\n await this.registry.restore();\n\n // Migrations run before subscriptions / webserver \u2014 first the legacy\n // 1.0.x-style native config, then the visUrl \u2192 mode/manualUrl move,\n // then a defensive schema repair for users upgrading from v1.2.0+\n // (where the partial-formed mode-object from the v1.2.0 extend-bug\n // persists since `legacy.visUrl` is already gone and migrate doesn't trigger).\n await this.migrateLegacyDefaultVisUrl();\n await this.migrateVisUrlToMode();\n await this.repairGlobalSchemas();\n\n // Garbage-collect stale clients (no token + lastSeen older than 30 days).\n await this.gcStaleClients();\n\n const instanceUuid = crypto.randomUUID();\n this.log.debug(\n `Config: port=${this.config.port}, auth=${this.config.authRequired}, mdns=${this.config.mdnsEnabled}`,\n );\n\n this.urlDiscovery = new UrlDiscovery(this, async states => {\n await this.globalConfig?.syncUrlDropdown(states);\n await this.registry?.syncUrlDropdown(states);\n });\n await this.urlDiscovery.collect();\n\n // After discovery: wire the default-mode provider for new clients.\n // - global.enabled=true \u2192 new clients default to 'global' (follow master)\n // - global.enabled=false \u2192 first discovered URL, fallback 'manual'\n this.registry.setNewClientModeProvider(() => this.computeNewClientMode());\n\n // Watch broker state for new/removed instances, VIS projects and client/global writes\n await this.subscribeForeignObjectsAsync('system.adapter.*');\n await this.subscribeStatesAsync('clients.*');\n await this.subscribeStatesAsync('global.*');\n await this.subscribeStatesAsync('info.refresh_urls');\n\n const systemLanguage = await this.readSystemLanguage();\n\n try {\n this.webServer = new WebServer(\n this,\n this.config,\n this.registry,\n this.globalConfig,\n instanceUuid,\n systemLanguage,\n );\n await this.webServer.start();\n } catch (err) {\n this.log.error(`Web server failed to start: ${String(err)}`);\n return;\n }\n\n if (this.config.mdnsEnabled) {\n this.mdnsService = new MDNSService(this, this.config, instanceUuid);\n this.mdnsService.start();\n } else {\n this.log.debug('mDNS disabled \u2014 clients must enter the URL manually.');\n }\n\n await this.setState('info.connection', { val: true, ack: true });\n const bindAddr = this.config.bindAddress || '0.0.0.0';\n this.log.info(\n `HA emulation running on ${bindAddr}:${this.config.port}${this.config.mdnsEnabled ? ', mDNS active' : ''}`,\n );\n }\n\n /**\n * Default mode for newly registered clients. Respects the master switch:\n * - `global.enabled=true` \u2192 `'global'` (follow master)\n * - `global.enabled=false` \u2192 first discovered URL, fallback `'manual'`\n */\n private computeNewClientMode(): string {\n if (this.globalConfig?.isEnabled()) {\n return MODE_GLOBAL;\n }\n const first = this.urlDiscovery?.getFirstDiscoveredUrl();\n return first ?? MODE_MANUAL;\n }\n\n /**\n * Read the ioBroker system language (set in Admin \u2192 Main Settings).\n * Used for the landing page so the end-user sees the same language as\n * their admin UI. Falls back to `en` when `system.config` can't be read\n * or holds a language we don't translate. Read once on startup \u2014 a\n * language switch at runtime only takes effect after an adapter restart,\n * which is fine for a setup-hint page that most users see once.\n */\n private async readSystemLanguage(): Promise<string> {\n try {\n const cfg = await this.getForeignObjectAsync('system.config');\n const lang = (cfg?.common as { language?: string } | undefined)?.language;\n return typeof lang === 'string' && lang.length > 0 ? lang : 'en';\n } catch {\n return 'en';\n }\n }\n\n /**\n * 1.0.x / 1.1.0 \u2192 1.1.1 migration \u2014 move the legacy `defaultVisUrl` from\n * instance native into `global.visUrl` + `global.enabled=true` and drop it\n * from native. Subsequent migrations (`migrateVisUrlToMode`) then move\n * `global.visUrl` into the mode/manualUrl model.\n */\n private async migrateLegacyDefaultVisUrl(): Promise<void> {\n const legacy = this.config as AdapterConfig & { defaultVisUrl?: string; visUrl?: string };\n const url = legacy.defaultVisUrl || legacy.visUrl;\n if (!url) {\n return;\n }\n // Defensive: validiere die legacy-URL bevor wir sie nach `global.visUrl`\n // schreiben. Malicious-Werte (`javascript:`, `data:`) sollen nicht durch\n // die Migration durchrutschen \u2014 `migrateVisUrlToMode` validiert zwar\n // nochmal, aber zwischen den Migrations-Schritten w\u00FCrde unsafe-Wert\n // sichtbar sein, und die native-Cleanup ist unbedingt.\n const safe = coerceSafeUrl(url);\n if (!safe) {\n this.log.warn(\n `Legacy URL rejected as unsafe \u2014 dropping native.defaultVisUrl/visUrl without migration: ${String(url)}`,\n );\n try {\n const id = `system.adapter.${this.namespace}`;\n const obj = await this.getForeignObjectAsync(id);\n if (obj?.native) {\n delete obj.native.defaultVisUrl;\n delete obj.native.visUrl;\n await this.setForeignObjectAsync(id, obj);\n }\n } catch (err) {\n this.log.warn(`Legacy config cleanup failed: ${String(err)}`);\n }\n return;\n }\n\n this.log.info('Migrating legacy native.defaultVisUrl/visUrl \u2192 global.visUrl');\n // We cannot call globalConfig.handleVisUrlWrite \u2014 that method is gone in\n // v1.2.0. Write the legacy state directly so migrateVisUrlToMode picks it up.\n // Wichtig: wenn der State-Write FEHLSCHL\u00C4GT (z.B. weil global.visUrl-Object\n // in v1.2.0+ schon weg ist), d\u00FCrfen wir die native-Werte NICHT l\u00F6schen \u2014\n // sonst ist die User-URL silent verloren. Stattdessen direkt nach\n // global.mode/manualUrl schreiben (das Ziel wo migrateVisUrlToMode\n // sie sonst hingeschrieben h\u00E4tte).\n let stateWritten = false;\n try {\n await this.setStateAsync('global.visUrl', { val: safe, ack: true });\n stateWritten = true;\n } catch {\n // global.visUrl-Object existiert nicht mehr \u2192 direkt ins Ziel schreiben\n try {\n if (this.globalConfig) {\n await this.globalConfig.migrationSet(MODE_MANUAL, safe);\n this.log.info(\n `Migration shortcut: global.visUrl-state missing \u2192 wrote directly to global.mode='manual', manualUrl='${safe}'`,\n );\n stateWritten = true;\n }\n } catch (err) {\n this.log.warn(`Legacy URL migration failed at fallback: ${String(err)}`);\n }\n }\n\n if (!stateWritten) {\n // Both paths failed \u2014 keep native values as a recovery anchor for\n // the user. Don't clean up.\n this.log.warn('Legacy URL preserved in native \u2014 neither global.visUrl nor global.mode write succeeded');\n return;\n }\n\n try {\n const id = `system.adapter.${this.namespace}`;\n const obj = await this.getForeignObjectAsync(id);\n if (obj?.native) {\n delete obj.native.defaultVisUrl;\n delete obj.native.visUrl;\n await this.setForeignObjectAsync(id, obj);\n }\n } catch (err) {\n this.log.warn(`Legacy config cleanup failed: ${String(err)}`);\n }\n }\n\n /**\n * 1.x \u2192 1.2.0 migration \u2014 move legacy per-client `visUrl`-states to the\n * `mode`/`manualUrl` model, plus the global `visUrl` to `global.mode` +\n * `global.manualUrl`. Old datapoints are removed, type of mode-states\n * upgraded to 'mixed'. Idempotent \u2014 does nothing on subsequent starts.\n */\n private async migrateVisUrlToMode(): Promise<void> {\n // 1) Global visUrl \u2192 mode + manualUrl\n try {\n const legacyGlobal = await this.getStateAsync('global.visUrl');\n if (\n legacyGlobal &&\n legacyGlobal.val !== undefined &&\n legacyGlobal.val !== null &&\n legacyGlobal.val !== ''\n ) {\n const safe = coerceSafeUrl(legacyGlobal.val);\n if (safe) {\n await this.globalConfig!.migrationSet(MODE_MANUAL, safe);\n this.log.info(`Migration: global.visUrl \u2192 mode='manual', manualUrl='${safe}'`);\n } else {\n await this.globalConfig!.migrationSet(MODE_MANUAL, null);\n this.log.warn(`Migration: legacy global.visUrl rejected as unsafe \u2014 set global.manualUrl manually`);\n }\n }\n } catch {\n /* state didn't exist \u2014 fresh install or already migrated */\n }\n try {\n await this.delObjectAsync('global.visUrl');\n } catch {\n /* didn't exist */\n }\n\n // 2) Per-client visUrl \u2192 mode='manual' + manualUrl\n const records = this.registry?.listAll() ?? [];\n for (const record of records) {\n try {\n const legacy = await this.getStateAsync(`clients.${record.id}.visUrl`);\n if (legacy && legacy.val !== undefined && legacy.val !== null && legacy.val !== '') {\n const safe = coerceSafeUrl(legacy.val);\n if (safe) {\n record.mode = MODE_MANUAL;\n record.manualUrl = safe;\n await this.setStateAsync(`clients.${record.id}.mode`, { val: MODE_MANUAL, ack: true });\n await this.setStateAsync(`clients.${record.id}.manualUrl`, { val: safe, ack: true });\n this.log.info(\n `Migration: client ${record.id} visUrl='${safe}' \u2192 mode='manual', manualUrl='${safe}'`,\n );\n } else {\n this.log.warn(\n `Migration: client ${record.id} legacy visUrl rejected as unsafe \u2014 set clients.${record.id}.manualUrl manually`,\n );\n }\n }\n } catch {\n /* state didn't exist for this client */\n }\n try {\n await this.delObjectAsync(`clients.${record.id}.visUrl`);\n } catch {\n /* didn't exist */\n }\n }\n\n // 3) global.mode + global.manualUrl repair handled by repairGlobalSchemas()\n // (called separately in onReady so it ALSO runs for users upgrading from\n // v1.2.0/v1.3.0/v1.3.1 where the legacy visUrl is already gone but the\n // partial-formed mode-object from the v1.2.0 extendObject-bug persists).\n }\n\n /**\n * Repairs partial-formed `global.mode` / `global.manualUrl` objects from\n * the v1.2.0 migration bug (extendObjectAsync was called with only\n * `common.type:'mixed'` \u2014 leaving the object without top-level `type`,\n * name, role, read, write, def). `extendObjectAsync` here merges the full\n * instanceObjects schema onto the existing partial object so js-controller\n * stops warning \"obj.type has to exist\" and the dropdown renders correctly.\n *\n * Idempotent \u2014 extending an already-complete object is a no-op write.\n */\n private async repairGlobalSchemas(): Promise<void> {\n try {\n await this.extendObjectAsync('global.mode', {\n type: 'state',\n common: {\n name: 'Global redirect mode',\n type: 'mixed',\n role: 'value',\n read: true,\n write: true,\n def: 0,\n },\n native: {},\n });\n } catch (err) {\n this.log.debug(`repair global.mode failed: ${String(err)}`);\n }\n try {\n await this.extendObjectAsync('global.manualUrl', {\n type: 'state',\n common: {\n name: \"Global manual URL (used when mode='manual')\",\n type: 'string',\n role: 'url',\n read: true,\n write: true,\n def: '',\n },\n native: {},\n });\n } catch (err) {\n this.log.debug(`repair global.manualUrl failed: ${String(err)}`);\n }\n }\n\n /**\n * Removes clients that are clearly stale: no auth token (= never authenticated\n * or revoked) AND `native.lastSeen` older than {@link STALE_CLIENT_TTL_MS}.\n * Clients without `lastSeen` (pre-1.2.0) get the timestamp seeded on this run\n * \u2014 GC kicks in only on subsequent restarts.\n */\n private async gcStaleClients(): Promise<void> {\n const now = Date.now();\n const records = this.registry?.listAll() ?? [];\n let removed = 0;\n for (const record of records) {\n if (record.token) {\n continue;\n }\n try {\n const obj = await this.getObjectAsync(`clients.${record.id}`);\n const native = (obj?.native as { lastSeen?: number } | undefined) ?? {};\n const lastSeen = typeof native.lastSeen === 'number' ? native.lastSeen : 0;\n if (lastSeen === 0) {\n // Pre-v1.2.0 client \u2014 seed timestamp, GC waits one cycle.\n await this.extendObjectAsync(`clients.${record.id}`, { native: { lastSeen: now } });\n continue;\n }\n if (now - lastSeen > STALE_CLIENT_TTL_MS) {\n await this.registry!.remove(record.id);\n removed++;\n }\n } catch (err) {\n this.log.debug(`Stale-GC: failed for ${record.id}: ${String(err)}`);\n }\n }\n if (removed > 0) {\n this.log.info(`Stale-Client-GC: removed ${removed} client(s) (no token + idle >30 days)`);\n }\n }\n\n /**\n * Master-switch action: when `global.enabled` flips, propagate to every\n * client's `mode`. true \u2192 all clients follow `'global'`. false \u2192 fall back\n * to the first discovered URL, or `'manual'` if discovery is empty.\n *\n * @param enabled New value of `global.enabled`.\n */\n private async applyMasterSwitch(enabled: boolean): Promise<void> {\n if (!this.registry) {\n return;\n }\n if (enabled) {\n await this.registry.bulkSetMode(MODE_GLOBAL);\n return;\n }\n const first = this.urlDiscovery?.getFirstDiscoveredUrl();\n if (first) {\n await this.registry.bulkSetMode(first);\n } else {\n await this.registry.bulkSetMode(MODE_MANUAL);\n this.log.warn(\n \"global.enabled=false but no discovered VIS URL \u2014 clients set to 'manual'; \" +\n 'fill clients.<id>.manualUrl per client',\n );\n }\n }\n\n private async onStateChange(id: string, state: ioBroker.State | null | undefined): Promise<void> {\n if (!state || state.ack) {\n return;\n }\n const clientParsed = this.registry ? parseClientStateId(id, this.namespace) : null;\n if (clientParsed) {\n if (clientParsed.kind === 'mode') {\n await this.registry!.handleModeWrite(clientParsed.id, state.val);\n // B4: if the user picked 'global' but global resolves to nothing,\n // give them a one-shot heads-up so the cause of the empty redirect\n // is obvious without digging through the resolver code.\n const record = this.registry!.getById(clientParsed.id);\n if (record?.mode === MODE_GLOBAL && this.globalConfig!.resolveUrlFor(record) === null) {\n this.log.warn(\n `Client ${record.id}: mode='global' but global has no resolvable URL \u2014 ` +\n 'fill global.mode/manualUrl, or pick a different client mode',\n );\n }\n } else if (clientParsed.kind === 'manualUrl') {\n await this.registry!.handleManualUrlWrite(clientParsed.id, state.val);\n } else if (clientParsed.kind === 'remove' && state.val === true) {\n await this.registry!.remove(clientParsed.id);\n }\n return;\n }\n const globalParsed = this.globalConfig ? parseGlobalStateId(id, this.namespace) : null;\n if (globalParsed === 'mode') {\n await this.globalConfig!.handleModeWrite(state.val);\n } else if (globalParsed === 'manualUrl') {\n await this.globalConfig!.handleManualUrlWrite(state.val);\n } else if (globalParsed === 'enabled') {\n await this.globalConfig!.handleEnabledWrite(state.val);\n await this.applyMasterSwitch(this.globalConfig!.isEnabled());\n return;\n }\n\n // info.refresh_urls \u2014 User-Trigger f\u00FCr manuelles Dropdown-Refresh ohne\n // Adapter-Neustart. Re-scan'd den Broker nach VIS/VIS-2-Projekten und\n // Admin-Tiles, schreibt die neuen states-Maps in alle Mode-Dropdowns.\n if (id === `${this.namespace}.info.refresh_urls` && state.val === true) {\n await this.handleRefreshUrlsWrite();\n }\n }\n\n /**\n * Handler for the `info.refresh_urls` button.\n * Triggert eine sofortige `urlDiscovery.collect()` (statt Debounce-Schedule),\n * damit der User nicht 2s warten muss. Schreibt anschlie\u00DFend `false ack` damit\n * der Button in der Admin-UI wieder \u201Eklickbar\" wird.\n */\n private async handleRefreshUrlsWrite(): Promise<void> {\n if (!this.urlDiscovery) {\n return;\n }\n try {\n await this.urlDiscovery.collect();\n this.log.info('URL discovery refreshed on user request');\n } catch (err) {\n this.log.warn(`URL refresh failed: ${err instanceof Error ? err.message : String(err)}`);\n } finally {\n await this.setStateAsync('info.refresh_urls', { val: false, ack: true }).catch(() => {});\n }\n }\n\n private onUnload(callback: () => void): void {\n try {\n this.urlDiscovery?.cancelRefresh();\n this.urlDiscovery = null;\n\n if (this.mdnsService) {\n this.mdnsService.stop();\n this.mdnsService = null;\n }\n\n if (this.webServer) {\n this.webServer.stop().catch((err: Error) => this.log.error(`Server stop error: ${err.message}`));\n this.webServer = null;\n }\n\n this.registry = null;\n this.globalConfig = null;\n\n // Detach process-level last-line-of-defence handlers\n if (this.unhandledRejectionHandler) {\n process.off('unhandledRejection', this.unhandledRejectionHandler);\n this.unhandledRejectionHandler = null;\n }\n if (this.uncaughtExceptionHandler) {\n process.off('uncaughtException', this.uncaughtExceptionHandler);\n this.uncaughtExceptionHandler = null;\n }\n\n void this.setState('info.connection', { val: false, ack: true });\n } catch (error) {\n const err = error as Error;\n this.log.error(`Shutdown error: ${err.message}`);\n } finally {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new HassEmu(options);\n} else {\n (() => new HassEmu())();\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["crypto"]
7
7
  }
package/io-package.json CHANGED
@@ -1,8 +1,47 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "hassemu",
4
- "version": "1.3.3",
4
+ "version": "1.5.0",
5
5
  "news": {
6
+ "1.5.0": {
7
+ "en": "Security: `/health` no longer leaks the `mdns`/`auth` config flags unauthenticated (reconnaissance risk on internet-exposed instances). `requires_api_password` (mDNS + /api/discovery_info) now mirrors the actual `authRequired` setting. Brute-force lockout map is FIFO-capped and prunes stale failure counts.",
8
+ "de": "Sicherheit: `/health` leakt die `mdns`/`auth`-Config-Flags nicht mehr unauthenticated (Reconnaissance-Risiko bei netzexponierten Instanzen). `requires_api_password` (mDNS + /api/discovery_info) spiegelt jetzt das tatsächliche `authRequired`-Setting. Brute-Force-Lockout-Map ist FIFO-gedeckelt und prunt veraltete Fail-Counts.",
9
+ "ru": "Безопасность: `/health` больше не утекает флаги `mdns`/`auth` без авторизации. `requires_api_password` теперь отражает фактический `authRequired`. Brute-force lockout map FIFO-ограничен и удаляет устаревшие записи.",
10
+ "pt": "Seguranca: `/health` nao expoe mais flags `mdns`/`auth` sem auth. `requires_api_password` agora espelha `authRequired` real. Brute-force lockout map e FIFO-capped e remove registros obsoletos.",
11
+ "nl": "Beveiliging: `/health` lekt de `mdns`/`auth` config flags niet meer unauthenticated. `requires_api_password` weerspiegelt nu de echte `authRequired`. Brute-force lockout map is FIFO-capped en pruned stale entries.",
12
+ "fr": "Securite : `/health` ne fuite plus les flags `mdns`/`auth` non authentifies. `requires_api_password` reflete maintenant le vrai `authRequired`. La map de lockout brute-force est FIFO-cappee et purge les entrees obsoletes.",
13
+ "it": "Sicurezza: `/health` non rivela piu i flag `mdns`/`auth` senza auth. `requires_api_password` ora rispecchia il vero `authRequired`. Mappa lockout brute-force FIFO-cappata e prune entry obsolete.",
14
+ "es": "Seguridad: `/health` ya no expone los flags `mdns`/`auth` sin auth. `requires_api_password` ahora refleja el `authRequired` real. Mapa de lockout brute-force FIFO-capped y limpia entradas obsoletas.",
15
+ "pl": "Bezpieczenstwo: `/health` nie ujawnia juz flag `mdns`/`auth` bez auth. `requires_api_password` teraz odzwierciedla prawdziwe `authRequired`. Mapa brute-force lockout FIFO-cappowana, czysci nieaktualne wpisy.",
16
+ "uk": "Безпека: `/health` більше не показує `mdns`/`auth` без авторизації. `requires_api_password` тепер відповідає реальному `authRequired`. Brute-force lockout map FIFO-обмежена і видаляє застарілі записи.",
17
+ "zh-cn": "安全:`/health` 不再向未认证用户泄漏 `mdns`/`auth` 配置标志。`requires_api_password` 现在反映实际的 `authRequired`。Brute-force lockout map 已 FIFO 限制并清理过期条目。"
18
+ },
19
+ "1.4.1": {
20
+ "en": "CI fix: deploy step now uses Node 24 (Node 22 + npm@latest had a MODULE_NOT_FOUND bug, blocked v1.4.0 from reaching npm). v1.4.0 changes are bundled into this release.",
21
+ "de": "CI-Fix: Deploy-Schritt nutzt jetzt Node 24 (Node 22 + npm@latest hatte einen MODULE_NOT_FOUND-Bug, hinderte v1.4.0 daran, auf npm zu kommen). v1.4.0-Änderungen sind in diesem Release gebündelt.",
22
+ "ru": "CI fix: deploy на Node 24 (Node 22 + npm@latest был MODULE_NOT_FOUND bug, v1.4.0 не дошёл до npm). v1.4.0 изменения включены в этот релиз.",
23
+ "pt": "CI fix: deploy agora usa Node 24 (Node 22 + npm@latest tinha bug MODULE_NOT_FOUND, v1.4.0 nao chegou ao npm). Mudancas de v1.4.0 incluidas neste release.",
24
+ "nl": "CI fix: deploy stap gebruikt nu Node 24 (Node 22 + npm@latest had MODULE_NOT_FOUND bug, v1.4.0 bereikte npm niet). v1.4.0 wijzigingen zijn in deze release.",
25
+ "fr": "CI fix : etape deploy utilise Node 24 (Node 22 + npm@latest avait un bug MODULE_NOT_FOUND, v1.4.0 n'a pas atteint npm). Modifications v1.4.0 incluses dans cette release.",
26
+ "it": "CI fix: deploy ora usa Node 24 (Node 22 + npm@latest aveva bug MODULE_NOT_FOUND, v1.4.0 non e arrivato a npm). Modifiche v1.4.0 incluse in questa release.",
27
+ "es": "CI fix: paso deploy ahora usa Node 24 (Node 22 + npm@latest tenia bug MODULE_NOT_FOUND, v1.4.0 no llego a npm). Cambios de v1.4.0 incluidos en este release.",
28
+ "pl": "CI fix: deploy uzywa teraz Node 24 (Node 22 + npm@latest mial bug MODULE_NOT_FOUND, v1.4.0 nie dotarl do npm). Zmiany v1.4.0 wlaczone w tym release.",
29
+ "uk": "CI fix: крок deploy тепер використовує Node 24 (Node 22 + npm@latest мав bug MODULE_NOT_FOUND, v1.4.0 не дійшов до npm). Зміни v1.4.0 включені в цей реліз.",
30
+ "zh-cn": "CI 修复:deploy 步骤现使用 Node 24 (Node 22 + npm@latest 有 MODULE_NOT_FOUND bug,v1.4.0 未到达 npm)。v1.4.0 变更包含在此发布。"
31
+ },
32
+ "1.4.0": {
33
+ "en": "New `info.refresh_urls` button reloads the VIS/Admin URL dropdown without an adapter restart. Plus: `/auth/token` now accepts form-urlencoded bodies (OAuth2 spec), mDNS no longer leaks sockets on start failure, and legacy URL migration drops unsafe values cleanly.",
34
+ "de": "Neuer Button `info.refresh_urls` lädt das VIS-/Admin-URL-Dropdown ohne Adapter-Neustart neu. Plus: `/auth/token` akzeptiert form-urlencoded Bodies (OAuth2-Spec), mDNS leakt keine Sockets mehr bei Startfehler, Legacy-URL-Migration verwirft unsichere Werte sauber.",
35
+ "ru": "Новая кнопка `info.refresh_urls` перезагружает VIS/Admin URL-dropdown без перезапуска адаптера. Plus: `/auth/token` принимает form-urlencoded body (OAuth2), mDNS не утекает сокеты при ошибке старта, legacy URL-migration корректно отклоняет небезопасные значения.",
36
+ "pt": "Novo botao `info.refresh_urls` recarrega o dropdown VIS/Admin sem reiniciar o adapter. Plus: `/auth/token` aceita form-urlencoded (OAuth2 spec), mDNS nao vaza sockets em falha de start, migration de URL legada descarta valores unsafe.",
37
+ "nl": "Nieuwe `info.refresh_urls` knop herlaadt VIS/Admin URL-dropdown zonder adapter-restart. Plus: `/auth/token` accepteert form-urlencoded body (OAuth2 spec), mDNS lekt geen sockets meer bij startfout, legacy URL-migratie verwerpt unsafe waarden netjes.",
38
+ "fr": "Nouveau bouton `info.refresh_urls` recharge le dropdown VIS/Admin sans redemarrage de l'adaptateur. Plus : `/auth/token` accepte form-urlencoded (OAuth2 spec), mDNS ne laisse plus fuir les sockets en cas d'echec de demarrage, migration legacy URL rejette proprement les valeurs unsafe.",
39
+ "it": "Nuovo pulsante `info.refresh_urls` ricarica il dropdown VIS/Admin senza riavvio dell'adapter. Plus: `/auth/token` accetta body form-urlencoded (OAuth2 spec), mDNS non fa piu leak di socket in caso di errore di start, migration URL legacy scarta valori unsafe in modo pulito.",
40
+ "es": "Nuevo boton `info.refresh_urls` recarga el dropdown VIS/Admin sin reiniciar el adaptador. Plus: `/auth/token` acepta form-urlencoded body (OAuth2 spec), mDNS ya no fuga sockets en fallo de start, migration URL legacy descarta valores unsafe limpiamente.",
41
+ "pl": "Nowy przycisk `info.refresh_urls` przeladowuje dropdown VIS/Admin URL bez restartu adaptera. Plus: `/auth/token` akceptuje form-urlencoded body (OAuth2 spec), mDNS nie wycieka socketow przy bledzie startu, migration URL legacy odrzuca unsafe wartosci czysto.",
42
+ "uk": "Нова кнопка `info.refresh_urls` перезавантажує VIS/Admin URL-dropdown без перезапуску адаптера. Plus: `/auth/token` приймає form-urlencoded body (OAuth2 spec), mDNS більше не витікає сокети при помилці старту, legacy URL-migration коректно відхиляє небезпечні значення.",
43
+ "zh-cn": "新增 `info.refresh_urls` 按钮:无需重启即可刷新 VIS/Admin URL 下拉。另:`/auth/token` 现接受 form-urlencoded 请求体 (OAuth2 规范),mDNS 启动失败不再泄漏 socket,legacy URL 迁移正确丢弃不安全值。"
44
+ },
6
45
  "1.3.3": {
7
46
  "en": "Documentation: rewrote release notes in user-friendly style across all languages.",
8
47
  "de": "Dokumentation: Release-Notes in alle Sprachen User-orientiert neu geschrieben.",
@@ -54,45 +93,6 @@
54
93
  "pl": "Bezpieczeństwo: blokada brute-force przy logowaniu (5 nieudanych prób → IP zablokowane na 15 min). Wersja emulowanego Home Assistant podniesiona do 2026.4.0.",
55
94
  "uk": "Безпека: блокування brute-force при вході (5 невдалих спроб → IP заблоковано на 15 хв). Версія емульованого Home Assistant оновлена до 2026.4.0.",
56
95
  "zh-cn": "安全:登录暴力破解防护(5 次失败 → IP 阻断 15 分钟)。模拟的 Home Assistant 版本提升至 2026.4.0。"
57
- },
58
- "1.2.0": {
59
- "en": "Breaking: redirect target now configured via mode dropdown + manualUrl free text instead of the old visUrl. Existing setups auto-migrated.",
60
- "de": "Breaking: Weiterleitungs-Ziel jetzt über mode-Dropdown + manualUrl-Freitext konfiguriert statt altem visUrl. Bestehende Setups werden automatisch migriert.",
61
- "ru": "Breaking: цель перенаправления теперь настраивается через dropdown mode + свободный текст manualUrl вместо старого visUrl. Существующие настройки мигрируют автоматически.",
62
- "pt": "Breaking: alvo de redirecionamento agora configurado via dropdown mode + texto livre manualUrl em vez do antigo visUrl. Configurações existentes são migradas automaticamente.",
63
- "nl": "Breaking: redirect-doel nu via mode-dropdown + manualUrl-vrije tekst geconfigureerd in plaats van het oude visUrl. Bestaande setups worden automatisch gemigreerd.",
64
- "fr": "Breaking : la cible de redirection se configure désormais via le dropdown mode + le texte libre manualUrl au lieu de l'ancien visUrl. Les configurations existantes sont migrées automatiquement.",
65
- "it": "Breaking: obiettivo di redirect ora configurato tramite dropdown mode + testo libero manualUrl invece del vecchio visUrl. Le configurazioni esistenti vengono migrate automaticamente.",
66
- "es": "Breaking: el destino de redirección ahora se configura mediante el dropdown mode + el texto libre manualUrl en lugar del antiguo visUrl. Las configuraciones existentes se migran automáticamente.",
67
- "pl": "Breaking: cel przekierowania jest teraz konfigurowany przez dropdown mode + wolny tekst manualUrl zamiast starego visUrl. Istniejące konfiguracje są migrowane automatycznie.",
68
- "uk": "Breaking: ціль перенаправлення тепер налаштовується через dropdown mode + вільний текст manualUrl замість старого visUrl. Існуючі налаштування мігрують автоматично.",
69
- "zh-cn": "Breaking:重定向目标现在通过 mode 下拉框 + manualUrl 自由文本配置,取代旧的 visUrl。现有配置会自动迁移。"
70
- },
71
- "1.1.6": {
72
- "en": "Internal cleanup. No user-facing changes.",
73
- "de": "Interne Bereinigung. Keine User-sichtbaren Änderungen.",
74
- "ru": "Внутренняя очистка. Без видимых изменений для пользователя.",
75
- "pt": "Limpeza interna. Sem alterações visíveis para o usuário.",
76
- "nl": "Interne opschoning. Geen wijzigingen voor de gebruiker.",
77
- "fr": "Nettoyage interne. Aucune modification visible pour l'utilisateur.",
78
- "it": "Pulizia interna. Nessuna modifica visibile all'utente.",
79
- "es": "Limpieza interna. Sin cambios visibles para el usuario.",
80
- "pl": "Wewnętrzne porządki. Brak zmian widocznych dla użytkownika.",
81
- "uk": "Внутрішнє прибирання. Без помітних для користувача змін.",
82
- "zh-cn": "内部清理。对用户无可见变化。"
83
- },
84
- "1.1.5": {
85
- "en": "Crash defense: process-level error handlers. Min js-controller restored to >=6.0.11.",
86
- "de": "Crash-Schutz: process-level Error-Handler. Min js-controller zurück auf >=6.0.11.",
87
- "ru": "Защита от сбоев: process-level обработчики ошибок. Мин. js-controller восстановлен на >=6.0.11.",
88
- "pt": "Proteção contra falhas: handlers de erro process-level. Mín. js-controller restaurado para >=6.0.11.",
89
- "nl": "Crashbescherming: process-level error-handlers. Min. js-controller hersteld op >=6.0.11.",
90
- "fr": "Défense contre les crashs : handlers d'erreur process-level. Min js-controller rétabli à >=6.0.11.",
91
- "it": "Difesa anti-crash: handler di errore process-level. Min js-controller ripristinato a >=6.0.11.",
92
- "es": "Defensa contra fallos: handlers de error process-level. Mín. js-controller restaurado a >=6.0.11.",
93
- "pl": "Ochrona przed crashem: handlery błędów process-level. Min. js-controller przywrócony do >=6.0.11.",
94
- "uk": "Захист від збоїв: process-level обробники помилок. Мін. js-controller повернено на >=6.0.11.",
95
- "zh-cn": "崩溃防护:process-level 错误处理器。最低 js-controller 恢复为 >=6.0.11。"
96
96
  }
97
97
  },
98
98
  "titleLang": {
@@ -235,6 +235,20 @@
235
235
  },
236
236
  "native": {}
237
237
  },
238
+ {
239
+ "_id": "info.refresh_urls",
240
+ "type": "state",
241
+ "common": {
242
+ "name": "Refresh URL discovery",
243
+ "desc": "Write true to re-scan the broker for VIS/VIS-2 projects, Admin tiles and other discovered URLs. Useful after creating a new VIS view without restarting the adapter.",
244
+ "type": "boolean",
245
+ "role": "button",
246
+ "read": true,
247
+ "write": true,
248
+ "def": false
249
+ },
250
+ "native": {}
251
+ },
238
252
  {
239
253
  "_id": "global.mode",
240
254
  "type": "state",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.hassemu",
3
- "version": "1.3.3",
3
+ "version": "1.5.0",
4
4
  "description": "Emulates a minimal Home Assistant server so devices expecting a Home Assistant dashboard can display any custom web URL.",
5
5
  "author": {
6
6
  "name": "krobi",
@@ -31,11 +31,12 @@
31
31
  "url": "https://github.com/krobipd/ioBroker.hassemu/issues"
32
32
  },
33
33
  "engines": {
34
- "node": ">=20",
34
+ "node": ">=22",
35
35
  "npm": ">=10"
36
36
  },
37
37
  "dependencies": {
38
38
  "@fastify/cookie": "^11.0.2",
39
+ "@fastify/formbody": "^8.0.2",
39
40
  "@iobroker/adapter-core": "^3.3.2",
40
41
  "bonjour-service": "^1.3.0",
41
42
  "fastify": "^5.8.5"
@@ -49,8 +50,8 @@
49
50
  "@iobroker/testing": "^5.2.2",
50
51
  "@tsconfig/node20": "^20.1.9",
51
52
  "@types/iobroker": "npm:@iobroker/types@^7.1.1",
52
- "@types/node": "^20.19.24",
53
- "nyc": "^17.1.0",
53
+ "@types/node": "^22.0.0",
54
+ "nyc": "^18.0.0",
54
55
  "rimraf": "^6.1.3",
55
56
  "source-map-support": "^0.5.21",
56
57
  "ts-node": "^10.9.2",