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 +1 -1
- package/node_modules/@homematic-manager/backend/dist/api/backend.d.ts +6 -0
- package/node_modules/@homematic-manager/backend/dist/api/backend.js +27 -0
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.d.ts +21 -3
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.js +119 -10
- package/node_modules/@homematic-manager/backend/dist/util/net.d.ts +67 -0
- package/node_modules/@homematic-manager/backend/dist/util/net.js +201 -0
- package/node_modules/@homematic-manager/backend/package.json +1 -1
- package/node_modules/@homematic-manager/core/dist/api/types.d.ts +66 -0
- package/node_modules/@homematic-manager/core/package.json +1 -1
- package/package.json +3 -3
- package/ui/assets/index-BQpPoLG-.css +1 -0
- package/ui/assets/index-D-hDPi8h.js +16 -0
- package/ui/favicon.ico +0 -0
- package/ui/index.html +3 -2
- package/ui/assets/index-CSaQ9232.js +0 -14
- package/ui/assets/index-CgW7l3BZ.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 |
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
145
|
-
return
|
|
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
|
-
|
|
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
|
|
526
|
-
|
|
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.
|
|
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",
|