@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
@@ -1,38 +1,94 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
- import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
 
6
- import { NatService, createNatHandlers } from '../nat-service.js';
6
+ import { NatService, NAT_UNMAP_GRACE_MS, type NatNodeSource } from '../nat-service.js';
7
+ import { NAT_ADDRESS_RESTART_MIN_INTERVAL_MS } from '../address-watch.js';
7
8
  import { ExternalIpDetector } from '../external-ip.js';
8
- import type { PortMapper, PortMappingResult } from '../port-mapper.js';
9
+ import { NatError } from '../types.js';
10
+ import type { GatewayInfo, PortMapper, PortMapRequest, PortMapResult } from '../port-mapper.js';
9
11
  import type { SecretsStore } from '../secrets/index.js';
10
12
  import { ddnsAccount } from '../secrets/index.js';
11
- import type { CadreNodeLike } from '../nat-service.js';
12
- import { OwnerNodeUnavailableError } from '../../owner/owner-node-client.js';
13
-
14
- class StubMapper implements PortMapper {
15
- mapCalls = 0;
16
- unmapCalls = 0;
17
- stopCalls = 0;
18
- externalIpValue: string | null = '203.0.113.10';
19
- mapFails = false;
20
- async map(o: { externalPort: number; internalPort: number; protocol?: 'tcp' | 'udp'; ttlMs?: number }): Promise<PortMappingResult> {
21
- this.mapCalls++;
22
- if (this.mapFails) throw new Error('router refused');
23
- return {
24
- mode: 'auto-upnp',
25
- externalIp: this.externalIpValue,
26
- externalPort: o.externalPort,
27
- internalPort: o.internalPort,
28
- protocol: o.protocol ?? 'tcp',
29
- leaseExpiresAt: new Date(Date.now() + (o.ttlMs ?? 60_000)),
13
+ import type { ManagedNodeInfo, NodeStateListener } from '../../orchestrator/types.js';
14
+
15
+ /** A router: maps `internal + offset` (or a `reassigned` port), refuses once `cap` ports are mapped. */
16
+ class FakeMapper implements PortMapper {
17
+ gateway: GatewayInfo | null = { lanAddress: '192.168.1.20', routerHost: '192.168.1.1' };
18
+ cap = Number.POSITIVE_INFINITY;
19
+ offset = 0;
20
+ /** Internal port → the external port the router grants for it instead. */
21
+ readonly reassigned = new Map<number, number>();
22
+ routerIp: string | null = '203.0.113.10';
23
+ readonly mapped = new Map<number, number>();
24
+ readonly mapCalls: number[] = [];
25
+ readonly unmapCalls: number[] = [];
26
+ /** Runs at the start of every `map`: a test's chance to act while a pass is mid-flight. */
27
+ onMap: (() => void) | undefined;
28
+ async discover(): Promise<GatewayInfo | null> { return this.gateway; }
29
+ async map(req: PortMapRequest): Promise<PortMapResult> {
30
+ this.mapCalls.push(req.internalPort);
31
+ this.onMap?.();
32
+ if (!this.mapped.has(req.internalPort) && this.mapped.size >= this.cap) {
33
+ throw new NatError('mapping_failed', 'router: mapping table is full');
34
+ }
35
+ const externalPort = this.reassigned.get(req.internalPort) ?? req.internalPort + this.offset;
36
+ this.mapped.set(req.internalPort, externalPort);
37
+ return { externalPort, leaseExpiresAt: new Date(Date.now() + req.ttlMs) };
38
+ }
39
+ async unmap(internalPort: number): Promise<void> {
40
+ this.unmapCalls.push(internalPort);
41
+ this.mapped.delete(internalPort);
42
+ }
43
+ async externalIp(): Promise<string | null> { return this.routerIp; }
44
+ async stop(): Promise<void> { /* nothing to release */ }
45
+ }
46
+
47
+ /** The orchestrator slice the service reads: a node list plus state-change events. */
48
+ class FakeNodes implements NatNodeSource {
49
+ private readonly nodes = new Map<string, ManagedNodeInfo>();
50
+ private readonly listeners = new Set<NodeStateListener>();
51
+ /** The orchestrator's announce hook, asked at every spawn; `rig` points it at the service. */
52
+ announce: (id: string, ports: { p2p: number; ws?: number }) => string[] = () => [];
53
+ listNodes(): ManagedNodeInfo[] { return [...this.nodes.values()]; }
54
+ onStateChange(listener: NodeStateListener): () => void {
55
+ this.listeners.add(listener);
56
+ return () => { this.listeners.delete(listener); };
57
+ }
58
+ add(id: string, ports: { p2p: number; ws?: number }, status: ManagedNodeInfo['status'] = 'running'): void {
59
+ const info: ManagedNodeInfo = {
60
+ id,
61
+ dockerId: `1:${id}`,
62
+ partyId: 'party',
63
+ profile: 'storage',
64
+ status,
65
+ spawnedAt: '2026-01-01T00:00:00Z',
66
+ workdir: `/w/${id}`,
67
+ ports: { health: 1, metrics: 2, p2p: ports.p2p, ws: ports.ws as number },
68
+ announcedAddrs: this.announce(id, ports),
30
69
  };
70
+ this.nodes.set(id, info);
71
+ this.emit(info);
72
+ }
73
+ /** A respawn on the same ports, announcing what the hook gives now. */
74
+ restart(id: string): void {
75
+ const { ports } = this.nodes.get(id)!;
76
+ this.add(id, { p2p: ports.p2p, ws: ports.ws });
77
+ }
78
+ setStatus(id: string, status: ManagedNodeInfo['status']): void {
79
+ const info = { ...this.nodes.get(id)!, status };
80
+ this.nodes.set(id, info);
81
+ this.emit(info);
82
+ }
83
+ /** `removeContainer`: the handle is gone from the list by the time the event fires. */
84
+ remove(id: string): void {
85
+ const info = { ...this.nodes.get(id)!, status: 'stopped' as const };
86
+ this.nodes.delete(id);
87
+ this.emit(info);
88
+ }
89
+ private emit(info: ManagedNodeInfo): void {
90
+ for (const l of this.listeners) l(info);
31
91
  }
32
- async verify() { return true; }
33
- async externalIp() { return this.externalIpValue; }
34
- async unmap() { this.unmapCalls++; }
35
- async stop() { this.stopCalls++; }
36
92
  }
37
93
 
38
94
  function makeSecrets(seed: Record<string, string> = {}): SecretsStore {
@@ -45,36 +101,6 @@ function makeSecrets(seed: Record<string, string> = {}): SecretsStore {
45
101
  };
46
102
  }
47
103
 
48
- function makeNode(peerId = '12D3KooWHost', addrs: string[] = ['/ip4/192.168.1.10/tcp/4001']): CadreNodeLike {
49
- return {
50
- getPeerId: () => peerId,
51
- getMultiaddrs: () => addrs,
52
- };
53
- }
54
-
55
- /**
56
- * A node that mimics a freshly spawned owner child: `getPeerId` throws
57
- * `OwnerNodeUnavailableError` for the first `failTimes` calls (admin channel
58
- * not bound yet), then returns a real peer ID. Exposes the call count so tests
59
- * can assert how many attempts the retry loop made.
60
- */
61
- function makeFlakyNode(
62
- failTimes: number,
63
- peerId = '12D3KooWFlaky',
64
- addrs: string[] = ['/ip4/192.168.1.10/tcp/4001'],
65
- ): { node: CadreNodeLike; peerIdCalls: () => number } {
66
- let calls = 0;
67
- const node: CadreNodeLike = {
68
- getPeerId: async () => {
69
- calls += 1;
70
- if (calls <= failTimes) throw new OwnerNodeUnavailableError('admin channel not bound yet');
71
- return peerId;
72
- },
73
- getMultiaddrs: async () => addrs,
74
- };
75
- return { node, peerIdCalls: () => calls };
76
- }
77
-
78
104
  function makeDetector(opts: { router?: string | null; pub?: string | null }): ExternalIpDetector {
79
105
  return new ExternalIpDetector({
80
106
  routerProbe: opts.router === undefined ? undefined : async () => opts.router ?? null,
@@ -87,12 +113,7 @@ function makeDetector(opts: { router?: string | null; pub?: string | null }): Ex
87
113
  });
88
114
  }
89
115
 
90
- /**
91
- * The provider call a `putDdns` lands on. Injected wherever a case configures a real
92
- * provider, so no case here reaches www.duckdns.org — an outbound request in a unit
93
- * test is a hang waiting for a machine without network. DuckDNS's own contract (the
94
- * plain-text `OK`) is covered in the ddns specs.
95
- */
116
+ /** The DuckDNS call a `putDdns` lands on; no case here reaches www.duckdns.org. */
96
117
  function okFetch(): typeof fetch {
97
118
  return (async () => ({
98
119
  ok: true,
@@ -103,413 +124,417 @@ function okFetch(): typeof fetch {
103
124
  }
104
125
 
105
126
  let tmpRoot: string;
127
+ let clock: number;
128
+ const now = (): Date => new Date(clock);
129
+
106
130
  beforeEach(() => {
107
131
  tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-nat-svc-'));
132
+ clock = Date.parse('2026-01-01T00:00:00Z');
108
133
  });
109
134
  afterEach(() => {
110
135
  try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
111
136
  });
112
137
 
113
- describe('NatService', () => {
114
- it('start() runs port mapping, detects IP, status reflects auto-upnp', async () => {
115
- const mapper = new StubMapper();
116
- const svc = new NatService({
117
- rootDir: tmpRoot,
118
- cadreNode: makeNode(),
119
- secretsStore: makeSecrets(),
120
- portMapper: mapper,
121
- externalIpDetector: makeDetector({ router: '203.0.113.10', pub: '203.0.113.10' }),
122
- });
123
- await svc.start();
124
- const s = svc.getStatus();
125
- expect(s.portMode).toBe('auto-upnp');
126
- expect(s.externalPort).toBe(4001);
127
- expect(s.externalIp).toBe('203.0.113.10');
128
- expect(s.directReachability).toBe('reachable');
129
- expect(mapper.mapCalls).toBe(1);
130
- });
138
+ interface Rig {
139
+ svc: NatService;
140
+ mapper: FakeMapper;
141
+ nodes: FakeNodes;
142
+ }
131
143
 
132
- it('detects CGNAT and flags directReachability=cgnat', async () => {
133
- const mapper = new StubMapper();
134
- const svc = new NatService({
135
- rootDir: tmpRoot,
136
- cadreNode: makeNode(),
137
- secretsStore: makeSecrets(),
138
- portMapper: mapper,
139
- externalIpDetector: makeDetector({ router: '100.64.0.5', pub: '203.0.113.10' }),
140
- });
141
- await svc.start();
142
- const s = svc.getStatus();
143
- expect(s.cgnatDetected).toBe(true);
144
- expect(s.directReachability).toBe('cgnat');
144
+ function rig(opts: {
145
+ mapper?: FakeMapper;
146
+ nodes?: FakeNodes;
147
+ detector?: ExternalIpDetector;
148
+ fetch?: typeof fetch;
149
+ secrets?: SecretsStore;
150
+ } = {}): Rig {
151
+ const mapper = opts.mapper ?? new FakeMapper();
152
+ const nodes = opts.nodes ?? new FakeNodes();
153
+ const svc = new NatService({
154
+ rootDir: tmpRoot,
155
+ nodeSource: nodes,
156
+ now,
157
+ secretsStore: opts.secrets ?? makeSecrets(),
158
+ portMapper: mapper,
159
+ externalIpDetector: opts.detector ?? makeDetector({ pub: '203.0.113.10' }),
160
+ ...(opts.fetch ? { fetch: opts.fetch } : {}),
145
161
  });
162
+ nodes.announce = (id, ports) => svc.publicAddressesFor(id, ports);
163
+ return { svc, mapper, nodes };
164
+ }
146
165
 
147
- it('flips portMode to failed when mapper throws', async () => {
148
- const mapper = new StubMapper();
149
- mapper.mapFails = true;
150
- const svc = new NatService({
151
- rootDir: tmpRoot,
152
- cadreNode: makeNode(),
153
- secretsStore: makeSecrets(),
154
- portMapper: mapper,
155
- externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
156
- });
157
- await svc.start();
158
- expect(svc.getStatus().portMode).toBe('failed');
159
- expect(svc.getStatus().directReachability).toBe('unreachable');
160
- });
166
+ /** `start()` maps in the background; a second pass is sequenced after it. */
167
+ async function startAndSettle(svc: NatService): Promise<void> {
168
+ await svc.start();
169
+ await svc.reconcile();
170
+ }
161
171
 
162
- it('stop() unmaps and is idempotent', async () => {
163
- const mapper = new StubMapper();
164
- const svc = new NatService({
165
- rootDir: tmpRoot,
166
- cadreNode: makeNode(),
167
- secretsStore: makeSecrets(),
168
- portMapper: mapper,
169
- externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
170
- });
171
- await svc.start();
172
- await svc.stop();
173
- await svc.stop();
174
- expect(mapper.unmapCalls).toBe(1);
175
- expect(mapper.stopCalls).toBe(1);
176
- });
172
+ function node(svc: NatService, id: string) {
173
+ const found = svc.getStatus().nodes.find((n) => n.nodeId === id);
174
+ if (!found) throw new Error(`node ${id} not in status`);
175
+ return found;
176
+ }
177
177
 
178
- it('getInviteAddresses → dns4 when DDNS configured + reachable', async () => {
179
- const mapper = new StubMapper();
180
- const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
181
- const svc = new NatService({
182
- rootDir: tmpRoot,
183
- cadreNode: makeNode('12D3KooWHost'),
184
- secretsStore: secrets,
185
- portMapper: mapper,
186
- externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
187
- fetch: okFetch(),
188
- });
189
- await svc.start();
190
- await svc.putDdns({
191
- providerId: 'duckdns',
192
- hostname: 'foo.duckdns.org',
193
- config: { token: 'T' },
194
- });
195
- const addrs = await svc.getInviteAddresses();
196
- expect(addrs).toEqual(['/dns4/foo.duckdns.org/tcp/4001/p2p/12D3KooWHost']);
197
- });
178
+ /** Poll until `check` holds; for passes the service queues on its own, which nothing public awaits. */
179
+ async function waitUntil(check: () => boolean, timeoutMs = 2_000): Promise<void> {
180
+ const deadline = Date.now() + timeoutMs;
181
+ while (!check()) {
182
+ if (Date.now() > deadline) throw new Error('condition not met in time');
183
+ await new Promise<void>((r) => setTimeout(r, 10));
184
+ }
185
+ }
198
186
 
199
- it('getInviteAddresses → ip4 when reachable but no DDNS', async () => {
200
- const mapper = new StubMapper();
201
- const svc = new NatService({
202
- rootDir: tmpRoot,
203
- cadreNode: makeNode('12D3KooWHost'),
204
- secretsStore: makeSecrets(),
205
- portMapper: mapper,
206
- externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
187
+ describe('NatService — mapping table', () => {
188
+ it('maps both ports of every running node, and stop() unmaps nothing', async () => {
189
+ const { svc, mapper, nodes } = rig({ detector: makeDetector({ router: '203.0.113.10', pub: '203.0.113.10' }) });
190
+ nodes.add('grn_b', { p2p: 4001, ws: 10001 });
191
+ nodes.add('grn_a', { p2p: 10003, ws: 10004 });
192
+ await startAndSettle(svc);
193
+
194
+ const status = svc.getStatus();
195
+ expect(status.gateway).toMatchObject({ found: true, lanAddress: '192.168.1.20', routerExternalIp: '203.0.113.10' });
196
+ expect(status.directReachability).toBe('reachable');
197
+ expect(node(svc, 'grn_a')).toMatchObject({
198
+ verdict: 'mapped',
199
+ tcp: { internalPort: 10003, externalPort: 10003, source: 'upnp' },
200
+ ws: { internalPort: 10004, externalPort: 10004, source: 'upnp' },
201
+ publicAddrs: ['/ip4/203.0.113.10/tcp/10003', '/ip4/203.0.113.10/tcp/10004/ws'],
207
202
  });
208
- await svc.start();
209
- const addrs = await svc.getInviteAddresses();
210
- expect(addrs).toEqual(['/ip4/203.0.113.42/tcp/4001/p2p/12D3KooWHost']);
211
- });
203
+ expect(node(svc, 'grn_b').tcp.externalPort).toBe(4001);
204
+ expect(mapper.mapped.size).toBe(4);
212
205
 
213
- it('getInviteAddresses → libp2p fallback when unreachable', async () => {
214
- const mapper = new StubMapper();
215
- mapper.mapFails = true;
216
- const svc = new NatService({
217
- rootDir: tmpRoot,
218
- cadreNode: makeNode('12D3KooWHost', ['/ip4/10.0.0.5/tcp/4001']),
219
- secretsStore: makeSecrets(),
220
- portMapper: mapper,
221
- externalIpDetector: makeDetector({ pub: null }),
222
- });
223
- await svc.start();
224
- const addrs = await svc.getInviteAddresses();
225
- expect(addrs).toEqual(['/ip4/10.0.0.5/tcp/4001/p2p/12D3KooWHost']);
206
+ await svc.stop();
207
+ expect(mapper.unmapCalls).toEqual([]);
226
208
  });
227
209
 
228
- it('putDdns persists secrets and updates status', async () => {
229
- const secrets = makeSecrets();
230
- const mapper = new StubMapper();
231
- const svc = new NatService({
232
- rootDir: tmpRoot,
233
- cadreNode: makeNode(),
234
- secretsStore: secrets,
235
- portMapper: mapper,
236
- externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
237
- fetch: okFetch(),
238
- });
239
- await svc.start();
240
- const status = await svc.putDdns({
241
- providerId: 'duckdns',
242
- hostname: 'h.duckdns.org',
243
- config: { token: 'TOK' },
244
- });
245
- expect(status.ddns.providerId).toBe('duckdns');
246
- expect(status.ddns.hostname).toBe('h.duckdns.org');
247
- expect(await secrets.get(ddnsAccount('duckdns', 'token'))).toBe('TOK');
248
- });
210
+ it('a router that caps its mappings fails per port; the other node keeps its routes across a renewal', async () => {
211
+ const mapper = new FakeMapper();
212
+ mapper.cap = 3;
213
+ const { svc, nodes } = rig({ mapper });
214
+ nodes.add('a', { p2p: 10003, ws: 10004 });
215
+ nodes.add('b', { p2p: 10005, ws: 10006 });
216
+ await startAndSettle(svc);
217
+
218
+ expect(node(svc, 'a').verdict).toBe('mapped');
219
+ const b = node(svc, 'b');
220
+ expect(b.verdict).toBe('unreachable');
221
+ expect(b.tcp.externalPort).toBe(10005);
222
+ expect(b.ws).toMatchObject({ externalPort: null, error: expect.stringContaining('full') });
223
+ expect(b.reason).toContain('WebSocket port 10006');
224
+ expect(b.reason).toContain('192.168.1.20');
225
+ expect(b.publicAddrs).toEqual(['/ip4/203.0.113.10/tcp/10005']);
226
+ expect(svc.getStatus().directReachability).toBe('unreachable');
249
227
 
250
- it('putDdns with unknown provider → ddns_provider_unknown', async () => {
251
- const mapper = new StubMapper();
252
- const svc = new NatService({
253
- rootDir: tmpRoot,
254
- cadreNode: makeNode(),
255
- secretsStore: makeSecrets(),
256
- portMapper: mapper,
257
- externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
228
+ await svc.renewMappings();
229
+ expect(node(svc, 'a')).toMatchObject({
230
+ verdict: 'mapped',
231
+ tcp: { externalPort: 10003, source: 'upnp' },
232
+ ws: { externalPort: 10004, source: 'upnp' },
258
233
  });
259
- await svc.start();
260
- await expect(svc.putDdns({
261
- providerId: 'no-such-provider',
262
- hostname: 'x',
263
- config: {},
264
- })).rejects.toMatchObject({ code: 'ddns_provider_unknown' });
234
+ expect(node(svc, 'b').ws?.externalPort).toBeNull();
265
235
  });
266
236
 
267
- it('putSettings can disable UPnP and re-enable it', async () => {
268
- const mapper = new StubMapper();
269
- const svc = new NatService({
270
- rootDir: tmpRoot,
271
- cadreNode: makeNode(),
272
- secretsStore: makeSecrets(),
273
- portMapper: mapper,
274
- externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
237
+ it('the route and the public addresses carry the external port the router assigned', async () => {
238
+ const mapper = new FakeMapper();
239
+ mapper.offset = 1;
240
+ const { svc, nodes } = rig({ mapper });
241
+ nodes.add('a', { p2p: 10003, ws: 10004 });
242
+ await startAndSettle(svc);
243
+
244
+ expect(node(svc, 'a')).toMatchObject({
245
+ tcp: { internalPort: 10003, externalPort: 10004 },
246
+ ws: { internalPort: 10004, externalPort: 10005 },
247
+ publicAddrs: ['/ip4/203.0.113.10/tcp/10004', '/ip4/203.0.113.10/tcp/10005/ws'],
275
248
  });
276
- await svc.start();
277
- expect(mapper.mapCalls).toBe(1);
249
+ });
278
250
 
279
- let s = await svc.putSettings({ upnpEnabled: false });
280
- expect(s.portMode).toBe('disabled');
251
+ it('a handle without a WebSocket port maps only its TCP port', async () => {
252
+ const { svc, nodes } = rig();
253
+ nodes.add('old', { p2p: 10003 });
254
+ await startAndSettle(svc);
281
255
 
282
- s = await svc.putSettings({ upnpEnabled: true });
283
- expect(s.portMode).toBe('auto-upnp');
284
- expect(mapper.mapCalls).toBeGreaterThanOrEqual(2);
256
+ expect(node(svc, 'old')).toMatchObject({ verdict: 'mapped', ws: null, publicAddrs: ['/ip4/203.0.113.10/tcp/10003'] });
285
257
  });
286
258
 
287
- it('testReachability re-runs IP detect and updates lastTestedAt', async () => {
288
- const mapper = new StubMapper();
289
- const svc = new NatService({
290
- rootDir: tmpRoot,
291
- cadreNode: makeNode(),
292
- secretsStore: makeSecrets(),
293
- portMapper: mapper,
294
- externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
295
- });
296
- await svc.start();
297
- expect(svc.getStatus().lastTestedAt).toBeNull();
298
- await svc.testReachability();
299
- expect(svc.getStatus().lastTestedAt).not.toBeNull();
259
+ it('a terminated node is unmapped at once and its manual forward is dropped', async () => {
260
+ const { svc, mapper, nodes } = rig();
261
+ nodes.add('a', { p2p: 10003, ws: 10004 });
262
+ await startAndSettle(svc);
263
+ await svc.putForward('a', { tcp: 40000 });
264
+ expect(readFileSync(join(tmpRoot, 'nat.json'), 'utf8')).toContain('"a"');
265
+
266
+ nodes.remove('a');
267
+ await svc.reconcile(); // sequenced after the pass the removal event queued
268
+
269
+ expect(mapper.unmapCalls).toContain(10004);
270
+ expect(mapper.mapped.size).toBe(0);
271
+ expect(svc.getStatus().nodes).toEqual([]);
272
+ expect(svc.getSettings().forwards).toEqual({});
273
+ expect(readFileSync(join(tmpRoot, 'nat.json'), 'utf8')).not.toContain('"a"');
300
274
  });
301
275
 
302
- it('listDdnsProviders returns at least DuckDNS', () => {
303
- const svc = new NatService({
304
- rootDir: tmpRoot,
305
- cadreNode: makeNode(),
306
- secretsStore: makeSecrets(),
307
- portMapper: new StubMapper(),
308
- externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
309
- });
310
- const providers = svc.listDdnsProviders();
311
- const duck = providers.find((p) => p.id === 'duckdns');
312
- expect(duck).toBeDefined();
313
- expect(duck!.configFields[0]).toMatchObject({ key: 'token', secret: true });
276
+ it('a node stopped inside the grace keeps its mapping; past it the mapping is released and a respawn re-maps', async () => {
277
+ const { svc, mapper, nodes } = rig();
278
+ nodes.add('a', { p2p: 10003, ws: 10004 });
279
+ await startAndSettle(svc);
280
+
281
+ nodes.setStatus('a', 'stopped');
282
+ await svc.reconcile(); // the stop event's own pass records when the node was first seen stopped
283
+ clock += NAT_UNMAP_GRACE_MS - 1_000;
284
+ await svc.reconcile();
285
+ expect(mapper.unmapCalls).toEqual([]);
286
+ expect(node(svc, 'a')).toMatchObject({ running: false, tcp: { externalPort: 10003 } });
287
+
288
+ clock += 1_000;
289
+ await svc.reconcile();
290
+ expect(mapper.unmapCalls).toEqual([10003, 10004]);
291
+ expect(node(svc, 'a').tcp.externalPort).toBeNull();
292
+
293
+ nodes.setStatus('a', 'running');
294
+ await svc.reconcile();
295
+ expect(node(svc, 'a')).toMatchObject({ running: true, verdict: 'mapped', tcp: { externalPort: 10003 } });
314
296
  });
315
- });
316
297
 
317
- describe('NatService — async channel node + onAddressesChanged', () => {
318
- it('getInviteAddresses awaits async getPeerId/getMultiaddrs from the channel', async () => {
319
- const asyncNode: CadreNodeLike = {
320
- getPeerId: async () => '12D3KooWAsync',
321
- getMultiaddrs: async () => ['/ip4/10.0.0.9/tcp/4001'],
298
+ it('a node spawned while an event-triggered pass runs is mapped by the pass that follows it', async () => {
299
+ const mapper = new FakeMapper();
300
+ const { svc, nodes } = rig({ mapper });
301
+ await startAndSettle(svc);
302
+
303
+ // The spawn of `a` queues an event pass; `b` spawns while that pass is mapping a's first port.
304
+ mapper.onMap = () => {
305
+ mapper.onMap = undefined;
306
+ nodes.add('b', { p2p: 10005, ws: 10006 });
322
307
  };
323
- const svc = new NatService({
324
- rootDir: tmpRoot,
325
- cadreNode: asyncNode,
326
- secretsStore: makeSecrets(),
327
- portMapper: new StubMapper(),
328
- externalIpDetector: makeDetector({ pub: '203.0.113.50' }),
329
- });
330
- await svc.start();
331
- const addrs = await svc.getInviteAddresses();
332
- expect(addrs).toEqual(['/ip4/203.0.113.50/tcp/4001/p2p/12D3KooWAsync']);
308
+ nodes.add('a', { p2p: 10003, ws: 10004 });
309
+
310
+ await waitUntil(() => svc.getStatus().nodes.some((n) => n.nodeId === 'b' && n.verdict === 'mapped'));
311
+ expect(node(svc, 'a').verdict).toBe('mapped');
333
312
  });
334
313
 
335
- it('throws node_unavailable when the node reports an empty peer ID (not ready)', async () => {
336
- const notReadyNode: CadreNodeLike = {
337
- getPeerId: async () => '',
338
- getMultiaddrs: async () => [],
339
- };
340
- const svc = new NatService({
341
- rootDir: tmpRoot,
342
- cadreNode: notReadyNode,
343
- secretsStore: makeSecrets(),
344
- portMapper: new StubMapper(),
345
- externalIpDetector: makeDetector({ pub: '203.0.113.50' }),
346
- });
347
- await svc.start();
348
- await expect(svc.getInviteAddresses()).rejects.toMatchObject({ code: 'node_unavailable' });
314
+ it('a router that answers a later probe maps the running nodes without a restart', async () => {
315
+ const mapper = new FakeMapper();
316
+ mapper.gateway = null;
317
+ const { svc, nodes } = rig({ mapper });
318
+ nodes.add('a', { p2p: 10003, ws: 10004 });
319
+ await startAndSettle(svc);
320
+ expect(node(svc, 'a').reason).toContain('No UPnP router answered');
321
+
322
+ mapper.gateway = { lanAddress: '192.168.1.20', routerHost: '192.168.1.1' };
323
+ await svc.redetect();
324
+ expect(svc.getStatus().gateway).toMatchObject({ found: true, lanAddress: '192.168.1.20' });
325
+ expect(node(svc, 'a')).toMatchObject({ verdict: 'mapped', tcp: { externalPort: 10003, source: 'upnp' } });
349
326
  });
327
+ });
350
328
 
351
- it('fires onAddressesChanged on putSettings and testReachability', async () => {
352
- const svc = new NatService({
353
- rootDir: tmpRoot,
354
- cadreNode: makeNode('12D3KooWHook'),
355
- secretsStore: makeSecrets(),
356
- portMapper: new StubMapper(),
357
- externalIpDetector: makeDetector({ pub: '203.0.113.7' }),
329
+ describe('NatService — manual forwards and the UPnP toggle', () => {
330
+ it('a manual forward wins over UPnP and is re-requested when cleared', async () => {
331
+ const { svc, mapper, nodes } = rig();
332
+ nodes.add('a', { p2p: 10003, ws: 10004 });
333
+ await startAndSettle(svc);
334
+
335
+ let status = await svc.putForward('a', { tcp: 40000 });
336
+ expect(mapper.unmapCalls).toEqual([10003]);
337
+ expect(status.nodes[0]).toMatchObject({
338
+ verdict: 'manual',
339
+ tcp: { externalPort: 40000, source: 'manual' },
340
+ ws: { externalPort: 10004, source: 'upnp' },
341
+ publicAddrs: ['/ip4/203.0.113.10/tcp/40000', '/ip4/203.0.113.10/tcp/10004/ws'],
358
342
  });
359
- await svc.start();
343
+ expect(JSON.parse(readFileSync(join(tmpRoot, 'nat.json'), 'utf8')).forwards).toEqual({ a: { tcp: 40000 } });
360
344
 
361
- const fired: string[][] = [];
362
- svc.onAddressesChanged((addrs) => { fired.push(addrs); });
345
+ status = await svc.putForward('a', { tcp: null });
346
+ expect(status.nodes[0]).toMatchObject({ verdict: 'mapped', tcp: { externalPort: 10003, source: 'upnp' } });
347
+ expect(svc.getSettings().forwards).toEqual({});
348
+ });
363
349
 
364
- await svc.putSettings({ externalPort: 4002 });
365
- await svc.testReachability();
350
+ it('putForward refuses an unknown node and an out-of-range port', async () => {
351
+ const { svc, nodes } = rig();
352
+ nodes.add('a', { p2p: 10003, ws: 10004 });
353
+ await startAndSettle(svc);
366
354
 
367
- expect(fired.length).toBeGreaterThanOrEqual(2);
368
- // The pushed addresses carry the node's peer ID.
369
- expect(fired[fired.length - 1]!.every((a: string) => a.includes('12D3KooWHook'))).toBe(true);
355
+ await expect(svc.putForward('nope', { tcp: 1 })).rejects.toMatchObject({ code: 'unknown_node' });
356
+ await expect(svc.putForward('a', { ws: 70000 })).rejects.toMatchObject({ code: 'invalid_config' });
370
357
  });
371
358
 
372
- it('onAddressesChanged unsubscribe stops further notifications', async () => {
373
- const svc = new NatService({
374
- rootDir: tmpRoot,
375
- cadreNode: makeNode(),
376
- secretsStore: makeSecrets(),
377
- portMapper: new StubMapper(),
378
- externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
379
- });
380
- await svc.start();
381
- let count = 0;
382
- const off = svc.onAddressesChanged(() => { count++; });
383
- await svc.testReachability();
384
- off();
385
- await svc.testReachability();
386
- expect(count).toBe(1);
359
+ it('turning UPnP off releases upnp routes and keeps manual ones', async () => {
360
+ const { svc, mapper, nodes } = rig();
361
+ nodes.add('a', { p2p: 10003, ws: 10004 });
362
+ await startAndSettle(svc);
363
+ await svc.putForward('a', { ws: 40004 });
364
+
365
+ const status = await svc.putSettings({ upnpEnabled: false });
366
+ expect(mapper.unmapCalls).toEqual([10004, 10003]);
367
+ const a = status.nodes[0]!;
368
+ expect(a.ws).toMatchObject({ externalPort: 40004, source: 'manual' });
369
+ expect(a.tcp).toMatchObject({ externalPort: null, source: null });
370
+ expect(a.verdict).toBe('unreachable');
371
+ expect(a.reason).toContain('UPnP is off');
372
+ expect(a.reason).toContain('TCP port 10003');
373
+
374
+ await svc.putSettings({ upnpEnabled: true });
375
+ expect(node(svc, 'a')).toMatchObject({ verdict: 'manual', tcp: { externalPort: 10003, source: 'upnp' } });
387
376
  });
388
377
  });
389
378
 
390
- describe('NatService — initial invite-address push retry', () => {
391
- it('delivers the initial push exactly once when the node is ready on the first attempt', async () => {
392
- // The common production happy path: node bound fast, listener registered
393
- // BEFORE start(). No retry needed — but the push must still fire (once).
394
- const { node, peerIdCalls } = makeFlakyNode(0, '12D3KooWReady');
395
- const svc = new NatService({
396
- rootDir: tmpRoot,
397
- cadreNode: node,
398
- secretsStore: makeSecrets(),
399
- portMapper: new StubMapper(),
400
- externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
401
- initialPushRetryMs: 1,
402
- initialPushTimeoutMs: 2_000,
403
- });
404
-
405
- const fired: string[][] = [];
406
- svc.onAddressesChanged((addrs) => { fired.push(addrs); });
407
-
408
- await svc.start();
379
+ describe('NatService — public addresses', () => {
380
+ it('predicts the identity mapping for a port with no route yet, but not after a failed attempt', async () => {
381
+ const mapper = new FakeMapper();
382
+ mapper.cap = 1;
383
+ const { svc, nodes } = rig({ mapper });
384
+ await startAndSettle(svc);
385
+
386
+ expect(svc.publicAddressesFor('new', { p2p: 20000, ws: 20001 })).toEqual([
387
+ '/ip4/203.0.113.10/tcp/20000',
388
+ '/ip4/203.0.113.10/tcp/20001/ws',
389
+ ]);
390
+
391
+ nodes.add('new', { p2p: 20000, ws: 20001 });
392
+ await svc.reconcile();
393
+ expect(svc.publicAddressesFor('new', { p2p: 20000, ws: 20001 })).toEqual(['/ip4/203.0.113.10/tcp/20000']);
394
+ });
409
395
 
410
- expect(fired).toEqual([['/ip4/203.0.113.42/tcp/4001/p2p/12D3KooWReady']]);
411
- expect(peerIdCalls()).toBe(1); // succeeded on the first attempt, no retry
396
+ it('predicts nothing without a gateway, and a manual forward regardless', async () => {
397
+ const mapper = new FakeMapper();
398
+ mapper.gateway = null;
399
+ const { svc, nodes } = rig({ mapper });
400
+ nodes.add('a', { p2p: 10003, ws: 10004 });
401
+ await startAndSettle(svc);
402
+
403
+ expect(svc.getStatus().gateway).toMatchObject({ found: false, lastError: expect.stringContaining('no UPnP gateway') });
404
+ expect(svc.publicAddressesFor('a', { p2p: 10003, ws: 10004 })).toEqual([]);
405
+ expect(node(svc, 'a').reason).toContain('No UPnP router answered');
406
+
407
+ await svc.putForward('a', { tcp: 40000, ws: 40001 });
408
+ expect(svc.publicAddressesFor('a', { p2p: 10003, ws: 10004 })).toEqual([
409
+ '/ip4/203.0.113.10/tcp/40000',
410
+ '/ip4/203.0.113.10/tcp/40001/ws',
411
+ ]);
412
+ expect(node(svc, 'a').verdict).toBe('manual');
412
413
  });
413
414
 
414
- it('retries past a not-yet-ready node and delivers the push exactly once', async () => {
415
- const { node, peerIdCalls } = makeFlakyNode(3, '12D3KooWFlaky');
416
- const svc = new NatService({
417
- rootDir: tmpRoot,
418
- cadreNode: node,
419
- secretsStore: makeSecrets(),
420
- portMapper: new StubMapper(),
421
- externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
422
- initialPushRetryMs: 1,
423
- initialPushTimeoutMs: 2_000,
415
+ it('uses the full DDNS name of the provider as the host part, even when typed as a bare subdomain', async () => {
416
+ const { svc, nodes } = rig({
417
+ secrets: makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' }),
418
+ fetch: okFetch(),
424
419
  });
420
+ nodes.add('a', { p2p: 10003, ws: 10004 });
421
+ await startAndSettle(svc);
425
422
 
426
- // Listener registered BEFORE start() — this is the race the fix closes.
427
- const fired: string[][] = [];
428
- svc.onAddressesChanged((addrs) => { fired.push(addrs); });
429
-
430
- await svc.start();
431
-
432
- // Delivered exactly once, with the NAT-resolved address carrying the peer ID.
433
- expect(fired).toEqual([['/ip4/203.0.113.42/tcp/4001/p2p/12D3KooWFlaky']]);
434
- // It took the failing attempts plus the successful one (proves it retried).
435
- expect(peerIdCalls()).toBe(4);
423
+ const status = await svc.putDdns({ providerId: 'duckdns', hostname: 'foo', config: { token: 'T' } });
424
+ expect(status.ddns).toMatchObject({ providerId: 'duckdns', hostname: 'foo.duckdns.org' });
425
+ expect(status.nodes[0]!.publicAddrs).toEqual(['/dns4/foo.duckdns.org/tcp/10003', '/dns4/foo.duckdns.org/tcp/10004/ws']);
436
426
  });
437
427
 
438
- it('gives up after the bounded timeout but still resolves start() (best-effort)', async () => {
439
- // Always unavailable — the retry budget elapses without a successful push.
440
- const node: CadreNodeLike = {
441
- getPeerId: async () => { throw new OwnerNodeUnavailableError('never ready'); },
442
- getMultiaddrs: async () => [],
443
- };
444
- const svc = new NatService({
445
- rootDir: tmpRoot,
446
- cadreNode: node,
447
- secretsStore: makeSecrets(),
448
- portMapper: new StubMapper(),
449
- externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
450
- initialPushRetryMs: 2,
451
- initialPushTimeoutMs: 20,
428
+ it('under CGNAT a upnp route yields no address and the host rolls up to cgnat; a manual route still counts', async () => {
429
+ const { svc, nodes } = rig({ detector: makeDetector({ router: '100.64.0.5', pub: '203.0.113.10' }) });
430
+ nodes.add('a', { p2p: 10003, ws: 10004 });
431
+ await startAndSettle(svc);
432
+
433
+ const status = svc.getStatus();
434
+ expect(status.cgnatDetected).toBe(true);
435
+ expect(status.directReachability).toBe('cgnat');
436
+ expect(status.nodes[0]).toMatchObject({ verdict: 'unreachable', publicAddrs: [] });
437
+ expect(status.nodes[0]!.reason).toContain('carrier-grade NAT');
438
+
439
+ const after = await svc.putForward('a', { tcp: 40000, ws: 40001 });
440
+ expect(after.nodes[0]).toMatchObject({
441
+ verdict: 'manual',
442
+ publicAddrs: ['/ip4/203.0.113.10/tcp/40000', '/ip4/203.0.113.10/tcp/40001/ws'],
452
443
  });
453
-
454
- const fired: string[][] = [];
455
- svc.onAddressesChanged((addrs) => { fired.push(addrs); });
456
-
457
- await svc.start(); // resolves despite the node never coming up
458
- expect(fired).toEqual([]); // best-effort: nothing delivered
459
- // The process is otherwise healthy — status still builds.
460
- expect(svc.getStatus().portMode).toBe('auto-upnp');
444
+ expect(after.directReachability).toBe('reachable');
461
445
  });
446
+ });
462
447
 
463
- it('does not retry on a non-node_unavailable error', async () => {
464
- let calls = 0;
465
- const node: CadreNodeLike = {
466
- getPeerId: async () => { calls += 1; throw new Error('boom'); },
467
- getMultiaddrs: async () => [],
468
- };
469
- const svc = new NatService({
470
- rootDir: tmpRoot,
471
- cadreNode: node,
472
- secretsStore: makeSecrets(),
473
- portMapper: new StubMapper(),
474
- externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
475
- initialPushRetryMs: 1,
476
- initialPushTimeoutMs: 2_000,
477
- });
478
- svc.onAddressesChanged(() => { /* registered before start */ });
448
+ describe('NatService — stale announced addresses', () => {
449
+ it('asks to restart only the node whose router-assigned port differs from the one it announced, and only once', async () => {
450
+ const mapper = new FakeMapper();
451
+ mapper.reassigned.set(10006, 30006);
452
+ const { svc, nodes } = rig({ mapper });
453
+ await startAndSettle(svc);
454
+ const stale: string[] = [];
455
+ svc.onNodeAddressesStale((id) => { stale.push(id); });
456
+
457
+ // Both spawn announcing the predicted identity mapping.
458
+ nodes.add('same', { p2p: 10003, ws: 10004 });
459
+ nodes.add('moved', { p2p: 10005, ws: 10006 });
460
+ await svc.reconcile();
461
+ expect(stale).toEqual(['moved']);
462
+
463
+ nodes.restart('moved');
464
+ clock += NAT_ADDRESS_RESTART_MIN_INTERVAL_MS;
465
+ await svc.reconcile();
466
+ expect(nodes.listNodes().find((n) => n.id === 'moved')!.announcedAddrs)
467
+ .toEqual(['/ip4/203.0.113.10/tcp/10005', '/ip4/203.0.113.10/tcp/30006/ws']);
468
+ expect(stale).toEqual(['moved']);
469
+ });
479
470
 
480
- await svc.start(); // does not hang retrying a non-transient failure
481
- expect(calls).toBe(1);
471
+ it('defers a second restart of one node until the rate-limit window has passed', async () => {
472
+ const mapper = new FakeMapper();
473
+ mapper.reassigned.set(10004, 30004);
474
+ const { svc, nodes } = rig({ mapper });
475
+ await startAndSettle(svc);
476
+ const stale: string[] = [];
477
+ svc.onNodeAddressesStale((id) => { stale.push(id); });
478
+
479
+ nodes.add('a', { p2p: 10003, ws: 10004 });
480
+ await svc.reconcile();
481
+ expect(stale).toEqual(['a']);
482
+
483
+ // Never restarted, so the difference stands at every later pass.
484
+ clock += NAT_ADDRESS_RESTART_MIN_INTERVAL_MS - 1;
485
+ await svc.reconcile();
486
+ expect(stale).toEqual(['a']);
487
+
488
+ clock += 1;
489
+ await svc.reconcile();
490
+ expect(stale).toEqual(['a', 'a']);
482
491
  });
483
492
  });
484
493
 
485
- describe('createNatHandlers', () => {
486
- it('every handler delegates to the service', async () => {
487
- const mapper = new StubMapper();
488
- const svc = new NatService({
489
- rootDir: tmpRoot,
490
- cadreNode: makeNode(),
491
- secretsStore: makeSecrets(),
492
- portMapper: mapper,
493
- externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
494
- fetch: okFetch(),
494
+ describe('NatService — external IP and change notification', () => {
495
+ it('a re-detection that loses the public probe keeps the previous result, CGNAT flag included', async () => {
496
+ let offline = false;
497
+ const detector = new ExternalIpDetector({
498
+ routerProbe: async () => '100.64.0.5',
499
+ fetch: (async () => {
500
+ if (offline) throw new Error('offline');
501
+ return { ok: true, status: 200, async text() { return '203.0.113.10'; } };
502
+ }) as unknown as typeof fetch,
503
+ publicIpUrls: ['https://stub.test'],
495
504
  });
496
- await svc.start();
497
- const h = createNatHandlers(svc);
498
-
499
- expect((await h.getStatus()).portMode).toBe('auto-upnp');
500
- expect((await h.listDdnsProviders()).find((p) => p.id === 'duckdns')).toBeDefined();
501
-
502
- const status = await h.testReachability();
505
+ const { svc } = rig({ detector });
506
+ await startAndSettle(svc);
507
+ expect(svc.getStatus()).toMatchObject({ externalIp: '203.0.113.10', cgnatDetected: true });
508
+
509
+ // Only the router answers now: taking that result would drop the CGNAT verdict
510
+ // and read the carrier address as the host's.
511
+ offline = true;
512
+ const status = await svc.testReachability();
513
+ expect(status).toMatchObject({ externalIp: '203.0.113.10', cgnatDetected: true });
503
514
  expect(status.lastTestedAt).not.toBeNull();
515
+ });
504
516
 
505
- const after = await h.putSettings({ externalPort: 4002 });
506
- expect(after.externalPort).toBe(4002);
517
+ it('onChange fires when the snapshot changes and stays quiet across an idle pass', async () => {
518
+ const { svc, nodes } = rig();
519
+ const seen: string[] = [];
520
+ svc.onChange((snap) => { seen.push(snap.directReachability); });
521
+ await startAndSettle(svc);
522
+ const afterStart = seen.length;
523
+ expect(afterStart).toBeGreaterThan(0);
524
+
525
+ await svc.reconcile();
526
+ expect(seen.length).toBe(afterStart);
527
+
528
+ nodes.add('a', { p2p: 10003, ws: 10004 });
529
+ await svc.reconcile();
530
+ expect(seen.length).toBeGreaterThan(afterStart);
531
+ expect(seen[seen.length - 1]).toBe('reachable');
532
+ });
507
533
 
508
- const ddnsStatus = await h.putDdns({
509
- providerId: 'duckdns',
510
- hostname: 'a.duckdns.org',
511
- config: { token: 'T' },
512
- });
513
- expect(ddnsStatus.ddns.hostname).toBe('a.duckdns.org');
534
+ it('putDdns with an unknown provider → ddns_provider_unknown', async () => {
535
+ const { svc } = rig();
536
+ await startAndSettle(svc);
537
+ await expect(svc.putDdns({ providerId: 'no-such-provider', hostname: 'x', config: {} }))
538
+ .rejects.toMatchObject({ code: 'ddns_provider_unknown' });
514
539
  });
515
540
  });