homematic-manager 3.0.0-beta.18 → 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.
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 |
@@ -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,6 +28,7 @@ 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 { describeCallbackAddresses, staticNetwork, systemNetwork } from '../util/net.js';
31
32
  import { RpcLog, isWriteMethod } from '../rpc/log.js';
32
33
  import { currentOrigin, runWithOrigin } from '../rpc/origin.js';
33
34
  import { ParamsetWriter } from '../write/paramset.js';
@@ -307,6 +308,8 @@ export class Backend {
307
308
  return this.#discover();
308
309
  case 'config.clearCaches':
309
310
  return this.#clearCaches();
311
+ case 'config.callbackAddresses':
312
+ return this.#callbackAddresses(p[0]);
310
313
  case 'interfaces.list':
311
314
  return this.#manager?.states() ?? [];
312
315
  case 'interfaces.reconnect':
@@ -533,6 +536,8 @@ export class Backend {
533
536
  ? {}
534
537
  : { watchdogIntervalMs: this.#options.watchdogIntervalMs }),
535
538
  ...(this.#options.localAddresses === undefined ? {} : { localAddresses: this.#options.localAddresses }),
539
+ ...(this.#options.network === undefined ? {} : { network: this.#options.network }),
540
+ ...(this.#keepsConfiguredCallbackIp() ? { keepConfiguredCallbackIp: true } : {}),
536
541
  ...(this.#options.now === undefined ? {} : { now: this.#options.now }),
537
542
  ...this.#options.interfaceManagerOptions,
538
543
  });
@@ -956,6 +961,27 @@ export class Backend {
956
961
  }
957
962
  }
958
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
+ }
959
985
  async #discover() {
960
986
  const discover = this.#options.discover ?? ((options) => discoverCcus(options));
961
987
  const found = await discover({ tls: this.#config.connection.tls });
@@ -1579,6 +1605,7 @@ export const API_METHOD_NAMES = [
1579
1605
  'config.set',
1580
1606
  'config.discover',
1581
1607
  'config.clearCaches',
1608
+ 'config.callbackAddresses',
1582
1609
  'interfaces.list',
1583
1610
  'interfaces.reconnect',
1584
1611
  'rega.state',
@@ -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). */
@@ -95,8 +95,20 @@ export interface InterfaceManagerOptions {
95
95
  readonly createClient?: (options: RpcClientOptions) => RpcClient;
96
96
  readonly createCallbackServers?: (handler: CallbackHandler) => CallbackServerSet;
97
97
  readonly probe?: (host: string, port: number) => Promise<PortProbe>;
98
- /** 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
+ */
99
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;
100
112
  /**
101
113
  * Overrides the port of one interface, for a process that does not sit on the well-known one:
102
114
  * the integration tests point at an hm-simulator on an ephemeral port, and an unusual proxy
@@ -134,8 +146,14 @@ export declare class InterfaceManager {
134
146
  * one address that changes - a new lease, another network - while `init` registrations survive
135
147
  * such a change in the interface process's handler list, and every other local subscriber on a
136
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.
137
153
  */
138
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;
139
157
  /** D-31: are the subscriptions currently dropped because nobody is looking? */
140
158
  get idle(): boolean;
141
159
  /** The interfaces whose ports answered the last background probe. */
@@ -24,7 +24,7 @@ import { configError, connectionError, errorMessage, isAddressInUse, isConnectio
24
24
  import { interfaceTargets } from '../config/defaults.js';
25
25
  import { RpcClient } from '../rpc/client.js';
26
26
  import { CallbackServers } from '../rpc/server.js';
27
- import { localIPv4Addresses, probePortState, withTimeout } from '../util/net.js';
27
+ import { describeCallbackAddresses, ipv4ToNumber, localIPv4Addresses, pickCallbackAddress, probePortState, staticNetwork, systemNetwork, withTimeout, } from '../util/net.js';
28
28
  /** How often the watchdog looks at every interface. 2.x used the same 15 s. */
29
29
  export const WATCHDOG_INTERVAL_MS = 15_000;
30
30
  /**
@@ -96,6 +96,11 @@ export class InterfaceManager {
96
96
  #callbackFailures = new Map();
97
97
  #reopening = new Map();
98
98
  #watchdog;
99
+ /** B-53: the callback address of the last connect, and what did not fit about the configured one. */
100
+ #callback;
101
+ #callbackRefresh;
102
+ /** B-53: the warning last logged, so a watchdog round does not log it again. */
103
+ #callbackNoted = '';
99
104
  #detected = [];
100
105
  #stopping = false;
101
106
  #idle = false;
@@ -132,17 +137,107 @@ export class InterfaceManager {
132
137
  * one address that changes - a new lease, another network - while `init` registrations survive
133
138
  * such a change in the interface process's handler list, and every other local subscriber on a
134
139
  * CCU registers on the loopback, which is what a look at that list expects to see.
140
+ *
141
+ * B-53 (#162, #165): with no address set, the one in the CCU's network, else the one the route
142
+ * to the CCU leaves from, else the first one that is not link-local - worked out again at every
143
+ * `init`. Until the first `init` this is the same choice without the name lookup and the route.
135
144
  */
136
145
  get callbackIp() {
137
- const configured = this.#options.connection.callback.ip;
138
- if (configured !== '') {
139
- return configured;
146
+ return this.#callback?.ip ?? this.#immediateCallbackIp();
147
+ }
148
+ /** B-53: the callback address set in the settings that does not fit, as of the last connect. */
149
+ get callbackWarning() {
150
+ return this.#callback?.warning;
151
+ }
152
+ /** Read through a method, so that the check after an `await` is not narrowed away. */
153
+ #hasStopped() {
154
+ return this.#stopping;
155
+ }
156
+ #network() {
157
+ if (this.#options.network !== undefined) {
158
+ return this.#options.network;
140
159
  }
141
- if (this.#options.connection.local === true) {
160
+ const injected = this.#options.localAddresses;
161
+ return injected === undefined ? systemNetwork() : staticNetwork(injected);
162
+ }
163
+ #immediateCallbackIp() {
164
+ const { host, local, callback } = this.#options.connection;
165
+ if (callback.ip !== '') {
166
+ return callback.ip;
167
+ }
168
+ if (local === true) {
142
169
  return LOOPBACK_IP;
143
170
  }
144
- const addresses = (this.#options.localAddresses ?? (() => localIPv4Addresses()))();
145
- return addresses[0] ?? LOOPBACK_IP;
171
+ const literal = ipv4ToNumber(host) === undefined ? undefined : host;
172
+ return pickCallbackAddress(host, literal, this.#network().interfaces(), undefined).auto.address;
173
+ }
174
+ /** B-53: works the callback address out once for every `init` that runs at the same time. */
175
+ #refreshCallback() {
176
+ this.#callbackRefresh ??= this.#resolveCallback().finally(() => {
177
+ this.#callbackRefresh = undefined;
178
+ });
179
+ return this.#callbackRefresh;
180
+ }
181
+ async #resolveCallback() {
182
+ const { host, local, callback } = this.#options.connection;
183
+ const configured = callback.ip;
184
+ let result;
185
+ if (local === true) {
186
+ // the addon: the loopback, or what was set - nothing on the CCU itself is second-guessed
187
+ result = { ip: configured === '' ? LOOPBACK_IP : configured };
188
+ }
189
+ else if (configured !== '' &&
190
+ (this.#options.keepConfiguredCallbackIp === true || configured === LOOPBACK_IP)) {
191
+ result = { ip: configured };
192
+ }
193
+ else {
194
+ let info;
195
+ try {
196
+ info = await describeCallbackAddresses(host, this.#network());
197
+ }
198
+ catch {
199
+ // the lookups never reject; an interface list that throws leaves the old rule
200
+ info = undefined;
201
+ }
202
+ if (info === undefined) {
203
+ result = { ip: configured === '' ? (localIPv4Addresses()[0] ?? LOOPBACK_IP) : configured };
204
+ }
205
+ else if (configured === '') {
206
+ result = { ip: info.auto.address };
207
+ }
208
+ else {
209
+ const candidate = info.addresses.find((entry) => entry.address === configured);
210
+ const auto = info.auto.address;
211
+ if (candidate === undefined) {
212
+ result = { ip: auto, warning: { address: configured, reason: 'notLocal', auto } };
213
+ }
214
+ else if (info.hostAddress !== undefined && !candidate.inSubnet && configured !== auto) {
215
+ result = { ip: configured, warning: { address: configured, reason: 'otherNetwork', auto } };
216
+ }
217
+ else {
218
+ result = { ip: configured };
219
+ }
220
+ }
221
+ }
222
+ this.#callback = result;
223
+ this.#noteCallbackWarning(result.warning);
224
+ return result;
225
+ }
226
+ /** B-53: a warning is logged when it appears or changes, not at every watchdog `init`. */
227
+ #noteCallbackWarning(warning) {
228
+ const key = warning === undefined ? '' : `${warning.reason} ${warning.address} ${warning.auto}`;
229
+ if (key === this.#callbackNoted) {
230
+ return;
231
+ }
232
+ this.#callbackNoted = key;
233
+ if (warning === undefined) {
234
+ return;
235
+ }
236
+ this.#options.onNotice('warn', warning.reason === 'notLocal'
237
+ ? `callback address ${warning.address} from the settings is no address of this machine any more - ` +
238
+ `the automatic address ${warning.auto} is used instead; choose "Automatic" in the settings to keep it that way`
239
+ : `callback address ${warning.address} from the settings is not in the CCU's network - ` +
240
+ `the CCU may not reach it and send no events; the automatic address would be ${warning.auto}`);
146
241
  }
147
242
  /** D-31: are the subscriptions currently dropped because nobody is looking? */
148
243
  get idle() {
@@ -401,7 +496,8 @@ export class InterfaceManager {
401
496
  if (!entry.target.resolved.init || this.#callbackFailures.has(entry.target.resolved.protocol)) {
402
497
  return;
403
498
  }
404
- const url = this.#callbackUrl(entry.target.resolved.protocol);
499
+ // the URL it was registered with: the automatic address may have moved since (B-53)
500
+ const url = entry.state.callbackUrl ?? this.#callbackUrl(entry.target.resolved.protocol);
405
501
  try {
406
502
  await withTimeout(entry.client.call('init', [url, ''], BACKGROUND), SHUTDOWN_TIMEOUT_MS, () => connectionError(`${entry.name}: de-registering timed out`));
407
503
  }
@@ -522,8 +618,13 @@ export class InterfaceManager {
522
618
  this.#noteNoCallbackServer(entry, this.#callbackFailures.get(resolved.protocol) ?? failure);
523
619
  return;
524
620
  }
525
- const url = this.#callbackUrl(resolved.protocol);
526
- this.#update(entry, { callbackUrl: url, callbackFailure: undefined });
621
+ const callback = await this.#refreshCallback();
622
+ // a name lookup may take seconds; a manager stopped meanwhile subscribes nothing any more
623
+ if (this.#hasStopped()) {
624
+ return;
625
+ }
626
+ const url = this.#servers.callbackUrl(resolved.protocol, callback.ip);
627
+ this.#update(entry, { callbackUrl: url, callbackFailure: undefined, callbackWarning: callback.warning });
527
628
  try {
528
629
  await entry.client.call('init', [url, resolved.ident], BACKGROUND);
529
630
  entry.lastEvent = this.#now();
@@ -627,6 +728,14 @@ export class InterfaceManager {
627
728
  state.callbackFailure = changes.callbackFailure;
628
729
  }
629
730
  }
731
+ if ('callbackWarning' in changes) {
732
+ if (changes.callbackWarning === undefined) {
733
+ delete state.callbackWarning;
734
+ }
735
+ else {
736
+ state.callbackWarning = { ...changes.callbackWarning };
737
+ }
738
+ }
630
739
  entry.state = state;
631
740
  }
632
741
  #startWatchdog() {
@@ -7,8 +7,11 @@
7
7
  * 5 s timeout each before the window became usable) and it destroys the socket instead of leaving
8
8
  * it to time out.
9
9
  */
10
+ import dgram from 'node:dgram';
11
+ import dns from 'node:dns';
10
12
  import net from 'node:net';
11
13
  import os from 'node:os';
14
+ import type { CallbackAddressInfo } from '@homematic-manager/core';
12
15
  /**
13
16
  * Every non-internal IPv4 address of this machine, then `127.0.0.1`; candidates for the callback
14
17
  * address. The loopback comes last so nothing that picked "the first candidate" changes - but it
@@ -17,6 +20,70 @@ import os from 'node:os';
17
20
  * can call back (openccu-lite task 28.10, maintainer 2026-09-08).
18
21
  */
19
22
  export declare function localIPv4Addresses(interfaces?: () => NodeJS.Dict<os.NetworkInterfaceInfo[]>): string[];
23
+ /** One IPv4 address of this machine; `netmask` is absent where it is not known (an injected list). */
24
+ export interface LocalIPv4 {
25
+ readonly address: string;
26
+ readonly netmask?: string;
27
+ }
28
+ /**
29
+ * B-53: every non-internal IPv4 address of this machine with its netmask, in the order
30
+ * `os.networkInterfaces()` gives them - the loopback is not in it.
31
+ */
32
+ export declare function localIPv4Interfaces(interfaces?: () => NodeJS.Dict<os.NetworkInterfaceInfo[]>): LocalIPv4[];
33
+ /** A dotted IPv4 address as a number; `undefined` for anything else. */
34
+ export declare function ipv4ToNumber(address: string): number | undefined;
35
+ /** True when `target` is in the network of `address/netmask`. False when any of them is no IPv4 address. */
36
+ export declare function inSubnet(address: string, netmask: string, target: string): boolean;
37
+ /** `169.254.0.0/16`, the address an interface gives itself when no DHCP server answered. */
38
+ export declare function isLinkLocal(address: string): boolean;
39
+ /** `127.0.0.0/8`. */
40
+ export declare function isLoopback(address: string): boolean;
41
+ /**
42
+ * B-53: what the automatic callback address is worked out from. Injected by the tests, so that no
43
+ * unit test depends on the network of the machine it runs on.
44
+ */
45
+ export interface CallbackNetwork {
46
+ /** This machine's non-internal IPv4 addresses, see {@link localIPv4Interfaces}. */
47
+ readonly interfaces: () => LocalIPv4[];
48
+ /** The CCU host's IPv4 address; `undefined` when it does not resolve. Never rejects. */
49
+ readonly resolve: (host: string) => Promise<string | undefined>;
50
+ /** The local address the operating system sends from towards `address`; `undefined` when there is no route. Never rejects. */
51
+ readonly route: (address: string) => Promise<string | undefined>;
52
+ }
53
+ /** How long a name lookup for the callback address may take before the first address is used. */
54
+ export declare const RESOLVE_TIMEOUT_MS = 3000;
55
+ /** The CCU host's IPv4 address, through the system resolver (`/etc/hosts`, mDNS where the OS has it). */
56
+ export declare function resolveIPv4(host: string, lookup?: typeof dns.promises.lookup): Promise<string | undefined>;
57
+ /**
58
+ * The local address a UDP socket gets when it is `connect()`ed to `address`: the source address of
59
+ * the route the operating system would take. `connect` on a datagram socket only sets the default
60
+ * peer - no packet is sent - so this works for a CCU behind a router or a VPN as well as for one in
61
+ * the same network.
62
+ */
63
+ export declare function routeSource(address: string, createSocket?: typeof dgram.createSocket): Promise<string | undefined>;
64
+ /** The machine's own network, as the running host sees it. */
65
+ export declare function systemNetwork(): CallbackNetwork;
66
+ /**
67
+ * A network made of a fixed address list, for a caller that injects `localAddresses` (the tests and
68
+ * the e2e hosts): no netmasks, no name lookup beyond an address literal, no route.
69
+ */
70
+ export declare function staticNetwork(addresses: () => string[]): CallbackNetwork;
71
+ /**
72
+ * B-53 (#162, #165): the automatic callback address, and every address with whether it is in the
73
+ * CCU's network. Synchronous: `hostAddress` and `routed` are what {@link describeCallbackAddresses}
74
+ * found out, and `undefined` when nothing could be.
75
+ *
76
+ * The order: the CCU on the loopback (or `local`) is called back on the loopback; then the
77
+ * address in the CCU's network; then the route's source address; then the first address that is
78
+ * not link-local. A link-local address wins only as the CCU's own network, and the loopback is
79
+ * never chosen for a CCU elsewhere unless this machine has no other address at all.
80
+ */
81
+ export declare function pickCallbackAddress(host: string, hostAddress: string | undefined, interfaces: readonly LocalIPv4[], routed: string | undefined, local?: boolean): CallbackAddressInfo;
82
+ /**
83
+ * B-53: {@link pickCallbackAddress} with the facts looked up - the CCU's name resolved, and the route
84
+ * asked for only when no address shares the CCU's network. Never rejects.
85
+ */
86
+ export declare function describeCallbackAddresses(host: string, network: CallbackNetwork, local?: boolean): Promise<CallbackAddressInfo>;
20
87
  export interface ProbeOptions {
21
88
  readonly timeoutMs?: number;
22
89
  /** Injected for the tests; defaults to `net.connect`. */
@@ -7,6 +7,8 @@
7
7
  * 5 s timeout each before the window became usable) and it destroys the socket instead of leaving
8
8
  * it to time out.
9
9
  */
10
+ import dgram from 'node:dgram';
11
+ import dns from 'node:dns';
10
12
  import net from 'node:net';
11
13
  import os from 'node:os';
12
14
  /**
@@ -28,6 +30,205 @@ export function localIPv4Addresses(interfaces = os.networkInterfaces) {
28
30
  addresses.push('127.0.0.1');
29
31
  return addresses;
30
32
  }
33
+ /** The address an interface process on this machine calls back on. */
34
+ const LOOPBACK = '127.0.0.1';
35
+ /**
36
+ * B-53: every non-internal IPv4 address of this machine with its netmask, in the order
37
+ * `os.networkInterfaces()` gives them - the loopback is not in it.
38
+ */
39
+ export function localIPv4Interfaces(interfaces = os.networkInterfaces) {
40
+ const found = [];
41
+ for (const entries of Object.values(interfaces())) {
42
+ for (const entry of entries ?? []) {
43
+ if (entry.family === 'IPv4' && !entry.internal && !found.some((seen) => seen.address === entry.address)) {
44
+ found.push({ address: entry.address, netmask: entry.netmask });
45
+ }
46
+ }
47
+ }
48
+ return found;
49
+ }
50
+ /** A dotted IPv4 address as a number; `undefined` for anything else. */
51
+ export function ipv4ToNumber(address) {
52
+ const parts = address.split('.');
53
+ if (parts.length !== 4) {
54
+ return undefined;
55
+ }
56
+ let value = 0;
57
+ for (const part of parts) {
58
+ if (!/^\d{1,3}$/.test(part)) {
59
+ return undefined;
60
+ }
61
+ const byte = Number(part);
62
+ if (byte > 255) {
63
+ return undefined;
64
+ }
65
+ value = value * 256 + byte;
66
+ }
67
+ return value;
68
+ }
69
+ /** True when `target` is in the network of `address/netmask`. False when any of them is no IPv4 address. */
70
+ export function inSubnet(address, netmask, target) {
71
+ const a = ipv4ToNumber(address);
72
+ const m = ipv4ToNumber(netmask);
73
+ const t = ipv4ToNumber(target);
74
+ if (a === undefined || m === undefined || t === undefined) {
75
+ return false;
76
+ }
77
+ // `>>> 0` keeps the results unsigned; `&` alone would make 192.168.x.x negative
78
+ return (a & m) >>> 0 === (t & m) >>> 0;
79
+ }
80
+ /** `169.254.0.0/16`, the address an interface gives itself when no DHCP server answered. */
81
+ export function isLinkLocal(address) {
82
+ return address.startsWith('169.254.');
83
+ }
84
+ /** `127.0.0.0/8`. */
85
+ export function isLoopback(address) {
86
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(address);
87
+ }
88
+ /** How long a name lookup for the callback address may take before the first address is used. */
89
+ export const RESOLVE_TIMEOUT_MS = 3000;
90
+ /** The CCU host's IPv4 address, through the system resolver (`/etc/hosts`, mDNS where the OS has it). */
91
+ export async function resolveIPv4(host, lookup = dns.promises.lookup) {
92
+ if (ipv4ToNumber(host) !== undefined) {
93
+ return host;
94
+ }
95
+ if (host === '' || net.isIPv6(host)) {
96
+ return undefined;
97
+ }
98
+ try {
99
+ const result = await withTimeout(lookup(host, { family: 4 }), RESOLVE_TIMEOUT_MS, () => new Error('timeout'));
100
+ return result.address;
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ /**
107
+ * The local address a UDP socket gets when it is `connect()`ed to `address`: the source address of
108
+ * the route the operating system would take. `connect` on a datagram socket only sets the default
109
+ * peer - no packet is sent - so this works for a CCU behind a router or a VPN as well as for one in
110
+ * the same network.
111
+ */
112
+ export function routeSource(address, createSocket = dgram.createSocket) {
113
+ return new Promise((resolve) => {
114
+ let socket;
115
+ try {
116
+ socket = createSocket('udp4');
117
+ }
118
+ catch {
119
+ resolve(undefined);
120
+ return;
121
+ }
122
+ let settled = false;
123
+ const done = (result) => {
124
+ if (settled) {
125
+ return;
126
+ }
127
+ settled = true;
128
+ try {
129
+ socket.close();
130
+ }
131
+ catch {
132
+ // already closed
133
+ }
134
+ resolve(result);
135
+ };
136
+ socket.on('error', () => {
137
+ done(undefined);
138
+ });
139
+ try {
140
+ // the port does not matter, nothing is sent; 9 is "discard"
141
+ socket.connect(9, address, () => {
142
+ try {
143
+ done(socket.address().address);
144
+ }
145
+ catch {
146
+ done(undefined);
147
+ }
148
+ });
149
+ }
150
+ catch {
151
+ done(undefined);
152
+ }
153
+ });
154
+ }
155
+ /** The machine's own network, as the running host sees it. */
156
+ export function systemNetwork() {
157
+ return {
158
+ interfaces: () => localIPv4Interfaces(),
159
+ resolve: (host) => resolveIPv4(host),
160
+ route: (address) => routeSource(address),
161
+ };
162
+ }
163
+ /**
164
+ * A network made of a fixed address list, for a caller that injects `localAddresses` (the tests and
165
+ * the e2e hosts): no netmasks, no name lookup beyond an address literal, no route.
166
+ */
167
+ export function staticNetwork(addresses) {
168
+ return {
169
+ interfaces: () => addresses()
170
+ .filter((address) => !isLoopback(address))
171
+ .map((address) => ({ address })),
172
+ resolve: (host) => Promise.resolve(ipv4ToNumber(host) === undefined ? undefined : host),
173
+ route: () => Promise.resolve(undefined),
174
+ };
175
+ }
176
+ /**
177
+ * B-53 (#162, #165): the automatic callback address, and every address with whether it is in the
178
+ * CCU's network. Synchronous: `hostAddress` and `routed` are what {@link describeCallbackAddresses}
179
+ * found out, and `undefined` when nothing could be.
180
+ *
181
+ * The order: the CCU on the loopback (or `local`) is called back on the loopback; then the
182
+ * address in the CCU's network; then the route's source address; then the first address that is
183
+ * not link-local. A link-local address wins only as the CCU's own network, and the loopback is
184
+ * never chosen for a CCU elsewhere unless this machine has no other address at all.
185
+ */
186
+ export function pickCallbackAddress(host, hostAddress, interfaces, routed, local = false) {
187
+ const matches = (entry) => hostAddress !== undefined && entry.netmask !== undefined && inSubnet(entry.address, entry.netmask, hostAddress);
188
+ const ccuOnLoopback = local || (hostAddress !== undefined && isLoopback(hostAddress));
189
+ const addresses = interfaces.map((entry) => ({
190
+ address: entry.address,
191
+ inSubnet: matches(entry),
192
+ ...(isLinkLocal(entry.address) ? { linkLocal: true } : {}),
193
+ }));
194
+ addresses.push({ address: LOOPBACK, inSubnet: ccuOnLoopback });
195
+ const choose = () => {
196
+ if (ccuOnLoopback) {
197
+ return { address: LOOPBACK, reason: 'loopback' };
198
+ }
199
+ const subnet = interfaces.find((entry) => matches(entry));
200
+ if (subnet !== undefined) {
201
+ return { address: subnet.address, reason: 'subnet' };
202
+ }
203
+ if (routed !== undefined && !isLinkLocal(routed) && interfaces.some((entry) => entry.address === routed)) {
204
+ return { address: routed, reason: 'route' };
205
+ }
206
+ const first = interfaces.find((entry) => !isLinkLocal(entry.address));
207
+ return { address: first?.address ?? LOOPBACK, reason: 'first' };
208
+ };
209
+ return {
210
+ host,
211
+ ...(hostAddress === undefined ? {} : { hostAddress }),
212
+ auto: choose(),
213
+ addresses,
214
+ };
215
+ }
216
+ /**
217
+ * B-53: {@link pickCallbackAddress} with the facts looked up - the CCU's name resolved, and the route
218
+ * asked for only when no address shares the CCU's network. Never rejects.
219
+ */
220
+ export async function describeCallbackAddresses(host, network, local = false) {
221
+ const interfaces = network.interfaces();
222
+ if (local) {
223
+ return pickCallbackAddress(host, undefined, interfaces, undefined, true);
224
+ }
225
+ const hostAddress = host === '' ? undefined : await network.resolve(host);
226
+ const inNetwork = hostAddress !== undefined &&
227
+ (isLoopback(hostAddress) ||
228
+ interfaces.some((entry) => entry.netmask !== undefined && inSubnet(entry.address, entry.netmask, hostAddress)));
229
+ const routed = hostAddress === undefined || inNetwork ? undefined : await network.route(hostAddress);
230
+ return pickCallbackAddress(host, hostAddress, interfaces, routed);
231
+ }
31
232
  /** Whether a TCP connection to `host:port` is accepted, refused, or not answered in time. Never throws. */
32
233
  export function probePortState(host, port, options = {}) {
33
234
  const timeoutMs = options.timeoutMs ?? 2000;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homematic-manager/backend",
3
- "version": "3.0.0-beta.18",
3
+ "version": "3.0.0-beta.19",
4
4
  "type": "module",
5
5
  "description": "Homematic Manager backend: XML-RPC/BIN-RPC clients and callback servers, caches, optional ReGa",
6
6
  "license": "AGPL-3.0-or-later",