@serfab/cadre-host 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +347 -423
- package/dist/bin/host.d.ts +6 -3
- package/dist/bin/host.js +332 -524
- package/dist/bin/host.js.map +1 -1
- package/dist/bin/join-output.d.ts +56 -0
- package/dist/bin/join-output.js +92 -0
- package/dist/bin/join-output.js.map +1 -0
- package/dist/bin/nat-output.d.ts +54 -0
- package/dist/bin/nat-output.js +138 -0
- package/dist/bin/nat-output.js.map +1 -0
- package/dist/bin/ui-access.d.ts +22 -0
- package/dist/bin/ui-access.js +44 -0
- package/dist/bin/ui-access.js.map +1 -0
- package/dist/hosted/claim-details.d.ts +66 -0
- package/dist/hosted/claim-details.js +60 -0
- package/dist/hosted/claim-details.js.map +1 -0
- package/dist/hosted/hosted-node-service.d.ts +325 -0
- package/dist/hosted/hosted-node-service.js +678 -0
- package/dist/hosted/hosted-node-service.js.map +1 -0
- package/dist/hosted/hosted-node-store.d.ts +40 -0
- package/dist/hosted/hosted-node-store.js +126 -0
- package/dist/hosted/hosted-node-store.js.map +1 -0
- package/dist/{donation/donation-supervisor.d.ts → hosted/hosted-node-supervisor.d.ts} +70 -53
- package/dist/hosted/hosted-node-supervisor.js +396 -0
- package/dist/hosted/hosted-node-supervisor.js.map +1 -0
- package/dist/hosted/hosted-node-watcher.d.ts +95 -0
- package/dist/hosted/hosted-node-watcher.js +260 -0
- package/dist/hosted/hosted-node-watcher.js.map +1 -0
- package/dist/hosted/index.d.ts +16 -0
- package/dist/hosted/index.js +13 -0
- package/dist/hosted/index.js.map +1 -0
- package/dist/hosted/node-status.d.ts +51 -0
- package/dist/hosted/node-status.js +77 -0
- package/dist/hosted/node-status.js.map +1 -0
- package/dist/hosted/types.d.ts +141 -0
- package/dist/hosted/types.js +22 -0
- package/dist/hosted/types.js.map +1 -0
- package/dist/index.d.ts +12 -24
- package/dist/index.js +9 -19
- package/dist/index.js.map +1 -1
- package/dist/installer/config.d.ts +27 -34
- package/dist/installer/config.js +46 -15
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +9 -19
- package/dist/installer/index.js +21 -85
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/paths.d.ts +0 -2
- package/dist/installer/paths.js +0 -4
- package/dist/installer/paths.js.map +1 -1
- package/dist/installer/wizard.d.ts +12 -9
- package/dist/installer/wizard.js +15 -14
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/address-resolver.d.ts +18 -17
- package/dist/nat/address-resolver.js +79 -25
- package/dist/nat/address-resolver.js.map +1 -1
- package/dist/nat/address-watch.d.ts +37 -0
- package/dist/nat/address-watch.js +84 -0
- package/dist/nat/address-watch.js.map +1 -0
- package/dist/nat/ddns/duckdns.d.ts +2 -1
- package/dist/nat/ddns/duckdns.js +5 -1
- package/dist/nat/ddns/duckdns.js.map +1 -1
- package/dist/nat/ddns/index.d.ts +6 -0
- package/dist/nat/ddns/index.js.map +1 -1
- package/dist/nat/index.d.ts +13 -9
- package/dist/nat/index.js +7 -5
- package/dist/nat/index.js.map +1 -1
- package/dist/nat/nat-service.d.ts +220 -77
- package/dist/nat/nat-service.js +653 -239
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/nat-store.d.ts +12 -5
- package/dist/nat/nat-store.js +124 -29
- package/dist/nat/nat-store.js.map +1 -1
- package/dist/nat/network-mode.d.ts +19 -0
- package/dist/nat/network-mode.js +32 -0
- package/dist/nat/network-mode.js.map +1 -0
- package/dist/nat/port-mapper.d.ts +83 -79
- package/dist/nat/port-mapper.js +148 -189
- package/dist/nat/port-mapper.js.map +1 -1
- package/dist/nat/reachability.d.ts +32 -15
- package/dist/nat/reachability.js +110 -22
- package/dist/nat/reachability.js.map +1 -1
- package/dist/nat/types.d.ts +98 -27
- package/dist/nat/types.js +8 -5
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +32 -74
- package/dist/orchestrator/host-process-orchestrator.js +84 -233
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/{installer/identity.d.ts → orchestrator/identity-file.d.ts} +5 -5
- package/dist/{installer/identity.js → orchestrator/identity-file.js} +6 -6
- package/dist/orchestrator/identity-file.js.map +1 -0
- package/dist/orchestrator/index.d.ts +2 -3
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/node-identity.d.ts +6 -6
- package/dist/orchestrator/node-identity.js +7 -7
- package/dist/orchestrator/node-identity.js.map +1 -1
- package/dist/orchestrator/port-allocator.d.ts +5 -6
- package/dist/orchestrator/port-allocator.js +9 -11
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +3 -5
- package/dist/orchestrator/state-store.js +2 -10
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +33 -22
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/package-version.d.ts +1 -0
- package/dist/package-version.js +4 -0
- package/dist/package-version.js.map +1 -0
- package/dist/server/error-handler.d.ts +5 -25
- package/dist/server/error-handler.js +11 -74
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/events/types.d.ts +7 -11
- package/dist/server/events/types.js +2 -1
- package/dist/server/events/types.js.map +1 -1
- package/dist/server/index.d.ts +11 -37
- package/dist/server/index.js +30 -48
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/hosted-nodes.d.ts +28 -0
- package/dist/server/routes/hosted-nodes.js +68 -0
- package/dist/server/routes/hosted-nodes.js.map +1 -0
- package/dist/server/routes/nat.d.ts +4 -1
- package/dist/server/routes/nat.js +13 -2
- package/dist/server/routes/nat.js.map +1 -1
- package/dist/server/routes/nodes.d.ts +6 -20
- package/dist/server/routes/nodes.js +7 -86
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +3 -7
- package/dist/server/routes/settings.js +18 -8
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +2 -25
- package/dist/server/routes/status.js +3 -43
- package/dist/server/routes/status.js.map +1 -1
- package/dist/server/server.js +8 -8
- package/dist/server/settings-store.d.ts +2 -2
- package/dist/server/settings-store.js +2 -2
- package/dist/server/static.js +1 -2
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-BHd8CWZA.css +1 -0
- package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
- package/dist/ui/assets/index-Ccr2GjPT.js.map +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/update/index.d.ts +2 -2
- package/dist/update/index.js +2 -2
- package/dist/update/store.d.ts +1 -1
- package/dist/update/store.js +1 -1
- package/dist/update/types.d.ts +1 -1
- package/dist/update/types.js +1 -1
- package/package.json +5 -7
- package/src/__tests__/cli-nat.smoke.test.ts +219 -212
- package/src/__tests__/cli.smoke.test.ts +2 -2
- package/src/__tests__/global-setup.ts +2 -3
- package/src/__tests__/orchestrator-claim-secret.test.ts +111 -0
- package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
- package/src/__tests__/orchestrator-handle-contract.ts +134 -134
- package/src/__tests__/orchestrator-node-identity.test.ts +177 -203
- package/src/__tests__/orchestrator-ports.test.ts +124 -143
- package/src/__tests__/orchestrator-push.test.ts +138 -146
- package/src/__tests__/orchestrator.test.ts +1017 -1063
- package/src/__tests__/ui-access.test.ts +30 -0
- package/src/bin/host.ts +397 -572
- package/src/bin/join-output.ts +120 -0
- package/src/bin/nat-output.ts +188 -0
- package/src/bin/ui-access.ts +50 -0
- package/src/{donation → hosted}/__tests__/fake-orchestrator.test.ts +172 -173
- package/src/{donation → hosted}/__tests__/fake-orchestrator.ts +43 -23
- package/src/hosted/__tests__/hosted-node-service.test.ts +708 -0
- package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -0
- package/src/hosted/__tests__/test-invitation.ts +33 -0
- package/src/hosted/claim-details.ts +103 -0
- package/src/hosted/hosted-node-service.ts +814 -0
- package/src/hosted/hosted-node-store.ts +144 -0
- package/src/hosted/hosted-node-supervisor.ts +439 -0
- package/src/hosted/hosted-node-watcher.ts +288 -0
- package/src/hosted/index.ts +53 -0
- package/src/hosted/node-status.ts +97 -0
- package/src/hosted/types.ts +160 -0
- package/src/index.ts +72 -91
- package/src/installer/__tests__/config.test.ts +15 -40
- package/src/installer/__tests__/installer.smoke.test.ts +184 -263
- package/src/installer/__tests__/wizard.test.ts +25 -13
- package/src/installer/config.ts +201 -175
- package/src/installer/index.ts +252 -327
- package/src/installer/paths.ts +104 -109
- package/src/installer/wizard.ts +24 -26
- package/src/nat/__tests__/address-resolver.test.ts +107 -82
- package/src/nat/__tests__/nat-service.test.ts +608 -515
- package/src/nat/__tests__/nat-store.test.ts +49 -12
- package/src/nat/__tests__/network-mode.test.ts +38 -0
- package/src/nat/__tests__/port-mapper.test.ts +30 -191
- package/src/nat/__tests__/reachability.test.ts +96 -13
- package/src/nat/address-resolver.ts +94 -52
- package/src/nat/address-watch.ts +92 -0
- package/src/nat/ddns/duckdns.ts +5 -1
- package/src/nat/ddns/index.ts +6 -0
- package/src/nat/index.ts +33 -13
- package/src/nat/nat-service.ts +942 -501
- package/src/nat/nat-store.ts +126 -31
- package/src/nat/network-mode.ts +36 -0
- package/src/nat/port-mapper.ts +241 -329
- package/src/nat/reachability.ts +155 -34
- package/src/nat/types.ts +110 -37
- package/src/{installer/__tests__/identity.test.ts → orchestrator/__tests__/identity-file.test.ts} +34 -34
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
- package/src/orchestrator/host-process-orchestrator.ts +1089 -1260
- package/src/{installer/identity.ts → orchestrator/identity-file.ts} +56 -56
- package/src/orchestrator/index.ts +9 -10
- package/src/orchestrator/node-identity.ts +41 -41
- package/src/orchestrator/port-allocator.ts +71 -73
- package/src/orchestrator/state-store.ts +76 -90
- package/src/orchestrator/types.ts +34 -23
- package/src/package-version.ts +4 -0
- package/src/server/__tests__/bus.test.ts +38 -38
- package/src/server/__tests__/error-handler.test.ts +81 -118
- package/src/server/__tests__/fakes.ts +15 -18
- package/src/server/__tests__/hosted-nodes-route.test.ts +161 -0
- package/src/server/__tests__/nodes-route.test.ts +114 -283
- package/src/server/__tests__/publishers.test.ts +193 -180
- package/src/server/__tests__/server.smoke.test.ts +134 -223
- package/src/server/__tests__/settings-route.test.ts +210 -195
- package/src/server/__tests__/sse-route.test.ts +119 -119
- package/src/server/__tests__/status-route.test.ts +82 -121
- package/src/server/error-handler.ts +112 -178
- package/src/server/events/types.ts +20 -24
- package/src/server/index.ts +223 -267
- package/src/server/routes/hosted-nodes.ts +83 -0
- package/src/server/routes/nat.ts +75 -63
- package/src/server/routes/nodes.ts +96 -188
- package/src/server/routes/settings.ts +20 -15
- package/src/server/routes/status.ts +5 -67
- package/src/server/server.ts +8 -8
- package/src/server/settings-store.ts +2 -2
- package/src/server/static.ts +1 -2
- package/src/update/index.ts +2 -2
- package/src/update/store.ts +1 -1
- package/src/update/types.ts +1 -1
- package/dist/auth/duration.d.ts +0 -1
- package/dist/auth/duration.js +0 -32
- package/dist/auth/duration.js.map +0 -1
- package/dist/auth/index.d.ts +0 -11
- package/dist/auth/index.js +0 -9
- package/dist/auth/index.js.map +0 -1
- package/dist/auth/self-label.d.ts +0 -36
- package/dist/auth/self-label.js +0 -57
- package/dist/auth/self-label.js.map +0 -1
- package/dist/auth/trust-circle-store.d.ts +0 -26
- package/dist/auth/trust-circle-store.js +0 -129
- package/dist/auth/trust-circle-store.js.map +0 -1
- package/dist/auth/trust-circle.d.ts +0 -156
- package/dist/auth/trust-circle.js +0 -307
- package/dist/auth/trust-circle.js.map +0 -1
- package/dist/auth/types.d.ts +0 -84
- package/dist/auth/types.js +0 -24
- package/dist/auth/types.js.map +0 -1
- package/dist/donation/donation-service.d.ts +0 -389
- package/dist/donation/donation-service.js +0 -828
- package/dist/donation/donation-service.js.map +0 -1
- package/dist/donation/donation-store.d.ts +0 -51
- package/dist/donation/donation-store.js +0 -152
- package/dist/donation/donation-store.js.map +0 -1
- package/dist/donation/donation-supervisor.js +0 -364
- package/dist/donation/donation-supervisor.js.map +0 -1
- package/dist/donation/grant-service.d.ts +0 -80
- package/dist/donation/grant-service.js +0 -210
- package/dist/donation/grant-service.js.map +0 -1
- package/dist/donation/grant-store.d.ts +0 -38
- package/dist/donation/grant-store.js +0 -135
- package/dist/donation/grant-store.js.map +0 -1
- package/dist/donation/index.d.ts +0 -20
- package/dist/donation/index.js +0 -17
- package/dist/donation/index.js.map +0 -1
- package/dist/donation/types.d.ts +0 -224
- package/dist/donation/types.js +0 -40
- package/dist/donation/types.js.map +0 -1
- package/dist/installer/identity.js.map +0 -1
- package/dist/owner/index.d.ts +0 -11
- package/dist/owner/index.js +0 -11
- package/dist/owner/index.js.map +0 -1
- package/dist/owner/owner-node-client.d.ts +0 -110
- package/dist/owner/owner-node-client.js +0 -184
- package/dist/owner/owner-node-client.js.map +0 -1
- package/dist/server/routes/bootstrap-node-validation.d.ts +0 -115
- package/dist/server/routes/bootstrap-node-validation.js +0 -197
- package/dist/server/routes/bootstrap-node-validation.js.map +0 -1
- package/dist/server/routes/grants-admin.d.ts +0 -29
- package/dist/server/routes/grants-admin.js +0 -58
- package/dist/server/routes/grants-admin.js.map +0 -1
- package/dist/server/routes/grants.d.ts +0 -28
- package/dist/server/routes/grants.js +0 -165
- package/dist/server/routes/grants.js.map +0 -1
- package/dist/server/routes/provision-request-validation.d.ts +0 -45
- package/dist/server/routes/provision-request-validation.js +0 -97
- package/dist/server/routes/provision-request-validation.js.map +0 -1
- package/dist/server/routes/strands.d.ts +0 -20
- package/dist/server/routes/strands.js +0 -38
- package/dist/server/routes/strands.js.map +0 -1
- package/dist/server/routes/trust-circle.d.ts +0 -17
- package/dist/server/routes/trust-circle.js +0 -35
- package/dist/server/routes/trust-circle.js.map +0 -1
- package/dist/strands/index.d.ts +0 -7
- package/dist/strands/index.js +0 -6
- package/dist/strands/index.js.map +0 -1
- package/dist/strands/strand-service.d.ts +0 -50
- package/dist/strands/strand-service.js +0 -103
- package/dist/strands/strand-service.js.map +0 -1
- package/dist/strands/types.d.ts +0 -93
- package/dist/strands/types.js +0 -23
- package/dist/strands/types.js.map +0 -1
- package/dist/ui/assets/index-C6nELNfC.js +0 -23
- package/dist/ui/assets/index-C6nELNfC.js.map +0 -1
- package/dist/ui/assets/index-TtIYSeZO.css +0 -1
- package/src/__tests__/cli-invite.smoke.test.ts +0 -192
- package/src/__tests__/host-owner.smoke.test.ts +0 -123
- package/src/__tests__/orchestrator-owner.test.ts +0 -234
- package/src/__tests__/orchestrator-pin-keys.test.ts +0 -152
- package/src/auth/__tests__/duration.test.ts +0 -28
- package/src/auth/__tests__/self-label.test.ts +0 -85
- package/src/auth/__tests__/trust-circle-integration.test.ts +0 -152
- package/src/auth/__tests__/trust-circle-store.test.ts +0 -115
- package/src/auth/__tests__/trust-circle.test.ts +0 -445
- package/src/auth/duration.ts +0 -33
- package/src/auth/index.ts +0 -21
- package/src/auth/self-label.ts +0 -74
- package/src/auth/trust-circle-store.ts +0 -155
- package/src/auth/trust-circle.ts +0 -395
- package/src/auth/types.ts +0 -99
- package/src/donation/__tests__/donation-service.test.ts +0 -1193
- package/src/donation/__tests__/donation-supervisor.test.ts +0 -548
- package/src/donation/__tests__/grant-service.test.ts +0 -213
- package/src/donation/__tests__/grant-store.test.ts +0 -113
- package/src/donation/donation-service.ts +0 -1033
- package/src/donation/donation-store.ts +0 -176
- package/src/donation/donation-supervisor.ts +0 -411
- package/src/donation/grant-service.ts +0 -252
- package/src/donation/grant-store.ts +0 -152
- package/src/donation/index.ts +0 -65
- package/src/donation/types.ts +0 -260
- package/src/owner/__tests__/owner-node-client.test.ts +0 -194
- package/src/owner/index.ts +0 -12
- package/src/owner/owner-node-client.ts +0 -259
- package/src/server/__tests__/bootstrap-node-validation.test.ts +0 -138
- package/src/server/__tests__/grants-admin-route.test.ts +0 -171
- package/src/server/__tests__/grants-route.test.ts +0 -394
- package/src/server/__tests__/strands-route.test.ts +0 -234
- package/src/server/routes/bootstrap-node-validation.ts +0 -206
- package/src/server/routes/grants-admin.ts +0 -74
- package/src/server/routes/grants.ts +0 -193
- package/src/server/routes/provision-request-validation.ts +0 -105
- package/src/server/routes/strands.ts +0 -54
- package/src/server/routes/trust-circle.ts +0 -49
- package/src/strands/__tests__/strand-service.test.ts +0 -165
- package/src/strands/index.ts +0 -14
- package/src/strands/strand-service.ts +0 -135
- package/src/strands/types.ts +0 -102
package/dist/nat/nat-service.js
CHANGED
|
@@ -2,286 +2,324 @@ import debug from 'debug';
|
|
|
2
2
|
import { NatError, } from './types.js';
|
|
3
3
|
import { NatStore } from './nat-store.js';
|
|
4
4
|
import { ExternalIpDetector } from './external-ip.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { DEFAULT_LEASE_TTL_MS, DEFAULT_REFRESH_MS, GATEWAY_DISCOVERY_TIMEOUT_MS, UpnpPortMapper, } from './port-mapper.js';
|
|
6
|
+
import { evaluateHostReachability, evaluateNodeReachability } from './reachability.js';
|
|
7
7
|
import { DdnsUpdater } from './ddns/updater.js';
|
|
8
8
|
import { getProvider, listProviders } from './ddns/index.js';
|
|
9
|
-
import {
|
|
9
|
+
import { buildPublicAddresses, isPublicIpv4 } from './address-resolver.js';
|
|
10
|
+
import { effectiveNetworkMode, publicInterfaceAddress } from './network-mode.js';
|
|
10
11
|
import { createSecretsStore, ddnsAccount } from './secrets/index.js';
|
|
11
|
-
import {
|
|
12
|
+
import { AddressWatch } from './address-watch.js';
|
|
12
13
|
const log = debug('cadre:host:nat-service');
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
/**
|
|
15
|
+
* How long a node may sit `stopped` before its mappings are released. Longer
|
|
16
|
+
* than the hosted-node supervisor's whole respawn backoff (150 s), so a crash
|
|
17
|
+
* followed by a respawn keeps its mapping.
|
|
18
|
+
*/
|
|
19
|
+
export const NAT_UNMAP_GRACE_MS = 3 * 60000;
|
|
20
|
+
/** The backstop reconcile cadence; state-change events trigger passes between ticks. */
|
|
21
|
+
export const NAT_RECONCILE_INTERVAL_MS = 60000;
|
|
22
|
+
/**
|
|
23
|
+
* How often the external IP is re-detected, and how often gateway discovery is
|
|
24
|
+
* retried while UPnP is on and no router has answered.
|
|
25
|
+
*/
|
|
26
|
+
export const NAT_IP_REDETECT_INTERVAL_MS = 5 * 60000;
|
|
27
|
+
const PORT_KINDS = ['tcp', 'ws'];
|
|
28
|
+
function noRoute(internalPort) {
|
|
29
|
+
return { internalPort, externalPort: null, source: null, leaseExpiresAt: null, error: null };
|
|
30
|
+
}
|
|
31
|
+
/** Public mode: the port is reached at its own number, with nothing to map. */
|
|
32
|
+
function directRoute(internalPort) {
|
|
33
|
+
return { internalPort, externalPort: internalPort, source: 'direct', leaseExpiresAt: null, error: null };
|
|
34
|
+
}
|
|
35
|
+
function manualRoute(internalPort, externalPort) {
|
|
36
|
+
return { internalPort, externalPort, source: 'manual', leaseExpiresAt: null, error: null };
|
|
37
|
+
}
|
|
38
|
+
function portsOf(ports) {
|
|
39
|
+
// A handle read back from an older `state.json` lacks `ws` whatever the type says.
|
|
40
|
+
const ws = typeof ports.ws === 'number' ? ports.ws : null;
|
|
41
|
+
return { tcp: ports.p2p, ws };
|
|
42
|
+
}
|
|
43
|
+
function samePorts(a, b) {
|
|
44
|
+
return a.tcp === b.tcp && a.ws === b.ws;
|
|
45
|
+
}
|
|
46
|
+
function errorMessage(err) {
|
|
47
|
+
return err instanceof Error ? err.message : String(err);
|
|
20
48
|
}
|
|
21
49
|
/**
|
|
22
|
-
* NatService —
|
|
23
|
-
* and
|
|
50
|
+
* NatService — one mapping table for every node the host runs, keyed by node
|
|
51
|
+
* id, with a route per port (TCP and WebSocket); external-IP detection; DDNS;
|
|
52
|
+
* and the public addresses a node announces.
|
|
24
53
|
*
|
|
25
54
|
* Lifecycle:
|
|
26
|
-
* - `start()`: load settings,
|
|
27
|
-
* external IP,
|
|
28
|
-
* - `stop()`:
|
|
29
|
-
* - `getStatus()`:
|
|
30
|
-
* - `
|
|
31
|
-
*
|
|
55
|
+
* - `start()`: load settings, discover the router (bounded), detect the
|
|
56
|
+
* external IP, start DDNS, then map the running nodes in the background.
|
|
57
|
+
* - `stop()`: clear timers only — mappings outlive the host process.
|
|
58
|
+
* - `getStatus()`: snapshot from cached state (cheap).
|
|
59
|
+
* - `publicAddressesFor(nodeId, ports)`: a node's public multiaddrs, also
|
|
60
|
+
* from cached state, predicting the identity mapping for a port that has
|
|
61
|
+
* no route yet.
|
|
62
|
+
* - `onNodeAddressesStale(listener)`: asked to restart a running node whose
|
|
63
|
+
* announced addresses no longer match `publicAddressesFor`, checked after
|
|
64
|
+
* every pass and every settings write (see `AddressWatch`).
|
|
32
65
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
66
|
+
* One reconcile pass against the node source behind three triggers (start,
|
|
67
|
+
* node state change, a 1-minute timer), plus one renewal pass for every lease
|
|
68
|
+
* every `DEFAULT_REFRESH_MS`. Passes are serialized on one promise tail, so a
|
|
69
|
+
* state change during a timer pass can neither map a port twice nor unmap a
|
|
70
|
+
* port the other pass is mapping.
|
|
71
|
+
*
|
|
72
|
+
* NOTE: this file holds the settings/DDNS glue, the mapping table and the status
|
|
73
|
+
* assembly together (about 890 lines; the stale-address check lives in
|
|
74
|
+
* `address-watch.ts` for that reason); when the next capability lands here, move
|
|
75
|
+
* the mapping table (`NodeEntry` through `releaseUpnpRoutes`) into its own module.
|
|
35
76
|
*/
|
|
36
77
|
export class NatService {
|
|
37
78
|
constructor(opts) {
|
|
38
|
-
|
|
39
|
-
this.portService = null;
|
|
79
|
+
this.detector = null;
|
|
40
80
|
this.ddnsUpdater = null;
|
|
81
|
+
this.gateway = { found: false, lanAddress: null, routerExternalIp: null, lastError: null };
|
|
82
|
+
/** The public IPv4 an interface holds (`publicInterfaceAddress`), re-read at start and on every probe. */
|
|
83
|
+
this.publicInterfaceIp = null;
|
|
41
84
|
this.latestIp = null;
|
|
42
85
|
this.lastTestedAt = null;
|
|
86
|
+
this.table = new Map();
|
|
43
87
|
this.started = false;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
this.
|
|
88
|
+
this.tail = Promise.resolve();
|
|
89
|
+
/** True while an event-triggered pass is queued and not yet started (crash storms coalesce into it). */
|
|
90
|
+
this.eventPassQueued = false;
|
|
91
|
+
this.timers = [];
|
|
92
|
+
this.unsubscribe = null;
|
|
93
|
+
this.changeListeners = new Set();
|
|
94
|
+
this.lastSignature = null;
|
|
95
|
+
this.nodeSource = opts.nodeSource;
|
|
47
96
|
this.store = opts.store ?? new NatStore(opts.rootDir);
|
|
48
97
|
this.nowFn = opts.now ?? (() => new Date());
|
|
49
98
|
this.fetchImpl = opts.fetch ?? fetch;
|
|
99
|
+
this.portMapper = opts.portMapper ?? new UpnpPortMapper();
|
|
100
|
+
this.leaseTtlMs = opts.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
50
101
|
this.secretsStore = opts.secretsStore ?? null;
|
|
51
|
-
this.portMapper = opts.portMapper ?? null;
|
|
52
102
|
this.injectedDetector = opts.externalIpDetector ?? null;
|
|
53
103
|
this.secretsRootDir = opts.rootDir;
|
|
54
|
-
this.
|
|
55
|
-
this.initialPushTimeoutMs = opts.initialPushTimeoutMs ?? 15000;
|
|
104
|
+
this.readInterfaces = opts.publicInterfaceAddress ?? (() => publicInterfaceAddress());
|
|
56
105
|
this.currentSettings = this.store.load();
|
|
106
|
+
this.addressWatch = new AddressWatch(() => this.nowFn().getTime());
|
|
57
107
|
}
|
|
58
|
-
|
|
108
|
+
// --- lifecycle ---
|
|
109
|
+
/**
|
|
110
|
+
* Discover the router and detect the external IP (both awaited, both
|
|
111
|
+
* bounded), start DDNS, then map re-attached running nodes in the background
|
|
112
|
+
* so the management API comes up promptly. Idempotent.
|
|
113
|
+
*/
|
|
59
114
|
async start() {
|
|
60
115
|
if (this.started)
|
|
61
116
|
return;
|
|
62
117
|
this.started = true;
|
|
63
|
-
// 1. Secrets store (lazy if not injected).
|
|
64
118
|
if (!this.secretsStore) {
|
|
65
119
|
this.secretsStore = await createSecretsStore(this.secretsRootDir);
|
|
66
120
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
121
|
+
this.publicInterfaceIp = this.readInterfaces();
|
|
122
|
+
log('network mode %s (setting %s, public interface address %s)', this.networkMode(), this.currentSettings.network, this.publicInterfaceIp ?? 'none');
|
|
123
|
+
if (this.usesUpnp())
|
|
124
|
+
await this.discoverGateway();
|
|
70
125
|
await this.detectIp();
|
|
71
|
-
// 4. DDNS updater.
|
|
72
126
|
this.ddnsUpdater = new DdnsUpdater({
|
|
73
127
|
settings: this.currentSettings.ddns,
|
|
74
128
|
secrets: this.secretsStore,
|
|
75
|
-
getExternalIp: () => this.
|
|
129
|
+
getExternalIp: () => this.detectedIp(),
|
|
76
130
|
fetch: this.fetchImpl,
|
|
77
131
|
now: this.nowFn,
|
|
78
132
|
});
|
|
79
133
|
await this.ddnsUpdater.start();
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
134
|
+
// Subscribe before the first pass so a spawn during it is not missed.
|
|
135
|
+
this.unsubscribe = this.nodeSource.onStateChange(() => { this.queueEventPass(); });
|
|
136
|
+
this.timers = [
|
|
137
|
+
setInterval(() => { this.sweep('reconcile', () => this.reconcile()); }, NAT_RECONCILE_INTERVAL_MS),
|
|
138
|
+
setInterval(() => { this.sweep('renewal', () => this.renewMappings()); }, DEFAULT_REFRESH_MS),
|
|
139
|
+
setInterval(() => { this.sweep('probe', () => this.redetect()); }, NAT_IP_REDETECT_INTERVAL_MS),
|
|
140
|
+
];
|
|
141
|
+
for (const t of this.timers)
|
|
142
|
+
t.unref();
|
|
143
|
+
this.sweep('startup', () => this.reconcile());
|
|
83
144
|
}
|
|
84
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Clear timers and release client resources. Idempotent.
|
|
147
|
+
*
|
|
148
|
+
* NOTE: deliberately unmaps nothing. Hosted children are detached and keep
|
|
149
|
+
* running across a host restart (an update restart included), so their
|
|
150
|
+
* mappings must outlive this process; they expire on their own within the
|
|
151
|
+
* lease TTL if the host stays down, and the next `start()` re-maps the
|
|
152
|
+
* re-attached nodes (re-mapping the same internal port is idempotent on the
|
|
153
|
+
* router).
|
|
154
|
+
*/
|
|
85
155
|
async stop() {
|
|
86
156
|
if (!this.started)
|
|
87
157
|
return;
|
|
88
158
|
this.started = false;
|
|
159
|
+
for (const t of this.timers)
|
|
160
|
+
clearInterval(t);
|
|
161
|
+
this.timers = [];
|
|
162
|
+
this.unsubscribe?.();
|
|
163
|
+
this.unsubscribe = null;
|
|
89
164
|
try {
|
|
90
165
|
this.ddnsUpdater?.stop();
|
|
91
166
|
}
|
|
92
167
|
catch (err) {
|
|
93
|
-
log('ddns stop failed: %s', err
|
|
168
|
+
log('ddns stop failed: %s', errorMessage(err));
|
|
94
169
|
}
|
|
95
170
|
this.ddnsUpdater = null;
|
|
96
171
|
try {
|
|
97
|
-
await this.
|
|
172
|
+
await this.portMapper.stop();
|
|
98
173
|
}
|
|
99
174
|
catch (err) {
|
|
100
|
-
log('port
|
|
175
|
+
log('port mapper stop failed: %s', errorMessage(err));
|
|
101
176
|
}
|
|
102
|
-
this.portService = null;
|
|
103
177
|
}
|
|
178
|
+
// --- reads ---
|
|
104
179
|
/** Cheap read of the current snapshot. */
|
|
105
180
|
getStatus() {
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
hostname: this.currentSettings.ddns.hostname,
|
|
111
|
-
externallyManaged: this.currentSettings.ddns.externallyManaged,
|
|
112
|
-
lastUpdateAt: null,
|
|
113
|
-
lastUpdateOk: null,
|
|
114
|
-
lastError: null,
|
|
115
|
-
};
|
|
181
|
+
const cgnatDetected = this.cgnatDetected();
|
|
182
|
+
const nodes = [...this.table.values()]
|
|
183
|
+
.sort((a, b) => a.nodeId.localeCompare(b.nodeId))
|
|
184
|
+
.map((entry) => this.describeNode(entry, cgnatDetected));
|
|
116
185
|
return {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
mappingLeaseExpiresAt: portState.leaseExpiresAt?.toISOString() ?? null,
|
|
122
|
-
externalIp,
|
|
186
|
+
network: { setting: this.currentSettings.network, mode: this.networkMode(), publicInterfaceIp: this.publicInterfaceIp },
|
|
187
|
+
upnpEnabled: this.currentSettings.upnpEnabled,
|
|
188
|
+
gateway: { ...this.gateway, routerExternalIp: this.latestIp?.routerIp ?? null },
|
|
189
|
+
externalIp: this.detectedIp(),
|
|
123
190
|
externalIpDetectedAt: this.latestIp?.detectedAt.toISOString() ?? null,
|
|
124
|
-
cgnatDetected
|
|
125
|
-
directReachability:
|
|
126
|
-
portMode: portState.mode,
|
|
127
|
-
cgnatDetected: this.latestIp?.cgnat ?? false,
|
|
128
|
-
}),
|
|
191
|
+
cgnatDetected,
|
|
192
|
+
directReachability: evaluateHostReachability(nodes, cgnatDetected),
|
|
129
193
|
lastTestedAt: this.lastTestedAt?.toISOString() ?? null,
|
|
130
|
-
ddns,
|
|
194
|
+
ddns: this.ddnsStatus(),
|
|
195
|
+
nodes,
|
|
131
196
|
};
|
|
132
197
|
}
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
await this.portService.refresh().catch((err) => log('refresh err: %s', err.message));
|
|
137
|
-
}
|
|
138
|
-
await this.detectIp();
|
|
139
|
-
this.lastTestedAt = this.nowFn();
|
|
140
|
-
// Best-effort DDNS push when the IP just changed.
|
|
141
|
-
if (this.ddnsUpdater) {
|
|
142
|
-
await this.ddnsUpdater.forceUpdate().catch((err) => log('ddns forceUpdate err: %s', err.message));
|
|
143
|
-
}
|
|
144
|
-
// Reachability/IP may have changed → invite addresses may have changed.
|
|
145
|
-
await this.fireAddressesChanged();
|
|
146
|
-
return this.getStatus();
|
|
198
|
+
/** Current settings (cheap read; for handlers to surface to UI). */
|
|
199
|
+
getSettings() {
|
|
200
|
+
return this.currentSettings;
|
|
147
201
|
}
|
|
148
202
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
203
|
+
* The public multiaddrs for a node, from cached state. A port with no route
|
|
204
|
+
* yet is predicted to land on the identity mapping when UPnP is enabled and
|
|
205
|
+
* a gateway was discovered — callers ask at spawn time, before the mapping
|
|
206
|
+
* for a brand-new node exists. A port whose mapping attempt failed is not
|
|
207
|
+
* predicted.
|
|
152
208
|
*/
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
catch (err) {
|
|
161
|
-
if (err instanceof OwnerNodeUnavailableError) {
|
|
162
|
-
throw new NatError('node_unavailable', `Owner node unavailable: ${err.message}`);
|
|
163
|
-
}
|
|
164
|
-
throw err;
|
|
165
|
-
}
|
|
166
|
-
// A reachable node that hasn't yet established its libp2p peer ID reports
|
|
167
|
-
// an empty peerId. Building addresses now would mint malformed `…/p2p/`
|
|
168
|
-
// suffixes (and push them to the node), so treat it as not-ready.
|
|
169
|
-
if (!peerId) {
|
|
170
|
-
throw new NatError('node_unavailable', 'Owner node has no peer ID yet (not ready)');
|
|
171
|
-
}
|
|
172
|
-
const status = this.getStatus();
|
|
173
|
-
const input = {
|
|
174
|
-
peerId,
|
|
175
|
-
externalPort: status.externalPort,
|
|
176
|
-
ddnsHostname: status.ddns.hostname,
|
|
177
|
-
externalIp: status.externalIp,
|
|
178
|
-
reachable: status.directReachability === 'reachable',
|
|
179
|
-
libp2pAddrs,
|
|
180
|
-
};
|
|
181
|
-
return buildInviteAddresses(input);
|
|
209
|
+
publicAddressesFor(nodeId, ports) {
|
|
210
|
+
const pair = portsOf(ports);
|
|
211
|
+
const entry = this.table.get(nodeId);
|
|
212
|
+
const tcp = this.routeOrPrediction(nodeId, 'tcp', pair.tcp, entry);
|
|
213
|
+
const ws = pair.ws === null ? null : this.routeOrPrediction(nodeId, 'ws', pair.ws, entry);
|
|
214
|
+
return this.addressesFor(tcp, ws);
|
|
182
215
|
}
|
|
183
216
|
/**
|
|
184
|
-
* Register a listener fired
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
* unsubscribe fn.
|
|
217
|
+
* Register a listener fired whenever a node's routes, the gateway, the
|
|
218
|
+
* external IP, the CGNAT flag, the UPnP toggle or the DDNS settings change.
|
|
219
|
+
* Returns an unsubscribe fn.
|
|
188
220
|
*/
|
|
189
|
-
|
|
190
|
-
this.
|
|
191
|
-
return () => { this.
|
|
221
|
+
onChange(listener) {
|
|
222
|
+
this.changeListeners.add(listener);
|
|
223
|
+
return () => { this.changeListeners.delete(listener); };
|
|
192
224
|
}
|
|
193
225
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
226
|
+
* Register a listener asked to restart a running node whose announced addresses
|
|
227
|
+
* (`ManagedNodeInfo.announcedAddrs`) differ from `publicAddressesFor` — at most
|
|
228
|
+
* once per node per `NAT_ADDRESS_RESTART_MIN_INTERVAL_MS`. Returns an unsubscribe fn.
|
|
197
229
|
*/
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return;
|
|
201
|
-
let addresses;
|
|
202
|
-
try {
|
|
203
|
-
addresses = await this.getInviteAddresses();
|
|
204
|
-
}
|
|
205
|
-
catch (err) {
|
|
206
|
-
log('onAddressesChanged skipped: %s', err.message);
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
await this.notifyAddressListeners(addresses);
|
|
230
|
+
onNodeAddressesStale(listener) {
|
|
231
|
+
return this.addressWatch.subscribe(listener);
|
|
210
232
|
}
|
|
211
|
-
/**
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
log('address listener threw: %s', err.message);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
233
|
+
/** List available DDNS providers (UI listing for configuration). */
|
|
234
|
+
listDdnsProviders() {
|
|
235
|
+
return listProviders().map((p) => ({
|
|
236
|
+
id: p.id,
|
|
237
|
+
displayName: p.displayName,
|
|
238
|
+
configFields: p.configFields,
|
|
239
|
+
}));
|
|
221
240
|
}
|
|
241
|
+
// --- passes (public so tests and `testReachability` can drive them) ---
|
|
222
242
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* or the bounded budget elapses (best-effort thereafter). Awaited by start() so
|
|
228
|
-
* the management API — the invite-minting path — does not come up first.
|
|
243
|
+
* One reconcile pass against the node source: map running nodes, release
|
|
244
|
+
* nodes stopped past the grace, forget terminated ones. Serialized against
|
|
245
|
+
* every other pass, so a caller may get a queued pass rather than an
|
|
246
|
+
* immediate one.
|
|
229
247
|
*/
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return;
|
|
233
|
-
const deadline = this.nowFn().getTime() + this.initialPushTimeoutMs;
|
|
234
|
-
for (;;) {
|
|
235
|
-
if (!this.started)
|
|
236
|
-
return; // stop() ran during a retry
|
|
237
|
-
try {
|
|
238
|
-
const addresses = await this.getInviteAddresses();
|
|
239
|
-
await this.notifyAddressListeners(addresses);
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
catch (err) {
|
|
243
|
-
const code = err instanceof NatError ? err.code : undefined;
|
|
244
|
-
// Only retry transient not-ready; surface anything unexpected via log + give up.
|
|
245
|
-
if (code !== 'node_unavailable' || this.nowFn().getTime() >= deadline) {
|
|
246
|
-
log('initial invite-address push not delivered: %s', err.message);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
await sleep(this.initialPushRetryMs);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
248
|
+
reconcile() {
|
|
249
|
+
return this.enqueue(() => this.reconcileOnce({ mapMissing: true }));
|
|
252
250
|
}
|
|
253
|
-
/**
|
|
254
|
-
|
|
255
|
-
|
|
251
|
+
/**
|
|
252
|
+
* One renewal pass: re-request every UPnP-eligible mapping of every running
|
|
253
|
+
* node, failed ones included. Each renewal is isolated; one failure does not
|
|
254
|
+
* stop the pass. A router that answers with another external port updates
|
|
255
|
+
* the route.
|
|
256
|
+
*/
|
|
257
|
+
renewMappings() {
|
|
258
|
+
return this.enqueue(() => this.renewOnce());
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The 5-minute probe pass: re-read the interfaces and re-route every node when
|
|
262
|
+
* the mode `auto` lands on changes; while UPnP is in use and no router has
|
|
263
|
+
* answered, search again and map the running nodes as soon as one does — a
|
|
264
|
+
* host that boots before its network, or a router that comes up later, is
|
|
265
|
+
* found here rather than at the next restart; then re-detect the external IP.
|
|
266
|
+
*/
|
|
267
|
+
async redetect() {
|
|
268
|
+
await this.enqueue(async () => {
|
|
269
|
+
const modeChanged = this.refreshNetworkMode();
|
|
270
|
+
const gatewayWasFound = this.gateway.found;
|
|
271
|
+
if (this.usesUpnp() && !gatewayWasFound)
|
|
272
|
+
await this.discoverGateway();
|
|
273
|
+
if (modeChanged || (!gatewayWasFound && this.gateway.found))
|
|
274
|
+
await this.reconcileOnce({ mapMissing: true });
|
|
275
|
+
});
|
|
276
|
+
await this.detectIp();
|
|
277
|
+
this.publish();
|
|
278
|
+
}
|
|
279
|
+
// --- writes ---
|
|
280
|
+
/** Re-discover the router if needed, re-map everything, re-detect the IP, then return a fresh snapshot. */
|
|
281
|
+
async testReachability() {
|
|
282
|
+
await this.enqueue(async () => {
|
|
283
|
+
this.refreshNetworkMode();
|
|
284
|
+
if (this.usesUpnp() && !this.gateway.found)
|
|
285
|
+
await this.discoverGateway();
|
|
286
|
+
await this.reconcileOnce({ mapMissing: false });
|
|
287
|
+
await this.renewOnce();
|
|
288
|
+
});
|
|
289
|
+
await this.detectIp();
|
|
290
|
+
this.lastTestedAt = this.nowFn();
|
|
291
|
+
if (this.ddnsUpdater) {
|
|
292
|
+
await this.ddnsUpdater.forceUpdate().catch((err) => log('ddns forceUpdate err: %s', errorMessage(err)));
|
|
293
|
+
}
|
|
294
|
+
this.publish();
|
|
295
|
+
return this.getStatus();
|
|
256
296
|
}
|
|
257
297
|
/** Replace settings; persists to disk and reconfigures the runtime. */
|
|
258
298
|
async putSettings(patch) {
|
|
259
299
|
const next = this.store.update(patch);
|
|
260
|
-
const
|
|
261
|
-
const
|
|
262
|
-
next.internalPort !== this.currentSettings.internalPort;
|
|
300
|
+
const wasUsingUpnp = this.usesUpnp();
|
|
301
|
+
const modeBefore = this.networkMode();
|
|
263
302
|
const ddnsChanged = next.ddns.providerId !== this.currentSettings.ddns.providerId ||
|
|
264
303
|
next.ddns.hostname !== this.currentSettings.ddns.hostname ||
|
|
265
304
|
next.ddns.externallyManaged !== this.currentSettings.ddns.externallyManaged ||
|
|
266
305
|
next.ddns.intervalMs !== this.currentSettings.ddns.intervalMs;
|
|
267
306
|
this.currentSettings = next;
|
|
268
307
|
if (this.started) {
|
|
269
|
-
if (upnpChanged || portChanged) {
|
|
270
|
-
try {
|
|
271
|
-
await this.portService?.stop();
|
|
272
|
-
}
|
|
273
|
-
catch { /* ignore */ }
|
|
274
|
-
this.portService = null;
|
|
275
|
-
await this.startPortService();
|
|
276
|
-
}
|
|
277
308
|
if (ddnsChanged && this.ddnsUpdater) {
|
|
278
309
|
await this.ddnsUpdater.updateSettings(next.ddns);
|
|
279
310
|
}
|
|
280
|
-
if (
|
|
281
|
-
//
|
|
282
|
-
|
|
311
|
+
if (this.usesUpnp() !== wasUsingUpnp || this.networkMode() !== modeBefore) {
|
|
312
|
+
// UPnP coming into use (turned on, or leaving public mode) with no gateway known yet:
|
|
313
|
+
// look for one before the pass, so the pass can map. Going out of use: the pass
|
|
314
|
+
// releases every `upnp` route and keeps the manual ones; public mode adds `direct` ones.
|
|
315
|
+
await this.enqueue(async () => {
|
|
316
|
+
if (this.usesUpnp() && !this.gateway.found)
|
|
317
|
+
await this.discoverGateway();
|
|
318
|
+
await this.reconcileOnce({ mapMissing: true });
|
|
319
|
+
});
|
|
283
320
|
}
|
|
284
321
|
}
|
|
322
|
+
this.publish();
|
|
285
323
|
return this.getStatus();
|
|
286
324
|
}
|
|
287
325
|
/**
|
|
@@ -296,7 +334,6 @@ export class NatService {
|
|
|
296
334
|
if (!this.secretsStore) {
|
|
297
335
|
throw new NatError('secrets_unavailable', 'secrets store not initialised; call start() first');
|
|
298
336
|
}
|
|
299
|
-
// Persist secrets.
|
|
300
337
|
for (const field of provider.configFields) {
|
|
301
338
|
if (!field.secret)
|
|
302
339
|
continue;
|
|
@@ -306,84 +343,460 @@ export class NatService {
|
|
|
306
343
|
}
|
|
307
344
|
await this.secretsStore.set(ddnsAccount(provider.id, field.key), value);
|
|
308
345
|
}
|
|
309
|
-
// Update nat.json.
|
|
310
346
|
const externallyManaged = body.externallyManaged ?? false;
|
|
311
347
|
return await this.putSettings({
|
|
312
348
|
ddns: {
|
|
313
349
|
providerId: provider.id,
|
|
314
|
-
hostname: body.hostname,
|
|
350
|
+
hostname: provider.qualifyHostname(body.hostname),
|
|
315
351
|
externallyManaged,
|
|
316
352
|
intervalMs: this.currentSettings.ddns.intervalMs,
|
|
317
353
|
},
|
|
318
354
|
});
|
|
319
355
|
}
|
|
320
|
-
/**
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
356
|
+
/**
|
|
357
|
+
* Record the external ports the user forwarded by hand for one node (`null`
|
|
358
|
+
* clears a port). A port that became manual releases its UPnP mapping; a
|
|
359
|
+
* port whose entry was cleared is re-requested over UPnP.
|
|
360
|
+
*/
|
|
361
|
+
async putForward(nodeId, patch) {
|
|
362
|
+
validateForwardPatch(patch);
|
|
363
|
+
if (!this.nodeSource.listNodes().some((n) => n.id === nodeId)) {
|
|
364
|
+
throw new NatError('unknown_node', `no hosted node with id "${nodeId}"`);
|
|
365
|
+
}
|
|
366
|
+
this.currentSettings = this.store.setForward(nodeId, patch);
|
|
367
|
+
if (this.started)
|
|
368
|
+
await this.reconcile();
|
|
369
|
+
this.publish();
|
|
370
|
+
return this.getStatus();
|
|
371
|
+
}
|
|
372
|
+
// --- reconcile internals ---
|
|
373
|
+
enqueue(fn) {
|
|
374
|
+
const next = this.tail.then(fn, fn);
|
|
375
|
+
// The stored tail swallows outcomes — a failed pass must not reject the
|
|
376
|
+
// next caller's wait, only sequence after it.
|
|
377
|
+
this.tail = next.then(() => undefined, () => undefined);
|
|
378
|
+
return next;
|
|
379
|
+
}
|
|
380
|
+
/** Fire-and-forget pass for the timer / event / startup triggers. */
|
|
381
|
+
sweep(trigger, run) {
|
|
382
|
+
void run().catch((err) => { log('%s pass failed: %s', trigger, errorMessage(err)); });
|
|
327
383
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
384
|
+
/**
|
|
385
|
+
* One pass per burst of events: an event while a pass is queued joins it,
|
|
386
|
+
* and an event during a running pass queues exactly one more, so a node that
|
|
387
|
+
* spawned after the running pass listed the nodes is mapped by the next pass
|
|
388
|
+
* rather than by the timer.
|
|
389
|
+
*/
|
|
390
|
+
queueEventPass() {
|
|
391
|
+
if (this.eventPassQueued)
|
|
332
392
|
return;
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
393
|
+
this.eventPassQueued = true;
|
|
394
|
+
void this.enqueue(() => {
|
|
395
|
+
this.eventPassQueued = false;
|
|
396
|
+
return this.reconcileOnce({ mapMissing: true });
|
|
397
|
+
}).catch((err) => { log('event pass failed: %s', errorMessage(err)); });
|
|
398
|
+
}
|
|
399
|
+
async reconcileOnce(opts) {
|
|
400
|
+
if (!this.started)
|
|
401
|
+
return;
|
|
402
|
+
const listed = new Map(this.nodeSource.listNodes().map((n) => [n.id, n]));
|
|
403
|
+
await this.forgetMissingNodes(listed);
|
|
404
|
+
for (const node of listed.values()) {
|
|
405
|
+
if (!this.started)
|
|
406
|
+
break;
|
|
336
407
|
try {
|
|
337
|
-
|
|
338
|
-
this.portMapper = mapper;
|
|
408
|
+
await this.reconcileNode(node, opts.mapMissing);
|
|
339
409
|
}
|
|
340
410
|
catch (err) {
|
|
341
|
-
|
|
342
|
-
|
|
411
|
+
// One bad node must never end the pass — the others still need their routes.
|
|
412
|
+
log('reconcile of node %s failed: %s', node.id, errorMessage(err));
|
|
343
413
|
}
|
|
344
414
|
}
|
|
345
|
-
this.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
415
|
+
this.publish();
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* A node no longer listed was terminated (`removeContainer`): release its
|
|
419
|
+
* mappings at once and drop its manual forward. A forward left behind for a
|
|
420
|
+
* node that vanished while the host was down goes the same way.
|
|
421
|
+
*/
|
|
422
|
+
async forgetMissingNodes(listed) {
|
|
423
|
+
for (const [nodeId, entry] of this.table) {
|
|
424
|
+
if (listed.has(nodeId))
|
|
425
|
+
continue;
|
|
426
|
+
await this.releaseUpnpRoutes(entry);
|
|
427
|
+
this.table.delete(nodeId);
|
|
428
|
+
}
|
|
429
|
+
for (const nodeId of Object.keys(this.currentSettings.forwards)) {
|
|
430
|
+
if (listed.has(nodeId))
|
|
431
|
+
continue;
|
|
432
|
+
this.currentSettings = this.store.deleteForward(nodeId);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async reconcileNode(node, mapMissing) {
|
|
436
|
+
const ports = portsOf(node.ports);
|
|
437
|
+
let entry = this.table.get(node.id);
|
|
438
|
+
if (entry && !samePorts(entry.ports, ports)) {
|
|
439
|
+
// Ports changed across a respawn: the old mappings point at ports nothing binds.
|
|
440
|
+
await this.releaseUpnpRoutes(entry);
|
|
441
|
+
entry = undefined;
|
|
442
|
+
}
|
|
443
|
+
if (!entry) {
|
|
444
|
+
entry = {
|
|
445
|
+
nodeId: node.id,
|
|
446
|
+
running: false,
|
|
447
|
+
stoppedSince: null,
|
|
448
|
+
ports,
|
|
449
|
+
routes: { tcp: noRoute(ports.tcp), ws: ports.ws === null ? null : noRoute(ports.ws) },
|
|
450
|
+
};
|
|
451
|
+
this.table.set(node.id, entry);
|
|
452
|
+
}
|
|
453
|
+
if (node.status === 'running') {
|
|
454
|
+
entry.running = true;
|
|
455
|
+
entry.stoppedSince = null;
|
|
456
|
+
await this.reconcileRoutes(entry, mapMissing);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
entry.running = false;
|
|
460
|
+
entry.stoppedSince ?? (entry.stoppedSince = this.nowFn().getTime());
|
|
461
|
+
if (this.nowFn().getTime() - entry.stoppedSince >= NAT_UNMAP_GRACE_MS) {
|
|
462
|
+
await this.releaseUpnpRoutes(entry);
|
|
463
|
+
}
|
|
464
|
+
// Manual forwards still follow the settings while the node is down; no router call is needed.
|
|
465
|
+
await this.reconcileRoutes(entry, false);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Bring one node's routes in line with the settings: manual wins over UPnP;
|
|
469
|
+
* UPnP off releases every `upnp` route; with `mapMissing`, a port with no
|
|
470
|
+
* route and no recorded failure (or an expired lease) is requested.
|
|
471
|
+
*/
|
|
472
|
+
async reconcileRoutes(entry, mapMissing) {
|
|
473
|
+
for (const kind of this.kindsOf(entry)) {
|
|
474
|
+
const route = entry.routes[kind];
|
|
475
|
+
const forward = this.currentSettings.forwards[entry.nodeId]?.[kind];
|
|
476
|
+
if (forward !== undefined) {
|
|
477
|
+
if (route.source === 'upnp')
|
|
478
|
+
await this.unmapRoute(entry, kind);
|
|
479
|
+
entry.routes[kind] = manualRoute(route.internalPort, forward);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (route.source === 'manual' || route.source === 'direct') {
|
|
483
|
+
entry.routes[kind] = noRoute(route.internalPort);
|
|
484
|
+
}
|
|
485
|
+
if (this.networkMode() === 'public') {
|
|
486
|
+
if (route.source === 'upnp')
|
|
487
|
+
await this.unmapRoute(entry, kind);
|
|
488
|
+
entry.routes[kind] = directRoute(route.internalPort);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (!this.currentSettings.upnpEnabled) {
|
|
492
|
+
if (entry.routes[kind].source === 'upnp')
|
|
493
|
+
await this.unmapRoute(entry, kind);
|
|
494
|
+
entry.routes[kind] = noRoute(route.internalPort);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (!mapMissing || !this.gateway.found)
|
|
498
|
+
continue;
|
|
499
|
+
const current = entry.routes[kind];
|
|
500
|
+
const untried = current.source === null && current.error === null;
|
|
501
|
+
if (untried || this.leaseExpired(current)) {
|
|
502
|
+
await this.mapRoute(entry, kind);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async renewOnce() {
|
|
507
|
+
if (!this.started)
|
|
508
|
+
return;
|
|
509
|
+
if (!this.usesUpnp() || !this.gateway.found)
|
|
510
|
+
return;
|
|
511
|
+
for (const entry of this.table.values()) {
|
|
512
|
+
if (!this.started)
|
|
513
|
+
break;
|
|
514
|
+
if (!entry.running)
|
|
515
|
+
continue;
|
|
516
|
+
for (const kind of this.kindsOf(entry)) {
|
|
517
|
+
if (entry.routes[kind].source !== 'upnp' && entry.routes[kind].source !== null)
|
|
518
|
+
continue;
|
|
519
|
+
await this.mapRoute(entry, kind);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
this.publish();
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Ask the router for one port. A refusal records `error` on that port only.
|
|
526
|
+
*
|
|
527
|
+
* NOTE: a router that stops answering costs each attempt its 10 s timeout, so a
|
|
528
|
+
* pass over N mapped ports can take 10 s × N; passes are serialized, so they queue
|
|
529
|
+
* rather than overlap. If that ever shows, skip ports whose last failure was a
|
|
530
|
+
* timeout until the next renewal pass.
|
|
531
|
+
*/
|
|
532
|
+
async mapRoute(entry, kind) {
|
|
533
|
+
const previous = entry.routes[kind];
|
|
534
|
+
const internalPort = previous.internalPort;
|
|
535
|
+
try {
|
|
536
|
+
const result = await this.portMapper.map({ internalPort, protocol: 'tcp', ttlMs: this.leaseTtlMs });
|
|
537
|
+
entry.routes[kind] = {
|
|
538
|
+
internalPort,
|
|
539
|
+
externalPort: result.externalPort,
|
|
540
|
+
source: 'upnp',
|
|
541
|
+
leaseExpiresAt: result.leaseExpiresAt.toISOString(),
|
|
542
|
+
error: null,
|
|
543
|
+
};
|
|
544
|
+
log('mapped %s %s port %d → external %d', entry.nodeId, kind, internalPort, result.externalPort);
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
const message = errorMessage(err);
|
|
548
|
+
// A failed renewal of a live mapping keeps the route: the router still
|
|
549
|
+
// holds it until the lease expires, and `leaseExpired` drops it then.
|
|
550
|
+
const live = previous.source === 'upnp' && !this.leaseExpired(previous);
|
|
551
|
+
entry.routes[kind] = live ? { ...previous, error: message } : { ...noRoute(internalPort), error: message };
|
|
552
|
+
log('mapping %s %s port %d failed: %s', entry.nodeId, kind, internalPort, message);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
async unmapRoute(entry, kind) {
|
|
556
|
+
const route = entry.routes[kind];
|
|
557
|
+
await this.portMapper.unmap(route.internalPort, 'tcp');
|
|
558
|
+
entry.routes[kind] = noRoute(route.internalPort);
|
|
559
|
+
log('unmapped %s %s port %d', entry.nodeId, kind, route.internalPort);
|
|
560
|
+
}
|
|
561
|
+
async releaseUpnpRoutes(entry) {
|
|
562
|
+
for (const kind of this.kindsOf(entry)) {
|
|
563
|
+
if (entry.routes[kind].source === 'upnp')
|
|
564
|
+
await this.unmapRoute(entry, kind);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
kindsOf(entry) {
|
|
568
|
+
return PORT_KINDS.filter((kind) => entry.routes[kind] !== null);
|
|
569
|
+
}
|
|
570
|
+
leaseExpired(route) {
|
|
571
|
+
if (route.source !== 'upnp' || !route.leaseExpiresAt)
|
|
572
|
+
return false;
|
|
573
|
+
return Date.parse(route.leaseExpiresAt) <= this.nowFn().getTime();
|
|
574
|
+
}
|
|
575
|
+
/** A UPnP route whose lease ran out reads as no route, keeping any recorded failure. */
|
|
576
|
+
effectiveRoute(route) {
|
|
577
|
+
if (!this.leaseExpired(route))
|
|
578
|
+
return route;
|
|
579
|
+
return { ...noRoute(route.internalPort), error: route.error };
|
|
580
|
+
}
|
|
581
|
+
// --- gateway / external IP ---
|
|
582
|
+
async discoverGateway() {
|
|
583
|
+
try {
|
|
584
|
+
const info = await this.portMapper.discover();
|
|
585
|
+
this.gateway = info
|
|
586
|
+
? { found: true, lanAddress: info.lanAddress, routerExternalIp: null, lastError: null }
|
|
587
|
+
: {
|
|
588
|
+
found: false,
|
|
589
|
+
lanAddress: null,
|
|
590
|
+
routerExternalIp: null,
|
|
591
|
+
lastError: `no UPnP gateway answered within ${GATEWAY_DISCOVERY_TIMEOUT_MS / 1000} s`,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
this.gateway = { found: false, lanAddress: null, routerExternalIp: null, lastError: errorMessage(err) };
|
|
596
|
+
}
|
|
597
|
+
if (!this.gateway.found)
|
|
598
|
+
log('gateway discovery: %s', this.gateway.lastError);
|
|
352
599
|
}
|
|
600
|
+
/**
|
|
601
|
+
* Detect the external IP. A detection that finds nothing (or throws), or that
|
|
602
|
+
* lost the public echo the previous one had, keeps the previous result: one
|
|
603
|
+
* failed probe must not drop every node's public address, and a router-only
|
|
604
|
+
* answer must not flip the CGNAT flag the two probes had agreed on.
|
|
605
|
+
*/
|
|
353
606
|
async detectIp() {
|
|
354
|
-
// If the caller injected a detector (typically a test stub), use it as-is —
|
|
355
|
-
// don't second-guess them by stitching in the port mapper's router probe.
|
|
356
|
-
const detector = this.injectedDetector ?? (this.portMapper
|
|
357
|
-
? new ExternalIpDetector({
|
|
358
|
-
fetch: this.fetchImpl,
|
|
359
|
-
now: this.nowFn,
|
|
360
|
-
routerProbe: async () => this.portMapper.externalIp(),
|
|
361
|
-
})
|
|
362
|
-
: new ExternalIpDetector({
|
|
363
|
-
fetch: this.fetchImpl,
|
|
364
|
-
now: this.nowFn,
|
|
365
|
-
}));
|
|
366
607
|
try {
|
|
367
|
-
|
|
608
|
+
const result = await this.ipDetector().detect();
|
|
609
|
+
if (this.knowsLessThanPrevious(result)) {
|
|
610
|
+
log('external IP detection found less than before; keeping the previous result');
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
this.latestIp = result;
|
|
368
614
|
}
|
|
369
615
|
catch (err) {
|
|
370
|
-
log('detectIp failed: %s', err
|
|
616
|
+
log('detectIp failed: %s', errorMessage(err));
|
|
371
617
|
}
|
|
372
618
|
}
|
|
373
|
-
|
|
619
|
+
knowsLessThanPrevious(result) {
|
|
620
|
+
if (result.publicIp === null && result.routerIp === null)
|
|
621
|
+
return true;
|
|
622
|
+
return result.publicIp === null && this.latestIp?.publicIp != null;
|
|
623
|
+
}
|
|
624
|
+
ipDetector() {
|
|
625
|
+
if (this.injectedDetector)
|
|
626
|
+
return this.injectedDetector;
|
|
627
|
+
this.detector ?? (this.detector = new ExternalIpDetector({
|
|
628
|
+
fetch: this.fetchImpl,
|
|
629
|
+
now: this.nowFn,
|
|
630
|
+
routerProbe: async () => (this.gateway.found ? await this.portMapper.externalIp() : null),
|
|
631
|
+
}));
|
|
632
|
+
return this.detector;
|
|
633
|
+
}
|
|
634
|
+
/** The detected external IP for display and DDNS: the public probe's, else the router's. */
|
|
635
|
+
detectedIp() {
|
|
636
|
+
return this.latestIp?.publicIp ?? this.latestIp?.routerIp ?? null;
|
|
637
|
+
}
|
|
638
|
+
/** The IP used in `/ip4/` addresses: only a public IPv4 counts. Public mode prefers the interface's own. */
|
|
639
|
+
publicIpv4() {
|
|
640
|
+
if (this.networkMode() === 'public' && this.publicInterfaceIp)
|
|
641
|
+
return this.publicInterfaceIp;
|
|
642
|
+
const ip = this.detectedIp();
|
|
643
|
+
return ip && isPublicIpv4(ip) ? ip : null;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Re-read the interfaces (a VPS gains an address, a laptop moves networks); true when that
|
|
647
|
+
* changed the mode in effect, so the caller re-routes every node in the same pass.
|
|
648
|
+
*/
|
|
649
|
+
refreshNetworkMode() {
|
|
650
|
+
const modeBefore = this.networkMode();
|
|
651
|
+
this.publicInterfaceIp = this.readInterfaces();
|
|
652
|
+
const changed = this.networkMode() !== modeBefore;
|
|
653
|
+
if (changed)
|
|
654
|
+
log('network mode changed to %s (public interface address %s)', this.networkMode(), this.publicInterfaceIp ?? 'none');
|
|
655
|
+
return changed;
|
|
656
|
+
}
|
|
657
|
+
/** The mode in effect: the `network` setting, or what `auto` reads from the interfaces. */
|
|
658
|
+
networkMode() {
|
|
659
|
+
return effectiveNetworkMode(this.currentSettings.network, this.publicInterfaceIp);
|
|
660
|
+
}
|
|
661
|
+
/** UPnP is asked for nothing in public mode: there is no router to ask. */
|
|
662
|
+
usesUpnp() {
|
|
663
|
+
return this.currentSettings.upnpEnabled && this.networkMode() === 'lan';
|
|
664
|
+
}
|
|
665
|
+
/** An interface holding the public address rules out carrier-grade NAT, whatever the probes say. */
|
|
666
|
+
cgnatDetected() {
|
|
667
|
+
return this.networkMode() === 'public' ? false : this.latestIp?.cgnat ?? false;
|
|
668
|
+
}
|
|
669
|
+
// --- status assembly ---
|
|
670
|
+
describeNode(entry, cgnatDetected) {
|
|
671
|
+
const tcp = this.effectiveRoute(entry.routes.tcp);
|
|
672
|
+
const ws = entry.routes.ws ? this.effectiveRoute(entry.routes.ws) : null;
|
|
673
|
+
const { verdict, reason } = evaluateNodeReachability({
|
|
674
|
+
tcp,
|
|
675
|
+
ws,
|
|
676
|
+
hostKnown: this.hostKnown(),
|
|
677
|
+
cgnatDetected,
|
|
678
|
+
upnpEnabled: this.usesUpnp(),
|
|
679
|
+
gatewayFound: this.gateway.found,
|
|
680
|
+
lanAddress: this.gateway.lanAddress,
|
|
681
|
+
});
|
|
374
682
|
return {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
683
|
+
nodeId: entry.nodeId,
|
|
684
|
+
running: entry.running,
|
|
685
|
+
verdict,
|
|
686
|
+
reason,
|
|
687
|
+
tcp,
|
|
688
|
+
ws,
|
|
689
|
+
publicAddrs: this.addressesFor(tcp, ws),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
hostKnown() {
|
|
693
|
+
return this.currentSettings.ddns.hostname !== null || this.publicIpv4() !== null;
|
|
694
|
+
}
|
|
695
|
+
addressesFor(tcp, ws) {
|
|
696
|
+
return buildPublicAddresses({
|
|
697
|
+
ddnsHostname: this.currentSettings.ddns.hostname,
|
|
698
|
+
externalIp: this.publicIpv4(),
|
|
699
|
+
cgnatDetected: this.cgnatDetected(),
|
|
700
|
+
tcp,
|
|
701
|
+
ws,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
routeOrPrediction(nodeId, kind, internalPort, entry) {
|
|
705
|
+
const forward = this.currentSettings.forwards[nodeId]?.[kind];
|
|
706
|
+
if (forward !== undefined)
|
|
707
|
+
return manualRoute(internalPort, forward);
|
|
708
|
+
if (this.networkMode() === 'public')
|
|
709
|
+
return directRoute(internalPort);
|
|
710
|
+
const existing = entry?.routes[kind];
|
|
711
|
+
if (existing && existing.internalPort === internalPort) {
|
|
712
|
+
const route = this.effectiveRoute(existing);
|
|
713
|
+
if (route.source !== null || route.error !== null)
|
|
714
|
+
return route;
|
|
715
|
+
}
|
|
716
|
+
if (this.usesUpnp() && this.gateway.found) {
|
|
717
|
+
return { internalPort, externalPort: internalPort, source: 'upnp', leaseExpiresAt: null, error: null };
|
|
718
|
+
}
|
|
719
|
+
return noRoute(internalPort);
|
|
720
|
+
}
|
|
721
|
+
ddnsStatus() {
|
|
722
|
+
return this.ddnsUpdater?.getStatus() ?? {
|
|
723
|
+
providerId: this.currentSettings.ddns.providerId,
|
|
724
|
+
hostname: this.currentSettings.ddns.hostname,
|
|
725
|
+
externallyManaged: this.currentSettings.ddns.externallyManaged,
|
|
726
|
+
lastUpdateAt: null,
|
|
727
|
+
lastUpdateOk: null,
|
|
380
728
|
lastError: null,
|
|
381
729
|
};
|
|
382
730
|
}
|
|
731
|
+
// --- change notification ---
|
|
732
|
+
/**
|
|
733
|
+
* The end of every pass and every settings write: tell the change listeners
|
|
734
|
+
* when the snapshot moved, then ask for a restart of every running node whose
|
|
735
|
+
* announced addresses went stale. Running the check here rather than only on a
|
|
736
|
+
* change is what lets a difference deferred by the restart rate limit fire on the
|
|
737
|
+
* first timer pass after the limit ends.
|
|
738
|
+
*/
|
|
739
|
+
publish() {
|
|
740
|
+
this.notifyIfChanged();
|
|
741
|
+
if (!this.started)
|
|
742
|
+
return;
|
|
743
|
+
this.addressWatch.check(this.nodeSource.listNodes(), (node) => this.publicAddressesFor(node.id, node.ports));
|
|
744
|
+
}
|
|
745
|
+
/** Fire the change listeners when the snapshot differs (timestamps aside) from the last one they saw. */
|
|
746
|
+
notifyIfChanged() {
|
|
747
|
+
const snapshot = this.getStatus();
|
|
748
|
+
const signature = signatureOf(snapshot);
|
|
749
|
+
if (signature === this.lastSignature)
|
|
750
|
+
return;
|
|
751
|
+
this.lastSignature = signature;
|
|
752
|
+
for (const listener of this.changeListeners) {
|
|
753
|
+
try {
|
|
754
|
+
listener(snapshot);
|
|
755
|
+
}
|
|
756
|
+
catch (err) {
|
|
757
|
+
log('change listener threw: %s', errorMessage(err));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
/** The snapshot minus the fields that move on their own (timestamps, lease expiry). */
|
|
763
|
+
function signatureOf(s) {
|
|
764
|
+
return JSON.stringify({
|
|
765
|
+
upnpEnabled: s.upnpEnabled,
|
|
766
|
+
gateway: s.gateway,
|
|
767
|
+
externalIp: s.externalIp,
|
|
768
|
+
cgnatDetected: s.cgnatDetected,
|
|
769
|
+
directReachability: s.directReachability,
|
|
770
|
+
ddns: { providerId: s.ddns.providerId, hostname: s.ddns.hostname, externallyManaged: s.ddns.externallyManaged },
|
|
771
|
+
nodes: s.nodes.map((n) => ({
|
|
772
|
+
nodeId: n.nodeId,
|
|
773
|
+
running: n.running,
|
|
774
|
+
verdict: n.verdict,
|
|
775
|
+
tcp: routeSignature(n.tcp),
|
|
776
|
+
ws: n.ws ? routeSignature(n.ws) : null,
|
|
777
|
+
publicAddrs: n.publicAddrs,
|
|
778
|
+
})),
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
function routeSignature(r) {
|
|
782
|
+
return { internalPort: r.internalPort, externalPort: r.externalPort, source: r.source, error: r.error };
|
|
783
|
+
}
|
|
784
|
+
function validateForwardPatch(patch) {
|
|
785
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
786
|
+
throw new NatError('invalid_config', 'a forward patch must be an object with tcp and/or ws');
|
|
787
|
+
}
|
|
788
|
+
for (const kind of PORT_KINDS) {
|
|
789
|
+
const value = patch[kind];
|
|
790
|
+
if (value === undefined || value === null)
|
|
791
|
+
continue;
|
|
792
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
793
|
+
throw new NatError('invalid_config', `${kind} must be an integer 1-65535 or null, got ${String(value)}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
383
796
|
}
|
|
384
797
|
/**
|
|
385
|
-
* Wrap a NatService into the typed handler shape consumed by
|
|
386
|
-
*
|
|
798
|
+
* Wrap a NatService into the typed handler shape consumed by the local UI
|
|
799
|
+
* server. Same shape as `createUpdateHandlers`.
|
|
387
800
|
*/
|
|
388
801
|
export function createNatHandlers(service) {
|
|
389
802
|
return {
|
|
@@ -392,6 +805,7 @@ export function createNatHandlers(service) {
|
|
|
392
805
|
async listDdnsProviders() { return service.listDdnsProviders(); },
|
|
393
806
|
async putDdns(body) { return await service.putDdns(body); },
|
|
394
807
|
async putSettings(body) { return await service.putSettings(body); },
|
|
808
|
+
async putForward(nodeId, patch) { return await service.putForward(nodeId, patch); },
|
|
395
809
|
};
|
|
396
810
|
}
|
|
397
811
|
//# sourceMappingURL=nat-service.js.map
|