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