homematic-manager 3.0.0-beta.17 → 3.0.0-beta.19

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.
Files changed (35) hide show
  1. package/README.md +1 -1
  2. package/dist/occulite.d.ts +14 -3
  3. package/dist/occulite.js +35 -4
  4. package/dist/server.js +31 -10
  5. package/node_modules/@homematic-manager/backend/dist/api/backend.d.ts +6 -0
  6. package/node_modules/@homematic-manager/backend/dist/api/backend.js +65 -24
  7. package/node_modules/@homematic-manager/backend/dist/index.d.ts +2 -1
  8. package/node_modules/@homematic-manager/backend/dist/index.js +2 -1
  9. package/node_modules/@homematic-manager/backend/dist/interfaces/manager.d.ts +23 -3
  10. package/node_modules/@homematic-manager/backend/dist/interfaces/manager.js +129 -13
  11. package/node_modules/@homematic-manager/backend/dist/meta/service.js +23 -1
  12. package/node_modules/@homematic-manager/backend/dist/rpc/client.d.ts +18 -5
  13. package/node_modules/@homematic-manager/backend/dist/rpc/client.js +11 -5
  14. package/node_modules/@homematic-manager/backend/dist/rpc/log.d.ts +86 -0
  15. package/node_modules/@homematic-manager/backend/dist/rpc/log.js +241 -0
  16. package/node_modules/@homematic-manager/backend/dist/rpc/origin.d.ts +30 -0
  17. package/node_modules/@homematic-manager/backend/dist/rpc/origin.js +35 -0
  18. package/node_modules/@homematic-manager/backend/dist/util/net.d.ts +67 -0
  19. package/node_modules/@homematic-manager/backend/dist/util/net.js +201 -0
  20. package/node_modules/@homematic-manager/backend/package.json +1 -1
  21. package/node_modules/@homematic-manager/core/dist/api/types.d.ts +91 -5
  22. package/node_modules/@homematic-manager/core/dist/api/types.js +2 -1
  23. package/node_modules/@homematic-manager/core/dist/i18n/messages.js +45 -0
  24. package/node_modules/@homematic-manager/core/dist/rssi/index.d.ts +31 -14
  25. package/node_modules/@homematic-manager/core/dist/rssi/index.js +28 -30
  26. package/node_modules/@homematic-manager/core/package.json +1 -1
  27. package/package.json +3 -3
  28. package/ui/assets/index-BQpPoLG-.css +1 -0
  29. package/ui/assets/index-D-hDPi8h.js +16 -0
  30. package/ui/favicon.ico +0 -0
  31. package/ui/index.html +3 -2
  32. package/node_modules/@homematic-manager/backend/dist/write/log.d.ts +0 -51
  33. package/node_modules/@homematic-manager/backend/dist/write/log.js +0 -151
  34. package/ui/assets/index-BqOnC2jh.js +0 -14
  35. package/ui/assets/index-DHvC2NX0.css +0 -1
package/README.md CHANGED
@@ -63,7 +63,7 @@ hard-coding it.
63
63
  | `--issue-cookie` | loopback binds only | hand the token to the browser on the page load |
64
64
  | `-a, --ccu` | - | CCU address; written to the configuration when it differs |
65
65
  | `--local` | - | we run on the CCU itself: talk to the interface processes directly |
66
- | `--callback-ip` | auto | address the interfaces call back to; needed where this host cannot see it (Docker) |
66
+ | `--callback-ip` | auto | address the interfaces call back to; needed where this host cannot see it (Docker). Auto is this host's address in the CCU's network, else the one on the route to the CCU |
67
67
  | `--callback-xmlrpc-port`, `--callback-binrpc-port` | free ports | fixed callback ports, so a container can publish them |
68
68
  | `--callback-xmlrpc-default-port`, `--callback-binrpc-default-port` | - | callback ports while the configured ones are 0, a free port when one is taken (the CCU addon: 2031, 2032; never written to the configuration) |
69
69
  | `--demo` | off | serve the UI on its demo fixture and start no backend |
@@ -8,8 +8,9 @@
8
8
  *
9
9
  * On openccu-lite nobody logs in *here*. The box's shell has already done it, lighttpd's gate has
10
10
  * already refused everyone who has no session (their D-28), and the shell opens an addon page with
