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