11
- * the session on the URL: `?sid=@xxxxxxxxxx@`, the CCU convention. That session id **is** a valid
12
- * credential for the box's own APIs, so the check is one request:
11
+ * the session on the URL: `?sid=@xxxxxxxxxx@`, the CCU convention. On an image from before their
12
+ * task 125 that session id **is** a valid credential for the box's own APIs, so the check is one
13
+ * request (on a newer image the shell sends the gate's `X-Occulite-Session` instead, see below):
13
14
  *
14
15
  * ```
15
16
  * GET /api/meta/v1/enums Authorization: Bearer <sid> -> 200 valid, 401 not
@@ -58,8 +59,18 @@ export interface OcculiteCheckOptions {
58
59
  */
59
60
  readonly requireState?: boolean;
60
61
  }
61
- /** A session id as the shell hands it over, `@…@` and all, or `undefined` when it is not one. */
62
+ /** A session id as the shell or the gate hands it over, `@…@` and all, or `undefined` when it is not one. */
62
63
  export declare function parseSid(value: string | null | undefined): string | undefined;
64
+ /**
65
+ * B-36: can the `?sid=` of a request be a session of the box whose gate named `gate`?
66
+ *
67
+ * A gate header of the 26-character shape says the box issues session ids of that shape (task 125),
68
+ * so a `?sid=` of any other shape there is the session's legacy alias, which the box's API refuses:
69
+ * the answer is known without asking. Without such a header - a CCU, an image from before the header,
70
+ * a ten-character header - nothing can be concluded, and the box decides as before. This only ever
71
+ * *refuses*: a `true` still has to be confirmed by the box.
72
+ */
73
+ export declare function sidCanBeSession(sid: string, gate: string | undefined): boolean;
63
74
  /** Checks a session id against the box that issued it. */
64
75
  export declare class OcculiteAuthenticator {
65
76
  #private;
package/dist/occulite.js CHANGED
@@ -8,8 +8,9 @@
8
8
  *
9
9
  * On openccu-lite nobody logs in *here*. The box's shell has already done it, lighttpd's gate has
10
10
  * already refused everyone who has no session (their D-28), and the shell opens an addon page with
11
- * the session on the URL: `?sid=@xxxxxxxxxx@`, the CCU convention. That session id **is** a valid
12
- * credential for the box's own APIs, so the check is one request:
11
+ * the session on the URL: `?sid=@xxxxxxxxxx@`, the CCU convention. On an image from before their
12
+ * task 125 that session id **is** a valid credential for the box's own APIs, so the check is one
13
+ * request (on a newer image the shell sends the gate's `X-Occulite-Session` instead, see below):
13
14
  *
14
15
  * ```
15
16
  * GET /api/meta/v1/enums Authorization: Bearer <sid> -> 200 valid, 401 not
@@ -32,10 +33,40 @@ export const OCCULITE_TIMEOUT_MS = 5000;
32
33
  * box - so the header is only a claim, and the box is asked about it like about any other session.
33
34
  */
34
35
  export const OCCULITE_SESSION_HEADER = 'x-occulite-session';
35
- /** A session id as the shell hands it over, `@…@` and all, or `undefined` when it is not one. */
36
+ /**
37
+ * B-36: what an openccu-lite session id looks like, and nothing else is one.
38
+ *
39
+ * - **Since openccu-lite task 125** (occulited `1f71597`, their D-77) a session id is what Go's
40
+ * `crypto/rand.Text` hands out: 26 characters of base32, `A-Z` and `2-7`. The box's API and its gate
41
+ * match exactly this (`sidRe`, the gate's `SID_PATTERN`).
42
+ * - **Before task 125** it was the CCU's shape, ten of `[0-9a-zA-Z]` (auth-off's fixed id too). Since
43
+ * then that shape is only a session's *legacy alias*, which the addon CGIs parse and the box's API
44
+ * refuses as a credential from anywhere. The two cannot be told apart by their characters: a
45
+ * ten-character value is a session on an older box and never one on a newer box.
46
+ * - **An API token** (`olt_` and 32 hex digits) is neither and is never a session: the header path
47
+ * asks `/api/auth/v1/state`, which also answers for a token.
48
+ *
49
+ * A value of another shape or length cannot be an openccu-lite session on any image, so it is refused
50
+ * here, without asking the box.
51
+ */
52
+ const SESSION_ID = /^[A-Z2-7]{26}$/;
53
+ const LEGACY_ID = /^[0-9a-zA-Z]{10}$/;
54
+ /** A session id as the shell or the gate hands it over, `@…@` and all, or `undefined` when it is not one. */
36
55
  export function parseSid(value) {
37
56
  const bare = (value ?? '').replace(/^@|@$/g, '').trim();
38
- return /^[0-9a-zA-Z]{6,64}$/.test(bare) ? bare : undefined;
57
+ return SESSION_ID.test(bare) || LEGACY_ID.test(bare) ? bare : undefined;
58
+ }
59
+ /**
60
+ * B-36: can the `?sid=` of a request be a session of the box whose gate named `gate`?
61
+ *
62
+ * A gate header of the 26-character shape says the box issues session ids of that shape (task 125),
63
+ * so a `?sid=` of any other shape there is the session's legacy alias, which the box's API refuses:
64
+ * the answer is known without asking. Without such a header - a CCU, an image from before the header,
65
+ * a ten-character header - nothing can be concluded, and the box decides as before. This only ever
66
+ * *refuses*: a `true` still has to be confirmed by the box.
67
+ */
68
+ export function sidCanBeSession(sid, gate) {
69
+ return gate === undefined || !SESSION_ID.test(gate) || SESSION_ID.test(sid);
39
70
  }
40
71
  /** Checks a session id against the box that issued it. */
41
72
  export class OcculiteAuthenticator {
package/dist/server.js CHANGED
@@ -29,7 +29,7 @@ import { applyCookieToken, applySessionToken, clearedSessionCookie, createToken,
29
29
  import { DeviceImageService, readIconMapFile } from './images.js';
30
30
  import { clientAddress, parseLoginForm, pickLanguage, readBody, renderLoginPage, } from './login.js';
31
31
  import { createLogger, silentLogger } from './log.js';
32
- import { OCCULITE_SESSION_HEADER, OcculiteAuthenticator, parseSid } from './occulite.js';
32
+ import { OCCULITE_SESSION_HEADER, OcculiteAuthenticator, parseSid, sidCanBeSession, } from './occulite.js';
33
33
  import { defaultDataDir, defaultMetadataDir, defaultUiDir, packageVersion } from './paths.js';
34
34
  import { proxyRequest, proxyUpgrade } from './proxy.js';
35
35
  import { RateLimiter, SessionStore } from './sessions.js';
@@ -184,8 +184,27 @@ export async function createWebHost(options = {}) {
184
184
  // session is checked against the box, turned into one of ours, and taken off the URL again
185
185
  // so that it does not sit in the address bar, in a bookmark and in every referrer.
186
186
  if (boxSessions && sessions && (method === 'GET' || method === 'HEAD')) {
187
- const offered = url.searchParams.get('sid');
188
- if (parseSid(offered) !== undefined) {
187
+ const offered = parseSid(url.searchParams.get('sid'));
188
+ if (offered !== undefined) {
189
+ // B-36: the gate header first. Since openccu-lite task 125 the `?sid=` a shell puts on
190
+ // the URL is the session's legacy alias, which the box's API refuses, while the same
191
+ // request carries the session the gate validated: a confirmed header signs the request
192
+ // in, whatever `?sid=` says, and the `?sid=` only comes off the URL.
193
+ const gate = gateSid(request);
194
+ if (gate !== undefined) {
195
+ const session = await sessionFromGate(request, sessions.get(readCookie(request.headers.cookie, SESSION_COOKIE)));
196
+ if (session?.credential === gate) {
197
+ redirectWithoutSid(request, response, session, url, rest);
198
+ return;
199
+ }
200
+ }
201
+ if (!sidCanBeSession(offered, gate)) {
202
+ // what the box would answer, without asking it: see sidCanBeSession
203
+ log.warn(`login: the session offered from ${clientAddress(request)} cannot be one of this openccu-lite's (not asked)`);
204
+ response.writeHead(302, { Location: '/', 'Cache-Control': 'no-store' });
205
+ response.end();
206
+ return;
207
+ }
189
208
  await handleHandover(request, response, offered, url, rest);
190
209
  return;
191
210
  }
@@ -356,17 +375,19 @@ export async function createWebHost(options = {}) {
356
375
  response.end();
357
376
  return;
358
377
  }
359
- const store = sessions;
360
- const session = store.create(checked.name, checked.level, checked.sid);
378
+ const session = sessions.create(checked.name, checked.level, checked.sid);
361
379
  backend?.noteMetaSession(checked.sid);
362
380
  log.info(`login: ${checked.name} (level ${String(checked.level)}) through the openccu-lite shell`);
363
- // the same page, without the session in the URL
381
+ redirectWithoutSid(request, response, session, url, rest);
382
+ }
383
+ /** The same page without the session in the URL, with the cookie of the session it was let in by. */
384
+ function redirectWithoutSid(request, response, session, url, rest) {
364
385
  const query = new URLSearchParams(url.searchParams);
365
386
  query.delete('sid');
366
387
  const search = query.size === 0 ? '' : `?${query.toString()}`;
367
388
  response.writeHead(302, {
368
389
  Location: `${base}${rest}${search}`,
369
- 'Set-Cookie': sessionCookie(session.id, base, store.ttlMs / 1000, isHttps(request)),
390
+ 'Set-Cookie': sessionCookie(session.id, base, sessions.ttlMs / 1000, isHttps(request)),
370
391
  'Cache-Control': 'no-store',
371
392
  });
372
393
  response.end();
@@ -403,9 +424,9 @@ export async function createWebHost(options = {}) {
403
424
  /**
404
425
  * B-94, D-65: the session of a request that came through openccu-lite's gate.
405
426
  *
406
- * The `?sid=` hand-over and the cookie stay as they are; this only adds a third way in, for a
407
- * request that has neither - a bookmark, a reload after our session expired - or whose cookie
408
- * belongs to another box session than the one the gate found. The header is a claim until the box
427
+ * The cookie stays as it is; this adds a third way in, for a request that has none - a bookmark,
428
+ * a reload after our session expired - or whose cookie belongs to another box session than the
429
+ * one the gate found. A request with `?sid=` asks this first too (B-36). The header is a claim until the box
409
430
  * confirms it (`GET /api/auth/v1/state`): a CCU and an older openccu-lite image pass a client's
410
431
  * header through, and the backend's port is open to every process on the box.
411
432
  */
@@ -20,6 +20,7 @@ import { InterfaceManager, type InterfaceManagerOptions } from '../interfaces/ma
20
20
  import { type MetaServiceOptions } from '../meta/service.js';
21
21
  import { RegaService, type RegaServiceOptions } from '../rega/client.js';
22
22
  import { ApiEventEmitter } from '../util/emitter.js';
23
+ import { type CallbackNetwork } from '../util/net.js';
23
24
  /** How often the BidCos service messages are polled while the connection is up. */
24
25
  export declare const SERVICE_MESSAGE_POLL_MS = 300000;
25
26
  /** How long the HmIP `getParamset(:0, VALUES)` sweep waits for the device list to settle. */
@@ -45,6 +46,11 @@ export interface BackendOptions extends Omit<ConfigStoreOptions, 'version'> {
45
46
  * popup to say that the callback ports must be published unchanged.
46
47
  */
47
48
  readonly inContainer?: boolean;
49
+ /**
50
+ * B-53: the interfaces, name lookup and route probe the automatic callback address comes from.
51
+ * Absent, the machine's own - or, where `localAddresses` is injected, that list alone.
52
+ */
53
+ readonly network?: CallbackNetwork;
48
54
  readonly rpcTimeoutMs?: number;
49
55
  readonly watchdogIntervalMs?: number;
50
56
  readonly serviceMessagePollMs?: number;
@@ -28,8 +28,9 @@ import { MetaService } from '../meta/service.js';
28
28
  import { RegaService } from '../rega/client.js';
29
29
  import { listDevicesAnswer } from '../rpc/server.js';
30
30
  import { ApiEventEmitter } from '../util/emitter.js';
31
- import { WriteLog } from '../write/log.js';
32
- import { isWriteMethod } from '../write/log.js';
31
+ import { describeCallbackAddresses, staticNetwork, systemNetwork } from '../util/net.js';
32
+ import { RpcLog, isWriteMethod } from '../rpc/log.js';
33
+ import { currentOrigin, runWithOrigin } from '../rpc/origin.js';
33
34
  import { ParamsetWriter } from '../write/paramset.js';
34
35
  import { ConfigRepair } from '../write/repair.js';
35
36
  import { WriteQueue } from '../write/queue.js';
@@ -56,7 +57,7 @@ export class Backend {
56
57
  #options;
57
58
  #config;
58
59
  #queue;
59
- #writeLog;
60
+ #rpcLog;
60
61
  #unknownMethodsSeen = new Set();
61
62
  #writer;
62
63
  #repair;
@@ -125,14 +126,14 @@ export class Backend {
125
126
  this.#queue = new WriteQueue({
126
127
  paceFor: (interfaceName) => writePaceFor(interfaceName, this.#config.connection.writePaceMs),
127
128
  });
128
- this.#writeLog = new WriteLog({
129
+ this.#rpcLog = new RpcLog({
129
130
  file: config.cacheFile('write-log.json'),
130
131
  rpcLogFolder: config.connection.rpcLogFolder,
131
132
  onAppended: (entry) => {
132
- this.events.emit('writeLog.appended', entry);
133
+ this.events.emit('rpcLog.appended', entry);
133
134
  },
134
135
  onError: (error) => {
135
- this.#notice('warn', `write log: ${errorMessage(error)}`);
136
+ this.#notice('warn', `RPC log: ${errorMessage(error)}`);
136
137
  },
137
138
  ...(options.cacheWriteDelayMs === undefined ? {} : { writeDelayMs: options.cacheWriteDelayMs }),
138
139
  });
@@ -166,7 +167,7 @@ export class Backend {
166
167
  });
167
168
  await caches.load();
168
169
  const backend = new Backend(options, config, caches);
169
- await backend.#writeLog.load();
170
+ await backend.#rpcLog.load();
170
171
  await backend.#linkTemplates.load();
171
172
  return backend;
172
173
  }
@@ -213,7 +214,7 @@ export class Backend {
213
214
  await this.#meta?.stop();
214
215
  this.#meta = undefined;
215
216
  await this.#caches.flush();
216
- await this.#writeLog.flush();
217
+ await this.#rpcLog.flush();
217
218
  this.events.clear();
218
219
  }
219
220
  /**
@@ -236,7 +237,7 @@ export class Backend {
236
237
  }
237
238
  if (this.#sessions > 0) {
238
239
  if (previous === 0) {
239
- void this.#resubscribe();
240
+ void runWithOrigin('background', () => this.#resubscribe());
240
241
  }
241
242
  return;
242
243
  }
@@ -246,7 +247,7 @@ export class Backend {
246
247
  }
247
248
  const timer = setTimeout(() => {
248
249
  this.#idleTimer = undefined;
249
- void this.#unsubscribeIdle();
250
+ void runWithOrigin('background', () => this.#unsubscribeIdle());
250
251
  }, grace);
251
252
  if (typeof timer.unref === 'function') {
252
253
  timer.unref();
@@ -279,7 +280,9 @@ export class Backend {
279
280
  */
280
281
  async request(method, ...params) {
281
282
  try {
282
- return await this.#dispatch(method, params);
283
+ // Task 48: everything a request awaits is a UI action for the RPC log; the console's
284
+ // own call is the console's. Whatever runs outside a request is background work.
285
+ return await runWithOrigin(method === 'rpc.call' ? 'console' : 'ui', () => this.#dispatch(method, params));
283
286
  }
284
287
  catch (error) {
285
288
  throw error instanceof BackendError ? error : internalError(errorMessage(error), error);
@@ -305,6 +308,8 @@ export class Backend {
305
308
  return this.#discover();
306
309
  case 'config.clearCaches':
307
310
  return this.#clearCaches();
311
+ case 'config.callbackAddresses':
312
+ return this.#callbackAddresses(p[0]);
308
313
  case 'interfaces.list':
309
314
  return this.#manager?.states() ?? [];
310
315
  case 'interfaces.reconnect':
@@ -468,10 +473,10 @@ export class Backend {
468
473
  return this.#methods(p[0]);
469
474
  case 'write.cancel':
470
475
  return this.#queue.cancel(p[0]);
471
- case 'writeLog.list':
472
- return this.#writeLog.list(p[0]);
473
- case 'writeLog.clear':
474
- this.#writeLog.clear();
476
+ case 'rpcLog.list':
477
+ return this.#rpcLog.list(p[0]);
478
+ case 'rpcLog.clear':
479
+ this.#rpcLog.clear();
475
480
  return null;
476
481
  case 'data.file':
477
482
  return this.#files.read(p[0]);
@@ -493,7 +498,15 @@ export class Backend {
493
498
  }
494
499
  return this.#manager;
495
500
  }
496
- async #connect() {
501
+ /**
502
+ * Task 48: the connection is background work even when a `config.set` from the settings dialog
503
+ * asks for it - the watchdog, the callback servers and the polling timers it creates would
504
+ * otherwise inherit that request's origin for the rest of the session.
505
+ */
506
+ #connect() {
507
+ return runWithOrigin('background', () => this.#connectNow());
508
+ }
509
+ async #connectNow() {
497
510
  const connection = this.#config.connection;
498
511
  this.#noServiceMessages.clear();
499
512
  this.#serviceMessageFailures.clear();
@@ -508,10 +521,11 @@ export class Backend {
508
521
  onNotice: (level, message, interfaceName) => {
509
522
  this.#notice(level, message, interfaceName);
510
523
  },
511
- onConnected: (interfaceName) => this.#onInterfaceConnected(interfaceName),
524
+ onConnected: (interfaceName) => runWithOrigin('background', () => this.#onInterfaceConnected(interfaceName)),
512
525
  onCall: (record) => {
513
526
  this.#onCall(record);
514
527
  },
528
+ originOf: currentOrigin,
515
529
  ...(this.#options.callbackHost === undefined ? {} : { callbackHost: this.#options.callbackHost }),
516
530
  ...(this.#options.defaultCallbackPorts === undefined
517
531
  ? {}
@@ -522,6 +536,8 @@ export class Backend {
522
536
  ? {}
523
537
  : { watchdogIntervalMs: this.#options.watchdogIntervalMs }),
524
538
  ...(this.#options.localAddresses === undefined ? {} : { localAddresses: this.#options.localAddresses }),
539
+ ...(this.#options.network === undefined ? {} : { network: this.#options.network }),
540
+ ...(this.#keepsConfiguredCallbackIp() ? { keepConfiguredCallbackIp: true } : {}),
525
541
  ...(this.#options.now === undefined ? {} : { now: this.#options.now }),
526
542
  ...this.#options.interfaceManagerOptions,
527
543
  });
@@ -833,7 +849,7 @@ export class Backend {
833
849
  * RPC
834
850
  */
835
851
  #onCall(record) {
836
- this.#writeLog.append(record);
852
+ this.#rpcLog.append(record);
837
853
  }
838
854
  /** A read: straight to the interface, never queued. */
839
855
  async #read(interfaceName, method, params) {
@@ -842,7 +858,10 @@ export class Backend {
842
858
  /** A write: through the paced queue of that interface. */
843
859
  async #write(interfaceName, method, params) {
844
860
  const client = this.#requireManager().client(interfaceName);
845
- return this.#queue.enqueue(interfaceName, () => client.call(method, params));
861
+ // Task 48: the queue runs a task from the timer that drained it, which is the *previous*
862
+ // task's context; the origin is the one of whoever enqueued, so it is taken here.
863
+ const origin = currentOrigin();
864
+ return this.#queue.enqueue(interfaceName, () => client.call(method, params, { origin }));
846
865
  }
847
866
  /*
848
867
  * configuration
@@ -877,7 +896,7 @@ export class Backend {
877
896
  : [];
878
897
  await this.#disconnect();
879
898
  const config = this.#withHostFacts(await this.#config.setConnection(connection));
880
- this.#writeLog.setRpcLogFolder(config.connection.rpcLogFolder);
899
+ this.#rpcLog.setRpcLogFolder(config.connection.rpcLogFolder);
881
900
  if (config.connection.host !== previousHost) {
882
901
  await this.#caches.flush();
883
902
  this.#caches = new CacheStore({
@@ -942,6 +961,27 @@ export class Backend {
942
961
  }
943
962
  }
944
963
  }
964
+ /**
965
+ * B-53: a callback address set at start (task 38) or inside a container is taken as it is - the
966
+ * Docker host's address is none of the container's, and it is still the right one.
967
+ */
968
+ #keepsConfiguredCallbackIp() {
969
+ return this.#options.inContainer === true || this.#config.callbackPins?.ip === true;
970
+ }
971
+ #network() {
972
+ if (this.#options.network !== undefined) {
973
+ return this.#options.network;
974
+ }
975
+ const injected = this.#options.localAddresses;
976
+ return injected === undefined ? systemNetwork() : staticNetwork(injected);
977
+ }
978
+ /** B-53: `config.callbackAddresses` - for the host being typed, or the configured one. */
979
+ async #callbackAddresses(host) {
980
+ const { connection } = this.#config;
981
+ const target = typeof host === 'string' ? host.trim() : connection.host;
982
+ const info = await describeCallbackAddresses(target, this.#network(), connection.local === true);
983
+ return this.#keepsConfiguredCallbackIp() ? { ...info, keepsConfigured: true } : info;
984
+ }
945
985
  async #discover() {
946
986
  const discover = this.#options.discover ?? ((options) => discoverCcus(options));
947
987
  const found = await discover({ tls: this.#config.connection.tls });
@@ -1282,7 +1322,7 @@ export class Backend {
1282
1322
  return;
1283
1323
  }
1284
1324
  const timer = setInterval(() => {
1285
- void this.pollServiceMessages();
1325
+ void runWithOrigin('background', () => this.pollServiceMessages());
1286
1326
  }, interval);
1287
1327
  if (typeof timer.unref === 'function') {
1288
1328
  timer.unref();
@@ -1310,7 +1350,7 @@ export class Backend {
1310
1350
  }
1311
1351
  const timer = setTimeout(() => {
1312
1352
  this.#hmipSweepTimer = undefined;
1313
- void this.sweepHmip();
1353
+ void runWithOrigin('background', () => this.sweepHmip());
1314
1354
  }, this.#options.hmipSweepDelayMs ?? HMIP_SWEEP_DELAY_MS);
1315
1355
  if (typeof timer.unref === 'function') {
1316
1356
  timer.unref();
@@ -1565,6 +1605,7 @@ export const API_METHOD_NAMES = [
1565
1605
  'config.set',
1566
1606
  'config.discover',
1567
1607
  'config.clearCaches',
1608
+ 'config.callbackAddresses',
1568
1609
  'interfaces.list',
1569
1610
  'interfaces.reconnect',
1570
1611
  'rega.state',
@@ -1630,8 +1671,8 @@ export const API_METHOD_NAMES = [
1630
1671
  'rpc.call',
1631
1672
  'rpc.methods',
1632
1673
  'write.cancel',
1633
- 'writeLog.list',
1634
- 'writeLog.clear',
1674
+ 'rpcLog.list',
1675
+ 'rpcLog.clear',
1635
1676
  'data.file',
1636
1677
  'session.info',
1637
1678
  ];
@@ -21,6 +21,8 @@ export * from './config/legacyImport.js';
21
21
  export * from './config/store.js';
22
22
  export * from './config/linkTemplates.js';
23
23
  export * from './rpc/client.js';
24
+ export * from './rpc/log.js';
25
+ export * from './rpc/origin.js';
24
26
  export * from './rpc/server.js';
25
27
  export * from './interfaces/manager.js';
26
28
  export * from './cache/devices.js';
@@ -30,7 +32,6 @@ export * from './cache/unreach.js';
30
32
  export * from './cache/store.js';
31
33
  export * from './meta/index.js';
32
34
  export * from './write/queue.js';
33
- export * from './write/log.js';
34
35
  export * from './write/paramset.js';
35
36
  export * from './rega/auth.js';
36
37
  export * from './rega/client.js';
@@ -24,6 +24,8 @@ export * from './config/store.js';
24
24
  export * from './config/linkTemplates.js';
25
25
  // the protocol layer
26
26
  export * from './rpc/client.js';
27
+ export * from './rpc/log.js';
28
+ export * from './rpc/origin.js';
27
29
  export * from './rpc/server.js';
28
30
  export * from './interfaces/manager.js';
29
31
  // state
@@ -36,7 +38,6 @@ export * from './cache/store.js';
36
38
  export * from './meta/index.js';
37
39
  // the write path (task 6)
38
40
  export * from './write/queue.js';
39
- export * from './write/log.js';
40
41
  export * from './write/paramset.js';
41
42
  // the optional and the peripheral
42
43
  export * from './rega/auth.js';
@@ -19,11 +19,11 @@
19
19
  * dialog. 2.x probed six ports with a 5 s timeout each before the window became usable, which is
20
20
  * the root of the "endless loading" issues #121, #126, #128 and #134.
21
21
  */
22
- import type { CallbackPins, ConnectionConfig, InterfaceState, ResolvedInterface } from '@homematic-manager/core';
22
+ import type { CallbackPins, CallbackWarning, ConnectionConfig, InterfaceState, ResolvedInterface } from '@homematic-manager/core';
23
23
  import { type InterfaceTarget } from '../config/defaults.js';
24
24
  import { RpcClient, type RpcCallRecord, type RpcClientOptions } from '../rpc/client.js';
25
25
  import { type CallbackHandler, type CallbackServerSet } from '../rpc/server.js';
26
- import { type PortProbe } from '../util/net.js';
26
+ import { type CallbackNetwork, type PortProbe } from '../util/net.js';
27
27
  /** How often the watchdog looks at every interface. 2.x used the same 15 s. */
28
28
  export declare const WATCHDOG_INTERVAL_MS = 15000;
29
29
  /** Where an interface process on this very box calls back (#144). */
@@ -65,6 +65,8 @@ export interface InterfaceManagerOptions {
65
65
  /** Called after a successful `init`; the backend fills its caches there. */
66
66
  readonly onConnected?: (interfaceName: string) => void | Promise<void>;
67
67
  readonly onCall?: (record: RpcCallRecord) => void;
68
+ /** Task 48: the origin of a call that names none, handed to every client (the backend's request context). */
69
+ readonly originOf?: RpcClientOptions['originOf'];
68
70
  readonly now?: () => number;
69
71
  readonly rpcTimeoutMs?: number;
70
72
  readonly watchdogIntervalMs?: number;
@@ -93,8 +95,20 @@ export interface InterfaceManagerOptions {
93
95
  readonly createClient?: (options: RpcClientOptions) => RpcClient;
94
96
  readonly createCallbackServers?: (handler: CallbackHandler) => CallbackServerSet;
95
97
  readonly probe?: (host: string, port: number) => Promise<PortProbe>;
96
- /** Injected for the callback address; defaults to this machine's IPv4 addresses. */
98
+ /**
99
+ * Injected for the callback address; defaults to this machine's IPv4 addresses. A list given
100
+ * here has no netmasks, so the automatic choice is its first entry (or the loopback for a CCU
101
+ * on the loopback) unless {@link network} is given as well.
102
+ */
97
103
  readonly localAddresses?: () => string[];
104
+ /** B-53: the interfaces, name lookup and route probe the automatic callback address is worked out from. */
105
+ readonly network?: CallbackNetwork;
106
+ /**
107
+ * B-53: take a set callback address as it is, even when it is no address of this machine - the
108
+ * host set it at start (task 38) or runs in a container, where the Docker host's address is the
109
+ * one that works. No fallback and no warning then.
110
+ */
111
+ readonly keepConfiguredCallbackIp?: boolean;
98
112
  /**
99
113
  * Overrides the port of one interface, for a process that does not sit on the well-known one:
100
114
  * the integration tests point at an hm-simulator on an ephemeral port, and an unusual proxy
@@ -132,8 +146,14 @@ export declare class InterfaceManager {
132
146
  * one address that changes - a new lease, another network - while `init` registrations survive
133
147
  * such a change in the interface process's handler list, and every other local subscriber on a
134
148
  * CCU registers on the loopback, which is what a look at that list expects to see.
149
+ *
150
+ * B-53 (#162, #165): with no address set, the one in the CCU's network, else the one the route
151
+ * to the CCU leaves from, else the first one that is not link-local - worked out again at every
152
+ * `init`. Until the first `init` this is the same choice without the name lookup and the route.
135
153
  */
136
154
  get callbackIp(): string;
155
+ /** B-53: the callback address set in the settings that does not fit, as of the last connect. */
156
+ get callbackWarning(): CallbackWarning | undefined;
137
157
  /** D-31: are the subscriptions currently dropped because nobody is looking? */
138
158
  get idle(): boolean;
139
159
  /** The interfaces whose ports answered the last background probe. */