@serfab/cadre-host 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/README.md +347 -423
  2. package/dist/bin/host.d.ts +6 -3
  3. package/dist/bin/host.js +332 -524
  4. package/dist/bin/host.js.map +1 -1
  5. package/dist/bin/join-output.d.ts +56 -0
  6. package/dist/bin/join-output.js +92 -0
  7. package/dist/bin/join-output.js.map +1 -0
  8. package/dist/bin/nat-output.d.ts +54 -0
  9. package/dist/bin/nat-output.js +138 -0
  10. package/dist/bin/nat-output.js.map +1 -0
  11. package/dist/bin/ui-access.d.ts +22 -0
  12. package/dist/bin/ui-access.js +44 -0
  13. package/dist/bin/ui-access.js.map +1 -0
  14. package/dist/hosted/claim-details.d.ts +66 -0
  15. package/dist/hosted/claim-details.js +60 -0
  16. package/dist/hosted/claim-details.js.map +1 -0
  17. package/dist/hosted/hosted-node-service.d.ts +325 -0
  18. package/dist/hosted/hosted-node-service.js +678 -0
  19. package/dist/hosted/hosted-node-service.js.map +1 -0
  20. package/dist/hosted/hosted-node-store.d.ts +40 -0
  21. package/dist/hosted/hosted-node-store.js +126 -0
  22. package/dist/hosted/hosted-node-store.js.map +1 -0
  23. package/dist/{donation/donation-supervisor.d.ts → hosted/hosted-node-supervisor.d.ts} +70 -53
  24. package/dist/hosted/hosted-node-supervisor.js +396 -0
  25. package/dist/hosted/hosted-node-supervisor.js.map +1 -0
  26. package/dist/hosted/hosted-node-watcher.d.ts +95 -0
  27. package/dist/hosted/hosted-node-watcher.js +260 -0
  28. package/dist/hosted/hosted-node-watcher.js.map +1 -0
  29. package/dist/hosted/index.d.ts +16 -0
  30. package/dist/hosted/index.js +13 -0
  31. package/dist/hosted/index.js.map +1 -0
  32. package/dist/hosted/node-status.d.ts +51 -0
  33. package/dist/hosted/node-status.js +77 -0
  34. package/dist/hosted/node-status.js.map +1 -0
  35. package/dist/hosted/types.d.ts +141 -0
  36. package/dist/hosted/types.js +22 -0
  37. package/dist/hosted/types.js.map +1 -0
  38. package/dist/index.d.ts +12 -24
  39. package/dist/index.js +9 -19
  40. package/dist/index.js.map +1 -1
  41. package/dist/installer/config.d.ts +27 -34
  42. package/dist/installer/config.js +46 -15
  43. package/dist/installer/config.js.map +1 -1
  44. package/dist/installer/index.d.ts +9 -19
  45. package/dist/installer/index.js +21 -85
  46. package/dist/installer/index.js.map +1 -1
  47. package/dist/installer/paths.d.ts +0 -2
  48. package/dist/installer/paths.js +0 -4
  49. package/dist/installer/paths.js.map +1 -1
  50. package/dist/installer/wizard.d.ts +12 -9
  51. package/dist/installer/wizard.js +15 -14
  52. package/dist/installer/wizard.js.map +1 -1
  53. package/dist/nat/address-resolver.d.ts +18 -17
  54. package/dist/nat/address-resolver.js +79 -25
  55. package/dist/nat/address-resolver.js.map +1 -1
  56. package/dist/nat/address-watch.d.ts +37 -0
  57. package/dist/nat/address-watch.js +84 -0
  58. package/dist/nat/address-watch.js.map +1 -0
  59. package/dist/nat/ddns/duckdns.d.ts +2 -1
  60. package/dist/nat/ddns/duckdns.js +5 -1
  61. package/dist/nat/ddns/duckdns.js.map +1 -1
  62. package/dist/nat/ddns/index.d.ts +6 -0
  63. package/dist/nat/ddns/index.js.map +1 -1
  64. package/dist/nat/index.d.ts +13 -9
  65. package/dist/nat/index.js +7 -5
  66. package/dist/nat/index.js.map +1 -1
  67. package/dist/nat/nat-service.d.ts +220 -77
  68. package/dist/nat/nat-service.js +653 -239
  69. package/dist/nat/nat-service.js.map +1 -1
  70. package/dist/nat/nat-store.d.ts +12 -5
  71. package/dist/nat/nat-store.js +124 -29
  72. package/dist/nat/nat-store.js.map +1 -1
  73. package/dist/nat/network-mode.d.ts +19 -0
  74. package/dist/nat/network-mode.js +32 -0
  75. package/dist/nat/network-mode.js.map +1 -0
  76. package/dist/nat/port-mapper.d.ts +83 -79
  77. package/dist/nat/port-mapper.js +148 -189
  78. package/dist/nat/port-mapper.js.map +1 -1
  79. package/dist/nat/reachability.d.ts +32 -15
  80. package/dist/nat/reachability.js +110 -22
  81. package/dist/nat/reachability.js.map +1 -1
  82. package/dist/nat/types.d.ts +98 -27
  83. package/dist/nat/types.js +8 -5
  84. package/dist/nat/types.js.map +1 -1
  85. package/dist/orchestrator/host-process-orchestrator.d.ts +32 -74
  86. package/dist/orchestrator/host-process-orchestrator.js +84 -233
  87. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  88. package/dist/{installer/identity.d.ts → orchestrator/identity-file.d.ts} +5 -5
  89. package/dist/{installer/identity.js → orchestrator/identity-file.js} +6 -6
  90. package/dist/orchestrator/identity-file.js.map +1 -0
  91. package/dist/orchestrator/index.d.ts +2 -3
  92. package/dist/orchestrator/index.js +1 -1
  93. package/dist/orchestrator/index.js.map +1 -1
  94. package/dist/orchestrator/node-identity.d.ts +6 -6
  95. package/dist/orchestrator/node-identity.js +7 -7
  96. package/dist/orchestrator/node-identity.js.map +1 -1
  97. package/dist/orchestrator/port-allocator.d.ts +5 -6
  98. package/dist/orchestrator/port-allocator.js +9 -11
  99. package/dist/orchestrator/port-allocator.js.map +1 -1
  100. package/dist/orchestrator/state-store.d.ts +3 -5
  101. package/dist/orchestrator/state-store.js +2 -10
  102. package/dist/orchestrator/state-store.js.map +1 -1
  103. package/dist/orchestrator/types.d.ts +33 -22
  104. package/dist/orchestrator/types.js.map +1 -1
  105. package/dist/package-version.d.ts +1 -0
  106. package/dist/package-version.js +4 -0
  107. package/dist/package-version.js.map +1 -0
  108. package/dist/server/error-handler.d.ts +5 -25
  109. package/dist/server/error-handler.js +11 -74
  110. package/dist/server/error-handler.js.map +1 -1
  111. package/dist/server/events/types.d.ts +7 -11
  112. package/dist/server/events/types.js +2 -1
  113. package/dist/server/events/types.js.map +1 -1
  114. package/dist/server/index.d.ts +11 -37
  115. package/dist/server/index.js +30 -48
  116. package/dist/server/index.js.map +1 -1
  117. package/dist/server/routes/hosted-nodes.d.ts +28 -0
  118. package/dist/server/routes/hosted-nodes.js +68 -0
  119. package/dist/server/routes/hosted-nodes.js.map +1 -0
  120. package/dist/server/routes/nat.d.ts +4 -1
  121. package/dist/server/routes/nat.js +13 -2
  122. package/dist/server/routes/nat.js.map +1 -1
  123. package/dist/server/routes/nodes.d.ts +6 -20
  124. package/dist/server/routes/nodes.js +7 -86
  125. package/dist/server/routes/nodes.js.map +1 -1
  126. package/dist/server/routes/settings.d.ts +3 -7
  127. package/dist/server/routes/settings.js +18 -8
  128. package/dist/server/routes/settings.js.map +1 -1
  129. package/dist/server/routes/status.d.ts +2 -25
  130. package/dist/server/routes/status.js +3 -43
  131. package/dist/server/routes/status.js.map +1 -1
  132. package/dist/server/server.js +8 -8
  133. package/dist/server/settings-store.d.ts +2 -2
  134. package/dist/server/settings-store.js +2 -2
  135. package/dist/server/static.js +1 -2
  136. package/dist/server/static.js.map +1 -1
  137. package/dist/ui/assets/index-BHd8CWZA.css +1 -0
  138. package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
  139. package/dist/ui/assets/index-Ccr2GjPT.js.map +1 -0
  140. package/dist/ui/index.html +2 -2
  141. package/dist/update/index.d.ts +2 -2
  142. package/dist/update/index.js +2 -2
  143. package/dist/update/store.d.ts +1 -1
  144. package/dist/update/store.js +1 -1
  145. package/dist/update/types.d.ts +1 -1
  146. package/dist/update/types.js +1 -1
  147. package/package.json +5 -7
  148. package/src/__tests__/cli-nat.smoke.test.ts +219 -212
  149. package/src/__tests__/cli.smoke.test.ts +2 -2
  150. package/src/__tests__/global-setup.ts +2 -3
  151. package/src/__tests__/orchestrator-claim-secret.test.ts +111 -0
  152. package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
  153. package/src/__tests__/orchestrator-handle-contract.ts +134 -134
  154. package/src/__tests__/orchestrator-node-identity.test.ts +177 -203
  155. package/src/__tests__/orchestrator-ports.test.ts +124 -143
  156. package/src/__tests__/orchestrator-push.test.ts +138 -146
  157. package/src/__tests__/orchestrator.test.ts +1017 -1063
  158. package/src/__tests__/ui-access.test.ts +30 -0
  159. package/src/bin/host.ts +397 -572
  160. package/src/bin/join-output.ts +120 -0
  161. package/src/bin/nat-output.ts +188 -0
  162. package/src/bin/ui-access.ts +50 -0
  163. package/src/{donation → hosted}/__tests__/fake-orchestrator.test.ts +172 -173
  164. package/src/{donation → hosted}/__tests__/fake-orchestrator.ts +43 -23
  165. package/src/hosted/__tests__/hosted-node-service.test.ts +708 -0
  166. package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -0
  167. package/src/hosted/__tests__/test-invitation.ts +33 -0
  168. package/src/hosted/claim-details.ts +103 -0
  169. package/src/hosted/hosted-node-service.ts +814 -0
  170. package/src/hosted/hosted-node-store.ts +144 -0
  171. package/src/hosted/hosted-node-supervisor.ts +439 -0
  172. package/src/hosted/hosted-node-watcher.ts +288 -0
  173. package/src/hosted/index.ts +53 -0
  174. package/src/hosted/node-status.ts +97 -0
  175. package/src/hosted/types.ts +160 -0
  176. package/src/index.ts +72 -91
  177. package/src/installer/__tests__/config.test.ts +15 -40
  178. package/src/installer/__tests__/installer.smoke.test.ts +184 -263
  179. package/src/installer/__tests__/wizard.test.ts +25 -13
  180. package/src/installer/config.ts +201 -175
  181. package/src/installer/index.ts +252 -327
  182. package/src/installer/paths.ts +104 -109
  183. package/src/installer/wizard.ts +24 -26
  184. package/src/nat/__tests__/address-resolver.test.ts +107 -82
  185. package/src/nat/__tests__/nat-service.test.ts +608 -515
  186. package/src/nat/__tests__/nat-store.test.ts +49 -12
  187. package/src/nat/__tests__/network-mode.test.ts +38 -0
  188. package/src/nat/__tests__/port-mapper.test.ts +30 -191
  189. package/src/nat/__tests__/reachability.test.ts +96 -13
  190. package/src/nat/address-resolver.ts +94 -52
  191. package/src/nat/address-watch.ts +92 -0
  192. package/src/nat/ddns/duckdns.ts +5 -1
  193. package/src/nat/ddns/index.ts +6 -0
  194. package/src/nat/index.ts +33 -13
  195. package/src/nat/nat-service.ts +942 -501
  196. package/src/nat/nat-store.ts +126 -31
  197. package/src/nat/network-mode.ts +36 -0
  198. package/src/nat/port-mapper.ts +241 -329
  199. package/src/nat/reachability.ts +155 -34
  200. package/src/nat/types.ts +110 -37
  201. package/src/{installer/__tests__/identity.test.ts → orchestrator/__tests__/identity-file.test.ts} +34 -34
  202. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  203. package/src/orchestrator/host-process-orchestrator.ts +1089 -1260
  204. package/src/{installer/identity.ts → orchestrator/identity-file.ts} +56 -56
  205. package/src/orchestrator/index.ts +9 -10
  206. package/src/orchestrator/node-identity.ts +41 -41
  207. package/src/orchestrator/port-allocator.ts +71 -73
  208. package/src/orchestrator/state-store.ts +76 -90
  209. package/src/orchestrator/types.ts +34 -23
  210. package/src/package-version.ts +4 -0
  211. package/src/server/__tests__/bus.test.ts +38 -38
  212. package/src/server/__tests__/error-handler.test.ts +81 -118
  213. package/src/server/__tests__/fakes.ts +15 -18
  214. package/src/server/__tests__/hosted-nodes-route.test.ts +161 -0
  215. package/src/server/__tests__/nodes-route.test.ts +114 -283
  216. package/src/server/__tests__/publishers.test.ts +193 -180
  217. package/src/server/__tests__/server.smoke.test.ts +134 -223
  218. package/src/server/__tests__/settings-route.test.ts +210 -195
  219. package/src/server/__tests__/sse-route.test.ts +119 -119
  220. package/src/server/__tests__/status-route.test.ts +82 -121
  221. package/src/server/error-handler.ts +112 -178
  222. package/src/server/events/types.ts +20 -24
  223. package/src/server/index.ts +223 -267
  224. package/src/server/routes/hosted-nodes.ts +83 -0
  225. package/src/server/routes/nat.ts +75 -63
  226. package/src/server/routes/nodes.ts +96 -188
  227. package/src/server/routes/settings.ts +20 -15
  228. package/src/server/routes/status.ts +5 -67
  229. package/src/server/server.ts +8 -8
  230. package/src/server/settings-store.ts +2 -2
  231. package/src/server/static.ts +1 -2
  232. package/src/update/index.ts +2 -2
  233. package/src/update/store.ts +1 -1
  234. package/src/update/types.ts +1 -1
  235. package/dist/auth/duration.d.ts +0 -1
  236. package/dist/auth/duration.js +0 -32
  237. package/dist/auth/duration.js.map +0 -1
  238. package/dist/auth/index.d.ts +0 -11
  239. package/dist/auth/index.js +0 -9
  240. package/dist/auth/index.js.map +0 -1
  241. package/dist/auth/self-label.d.ts +0 -36
  242. package/dist/auth/self-label.js +0 -57
  243. package/dist/auth/self-label.js.map +0 -1
  244. package/dist/auth/trust-circle-store.d.ts +0 -26
  245. package/dist/auth/trust-circle-store.js +0 -129
  246. package/dist/auth/trust-circle-store.js.map +0 -1
  247. package/dist/auth/trust-circle.d.ts +0 -156
  248. package/dist/auth/trust-circle.js +0 -307
  249. package/dist/auth/trust-circle.js.map +0 -1
  250. package/dist/auth/types.d.ts +0 -84
  251. package/dist/auth/types.js +0 -24
  252. package/dist/auth/types.js.map +0 -1
  253. package/dist/donation/donation-service.d.ts +0 -389
  254. package/dist/donation/donation-service.js +0 -828
  255. package/dist/donation/donation-service.js.map +0 -1
  256. package/dist/donation/donation-store.d.ts +0 -51
  257. package/dist/donation/donation-store.js +0 -152
  258. package/dist/donation/donation-store.js.map +0 -1
  259. package/dist/donation/donation-supervisor.js +0 -364
  260. package/dist/donation/donation-supervisor.js.map +0 -1
  261. package/dist/donation/grant-service.d.ts +0 -80
  262. package/dist/donation/grant-service.js +0 -210
  263. package/dist/donation/grant-service.js.map +0 -1
  264. package/dist/donation/grant-store.d.ts +0 -38
  265. package/dist/donation/grant-store.js +0 -135
  266. package/dist/donation/grant-store.js.map +0 -1
  267. package/dist/donation/index.d.ts +0 -20
  268. package/dist/donation/index.js +0 -17
  269. package/dist/donation/index.js.map +0 -1
  270. package/dist/donation/types.d.ts +0 -224
  271. package/dist/donation/types.js +0 -40
  272. package/dist/donation/types.js.map +0 -1
  273. package/dist/installer/identity.js.map +0 -1
  274. package/dist/owner/index.d.ts +0 -11
  275. package/dist/owner/index.js +0 -11
  276. package/dist/owner/index.js.map +0 -1
  277. package/dist/owner/owner-node-client.d.ts +0 -110
  278. package/dist/owner/owner-node-client.js +0 -184
  279. package/dist/owner/owner-node-client.js.map +0 -1
  280. package/dist/server/routes/bootstrap-node-validation.d.ts +0 -115
  281. package/dist/server/routes/bootstrap-node-validation.js +0 -197
  282. package/dist/server/routes/bootstrap-node-validation.js.map +0 -1
  283. package/dist/server/routes/grants-admin.d.ts +0 -29
  284. package/dist/server/routes/grants-admin.js +0 -58
  285. package/dist/server/routes/grants-admin.js.map +0 -1
  286. package/dist/server/routes/grants.d.ts +0 -28
  287. package/dist/server/routes/grants.js +0 -165
  288. package/dist/server/routes/grants.js.map +0 -1
  289. package/dist/server/routes/provision-request-validation.d.ts +0 -45
  290. package/dist/server/routes/provision-request-validation.js +0 -97
  291. package/dist/server/routes/provision-request-validation.js.map +0 -1
  292. package/dist/server/routes/strands.d.ts +0 -20
  293. package/dist/server/routes/strands.js +0 -38
  294. package/dist/server/routes/strands.js.map +0 -1
  295. package/dist/server/routes/trust-circle.d.ts +0 -17
  296. package/dist/server/routes/trust-circle.js +0 -35
  297. package/dist/server/routes/trust-circle.js.map +0 -1
  298. package/dist/strands/index.d.ts +0 -7
  299. package/dist/strands/index.js +0 -6
  300. package/dist/strands/index.js.map +0 -1
  301. package/dist/strands/strand-service.d.ts +0 -50
  302. package/dist/strands/strand-service.js +0 -103
  303. package/dist/strands/strand-service.js.map +0 -1
  304. package/dist/strands/types.d.ts +0 -93
  305. package/dist/strands/types.js +0 -23
  306. package/dist/strands/types.js.map +0 -1
  307. package/dist/ui/assets/index-C6nELNfC.js +0 -23
  308. package/dist/ui/assets/index-C6nELNfC.js.map +0 -1
  309. package/dist/ui/assets/index-TtIYSeZO.css +0 -1
  310. package/src/__tests__/cli-invite.smoke.test.ts +0 -192
  311. package/src/__tests__/host-owner.smoke.test.ts +0 -123
  312. package/src/__tests__/orchestrator-owner.test.ts +0 -234
  313. package/src/__tests__/orchestrator-pin-keys.test.ts +0 -152
  314. package/src/auth/__tests__/duration.test.ts +0 -28
  315. package/src/auth/__tests__/self-label.test.ts +0 -85
  316. package/src/auth/__tests__/trust-circle-integration.test.ts +0 -152
  317. package/src/auth/__tests__/trust-circle-store.test.ts +0 -115
  318. package/src/auth/__tests__/trust-circle.test.ts +0 -445
  319. package/src/auth/duration.ts +0 -33
  320. package/src/auth/index.ts +0 -21
  321. package/src/auth/self-label.ts +0 -74
  322. package/src/auth/trust-circle-store.ts +0 -155
  323. package/src/auth/trust-circle.ts +0 -395
  324. package/src/auth/types.ts +0 -99
  325. package/src/donation/__tests__/donation-service.test.ts +0 -1193
  326. package/src/donation/__tests__/donation-supervisor.test.ts +0 -548
  327. package/src/donation/__tests__/grant-service.test.ts +0 -213
  328. package/src/donation/__tests__/grant-store.test.ts +0 -113
  329. package/src/donation/donation-service.ts +0 -1033
  330. package/src/donation/donation-store.ts +0 -176
  331. package/src/donation/donation-supervisor.ts +0 -411
  332. package/src/donation/grant-service.ts +0 -252
  333. package/src/donation/grant-store.ts +0 -152
  334. package/src/donation/index.ts +0 -65
  335. package/src/donation/types.ts +0 -260
  336. package/src/owner/__tests__/owner-node-client.test.ts +0 -194
  337. package/src/owner/index.ts +0 -12
  338. package/src/owner/owner-node-client.ts +0 -259
  339. package/src/server/__tests__/bootstrap-node-validation.test.ts +0 -138
  340. package/src/server/__tests__/grants-admin-route.test.ts +0 -171
  341. package/src/server/__tests__/grants-route.test.ts +0 -394
  342. package/src/server/__tests__/strands-route.test.ts +0 -234
  343. package/src/server/routes/bootstrap-node-validation.ts +0 -206
  344. package/src/server/routes/grants-admin.ts +0 -74
  345. package/src/server/routes/grants.ts +0 -193
  346. package/src/server/routes/provision-request-validation.ts +0 -105
  347. package/src/server/routes/strands.ts +0 -54
  348. package/src/server/routes/trust-circle.ts +0 -49
  349. package/src/strands/__tests__/strand-service.test.ts +0 -165
  350. package/src/strands/index.ts +0 -14
  351. package/src/strands/strand-service.ts +0 -135
  352. package/src/strands/types.ts +0 -102
@@ -1,515 +1,608 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
- import { mkdtempSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
-
6
- import { NatService, createNatHandlers } from '../nat-service.js';
7
- import { ExternalIpDetector } from '../external-ip.js';
8
- import type { PortMapper, PortMappingResult } from '../port-mapper.js';
9
- import type { SecretsStore } from '../secrets/index.js';
10
- 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)),
30
- };
31
- }
32
- async verify() { return true; }
33
- async externalIp() { return this.externalIpValue; }
34
- async unmap() { this.unmapCalls++; }
35
- async stop() { this.stopCalls++; }
36
- }
37
-
38
- function makeSecrets(seed: Record<string, string> = {}): SecretsStore {
39
- const m = new Map(Object.entries(seed));
40
- return {
41
- async set(a, v) { m.set(a, v); },
42
- async get(a) { return m.has(a) ? m.get(a)! : null; },
43
- async delete(a) { return m.delete(a); },
44
- async list() { return [...m.keys()]; },
45
- };
46
- }
47
-
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
- function makeDetector(opts: { router?: string | null; pub?: string | null }): ExternalIpDetector {
79
- return new ExternalIpDetector({
80
- routerProbe: opts.router === undefined ? undefined : async () => opts.router ?? null,
81
- fetch: (async () => ({
82
- ok: opts.pub != null,
83
- status: opts.pub != null ? 200 : 500,
84
- async text() { return opts.pub ?? ''; },
85
- })) as unknown as typeof fetch,
86
- publicIpUrls: ['https://stub.test'],
87
- });
88
- }
89
-
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
- */
96
- function okFetch(): typeof fetch {
97
- return (async () => ({
98
- ok: true,
99
- status: 200,
100
- statusText: 'OK',
101
- async text() { return 'OK'; },
102
- })) as unknown as typeof fetch;
103
- }
104
-
105
- let tmpRoot: string;
106
- beforeEach(() => {
107
- tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-nat-svc-'));
108
- });
109
- afterEach(() => {
110
- try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
111
- });
112
-
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
- });
131
-
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');
145
- });
146
-
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
- });
161
-
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
- });
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
- });
198
-
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' }),
207
- });
208
- await svc.start();
209
- const addrs = await svc.getInviteAddresses();
210
- expect(addrs).toEqual(['/ip4/203.0.113.42/tcp/4001/p2p/12D3KooWHost']);
211
- });
212
-
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']);
226
- });
227
-
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
- });
249
-
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' }),
258
- });
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' });
265
- });
266
-
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' }),
275
- });
276
- await svc.start();
277
- expect(mapper.mapCalls).toBe(1);
278
-
279
- let s = await svc.putSettings({ upnpEnabled: false });
280
- expect(s.portMode).toBe('disabled');
281
-
282
- s = await svc.putSettings({ upnpEnabled: true });
283
- expect(s.portMode).toBe('auto-upnp');
284
- expect(mapper.mapCalls).toBeGreaterThanOrEqual(2);
285
- });
286
-
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();
300
- });
301
-
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 });
314
- });
315
- });
316
-
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'],
322
- };
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']);
333
- });
334
-
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' });
349
- });
350
-
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' }),
358
- });
359
- await svc.start();
360
-
361
- const fired: string[][] = [];
362
- svc.onAddressesChanged((addrs) => { fired.push(addrs); });
363
-
364
- await svc.putSettings({ externalPort: 4002 });
365
- await svc.testReachability();
366
-
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);
370
- });
371
-
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);
387
- });
388
- });
389
-
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();
409
-
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
412
- });
413
-
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,
424
- });
425
-
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);
436
- });
437
-
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,
452
- });
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');
461
- });
462
-
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 */ });
479
-
480
- await svc.start(); // does not hang retrying a non-transient failure
481
- expect(calls).toBe(1);
482
- });
483
- });
484
-
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(),
495
- });
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();
503
- expect(status.lastTestedAt).not.toBeNull();
504
-
505
- const after = await h.putSettings({ externalPort: 4002 });
506
- expect(after.externalPort).toBe(4002);
507
-
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');
514
- });
515
- });
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
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';
8
+ import { ExternalIpDetector } from '../external-ip.js';
9
+ import { NatError } from '../types.js';
10
+ import type { GatewayInfo, PortMapper, PortMapRequest, PortMapResult } from '../port-mapper.js';
11
+ import type { SecretsStore } from '../secrets/index.js';
12
+ import { ddnsAccount } from '../secrets/index.js';
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),
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);
91
+ }
92
+ }
93
+
94
+ function makeSecrets(seed: Record<string, string> = {}): SecretsStore {
95
+ const m = new Map(Object.entries(seed));
96
+ return {
97
+ async set(a, v) { m.set(a, v); },
98
+ async get(a) { return m.has(a) ? m.get(a)! : null; },
99
+ async delete(a) { return m.delete(a); },
100
+ async list() { return [...m.keys()]; },
101
+ };
102
+ }
103
+
104
+ function makeDetector(opts: { router?: string | null; pub?: string | null }): ExternalIpDetector {
105
+ return new ExternalIpDetector({
106
+ routerProbe: opts.router === undefined ? undefined : async () => opts.router ?? null,
107
+ fetch: (async () => ({
108
+ ok: opts.pub != null,
109
+ status: opts.pub != null ? 200 : 500,
110
+ async text() { return opts.pub ?? ''; },
111
+ })) as unknown as typeof fetch,
112
+ publicIpUrls: ['https://stub.test'],
113
+ });
114
+ }
115
+
116
+ /** The DuckDNS call a `putDdns` lands on; no case here reaches www.duckdns.org. */
117
+ function okFetch(): typeof fetch {
118
+ return (async () => ({
119
+ ok: true,
120
+ status: 200,
121
+ statusText: 'OK',
122
+ async text() { return 'OK'; },
123
+ })) as unknown as typeof fetch;
124
+ }
125
+
126
+ let tmpRoot: string;
127
+ let clock: number;
128
+ const now = (): Date => new Date(clock);
129
+
130
+ beforeEach(() => {
131
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-nat-svc-'));
132
+ clock = Date.parse('2026-01-01T00:00:00Z');
133
+ });
134
+ afterEach(() => {
135
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
136
+ });
137
+
138
+ interface Rig {
139
+ svc: NatService;
140
+ mapper: FakeMapper;
141
+ nodes: FakeNodes;
142
+ }
143
+
144
+ function rig(opts: {
145
+ mapper?: FakeMapper;
146
+ nodes?: FakeNodes;
147
+ detector?: ExternalIpDetector;
148
+ fetch?: typeof fetch;
149
+ secrets?: SecretsStore;
150
+ /** The public IPv4 an interface holds; default none (a machine behind a router). */
151
+ publicInterfaceIp?: string | null;
152
+ } = {}): Rig {
153
+ const mapper = opts.mapper ?? new FakeMapper();
154
+ const nodes = opts.nodes ?? new FakeNodes();
155
+ const svc = new NatService({
156
+ rootDir: tmpRoot,
157
+ nodeSource: nodes,
158
+ now,
159
+ secretsStore: opts.secrets ?? makeSecrets(),
160
+ portMapper: mapper,
161
+ externalIpDetector: opts.detector ?? makeDetector({ pub: '203.0.113.10' }),
162
+ // Deterministic: never read the test machine's real interfaces.
163
+ publicInterfaceAddress: () => opts.publicInterfaceIp ?? null,
164
+ ...(opts.fetch ? { fetch: opts.fetch } : {}),
165
+ });
166
+ nodes.announce = (id, ports) => svc.publicAddressesFor(id, ports);
167
+ return { svc, mapper, nodes };
168
+ }
169
+
170
+ /** `start()` maps in the background; a second pass is sequenced after it. */
171
+ async function startAndSettle(svc: NatService): Promise<void> {
172
+ await svc.start();
173
+ await svc.reconcile();
174
+ }
175
+
176
+ function node(svc: NatService, id: string) {
177
+ const found = svc.getStatus().nodes.find((n) => n.nodeId === id);
178
+ if (!found) throw new Error(`node ${id} not in status`);
179
+ return found;
180
+ }
181
+
182
+ /** Poll until `check` holds; for passes the service queues on its own, which nothing public awaits. */
183
+ async function waitUntil(check: () => boolean, timeoutMs = 2_000): Promise<void> {
184
+ const deadline = Date.now() + timeoutMs;
185
+ while (!check()) {
186
+ if (Date.now() > deadline) throw new Error('condition not met in time');
187
+ await new Promise<void>((r) => setTimeout(r, 10));
188
+ }
189
+ }
190
+
191
+ describe('NatService — mapping table', () => {
192
+ it('maps both ports of every running node, and stop() unmaps nothing', async () => {
193
+ const { svc, mapper, nodes } = rig({ detector: makeDetector({ router: '203.0.113.10', pub: '203.0.113.10' }) });
194
+ nodes.add('grn_b', { p2p: 4001, ws: 10001 });
195
+ nodes.add('grn_a', { p2p: 10003, ws: 10004 });
196
+ await startAndSettle(svc);
197
+
198
+ const status = svc.getStatus();
199
+ expect(status.gateway).toMatchObject({ found: true, lanAddress: '192.168.1.20', routerExternalIp: '203.0.113.10' });
200
+ expect(status.directReachability).toBe('reachable');
201
+ expect(node(svc, 'grn_a')).toMatchObject({
202
+ verdict: 'mapped',
203
+ tcp: { internalPort: 10003, externalPort: 10003, source: 'upnp' },
204
+ ws: { internalPort: 10004, externalPort: 10004, source: 'upnp' },
205
+ publicAddrs: ['/ip4/203.0.113.10/tcp/10003', '/ip4/203.0.113.10/tcp/10004/ws'],
206
+ });
207
+ expect(node(svc, 'grn_b').tcp.externalPort).toBe(4001);
208
+ expect(mapper.mapped.size).toBe(4);
209
+
210
+ await svc.stop();
211
+ expect(mapper.unmapCalls).toEqual([]);
212
+ });
213
+
214
+ it('a router that caps its mappings fails per port; the other node keeps its routes across a renewal', async () => {
215
+ const mapper = new FakeMapper();
216
+ mapper.cap = 3;
217
+ const { svc, nodes } = rig({ mapper });
218
+ nodes.add('a', { p2p: 10003, ws: 10004 });
219
+ nodes.add('b', { p2p: 10005, ws: 10006 });
220
+ await startAndSettle(svc);
221
+
222
+ expect(node(svc, 'a').verdict).toBe('mapped');
223
+ const b = node(svc, 'b');
224
+ expect(b.verdict).toBe('unreachable');
225
+ expect(b.tcp.externalPort).toBe(10005);
226
+ expect(b.ws).toMatchObject({ externalPort: null, error: expect.stringContaining('full') });
227
+ expect(b.reason).toContain('WebSocket port 10006');
228
+ expect(b.reason).toContain('192.168.1.20');
229
+ expect(b.publicAddrs).toEqual(['/ip4/203.0.113.10/tcp/10005']);
230
+ expect(svc.getStatus().directReachability).toBe('unreachable');
231
+
232
+ await svc.renewMappings();
233
+ expect(node(svc, 'a')).toMatchObject({
234
+ verdict: 'mapped',
235
+ tcp: { externalPort: 10003, source: 'upnp' },
236
+ ws: { externalPort: 10004, source: 'upnp' },
237
+ });
238
+ expect(node(svc, 'b').ws?.externalPort).toBeNull();
239
+ });
240
+
241
+ it('the route and the public addresses carry the external port the router assigned', async () => {
242
+ const mapper = new FakeMapper();
243
+ mapper.offset = 1;
244
+ const { svc, nodes } = rig({ mapper });
245
+ nodes.add('a', { p2p: 10003, ws: 10004 });
246
+ await startAndSettle(svc);
247
+
248
+ expect(node(svc, 'a')).toMatchObject({
249
+ tcp: { internalPort: 10003, externalPort: 10004 },
250
+ ws: { internalPort: 10004, externalPort: 10005 },
251
+ publicAddrs: ['/ip4/203.0.113.10/tcp/10004', '/ip4/203.0.113.10/tcp/10005/ws'],
252
+ });
253
+ });
254
+
255
+ it('a handle without a WebSocket port maps only its TCP port', async () => {
256
+ const { svc, nodes } = rig();
257
+ nodes.add('old', { p2p: 10003 });
258
+ await startAndSettle(svc);
259
+
260
+ expect(node(svc, 'old')).toMatchObject({ verdict: 'mapped', ws: null, publicAddrs: ['/ip4/203.0.113.10/tcp/10003'] });
261
+ });
262
+
263
+ it('a terminated node is unmapped at once and its manual forward is dropped', async () => {
264
+ const { svc, mapper, nodes } = rig();
265
+ nodes.add('a', { p2p: 10003, ws: 10004 });
266
+ await startAndSettle(svc);
267
+ await svc.putForward('a', { tcp: 40000 });
268
+ expect(readFileSync(join(tmpRoot, 'nat.json'), 'utf8')).toContain('"a"');
269
+
270
+ nodes.remove('a');
271
+ await svc.reconcile(); // sequenced after the pass the removal event queued
272
+
273
+ expect(mapper.unmapCalls).toContain(10004);
274
+ expect(mapper.mapped.size).toBe(0);
275
+ expect(svc.getStatus().nodes).toEqual([]);
276
+ expect(svc.getSettings().forwards).toEqual({});
277
+ expect(readFileSync(join(tmpRoot, 'nat.json'), 'utf8')).not.toContain('"a"');
278
+ });
279
+
280
+ it('a node stopped inside the grace keeps its mapping; past it the mapping is released and a respawn re-maps', async () => {
281
+ const { svc, mapper, nodes } = rig();
282
+ nodes.add('a', { p2p: 10003, ws: 10004 });
283
+ await startAndSettle(svc);
284
+
285
+ nodes.setStatus('a', 'stopped');
286
+ await svc.reconcile(); // the stop event's own pass records when the node was first seen stopped
287
+ clock += NAT_UNMAP_GRACE_MS - 1_000;
288
+ await svc.reconcile();
289
+ expect(mapper.unmapCalls).toEqual([]);
290
+ expect(node(svc, 'a')).toMatchObject({ running: false, tcp: { externalPort: 10003 } });
291
+
292
+ clock += 1_000;
293
+ await svc.reconcile();
294
+ expect(mapper.unmapCalls).toEqual([10003, 10004]);
295
+ expect(node(svc, 'a').tcp.externalPort).toBeNull();
296
+
297
+ nodes.setStatus('a', 'running');
298
+ await svc.reconcile();
299
+ expect(node(svc, 'a')).toMatchObject({ running: true, verdict: 'mapped', tcp: { externalPort: 10003 } });
300
+ });
301
+
302
+ it('a node spawned while an event-triggered pass runs is mapped by the pass that follows it', async () => {
303
+ const mapper = new FakeMapper();
304
+ const { svc, nodes } = rig({ mapper });
305
+ await startAndSettle(svc);
306
+
307
+ // The spawn of `a` queues an event pass; `b` spawns while that pass is mapping a's first port.
308
+ mapper.onMap = () => {
309
+ mapper.onMap = undefined;
310
+ nodes.add('b', { p2p: 10005, ws: 10006 });
311
+ };
312
+ nodes.add('a', { p2p: 10003, ws: 10004 });
313
+
314
+ await waitUntil(() => svc.getStatus().nodes.some((n) => n.nodeId === 'b' && n.verdict === 'mapped'));
315
+ expect(node(svc, 'a').verdict).toBe('mapped');
316
+ });
317
+
318
+ it('a router that answers a later probe maps the running nodes without a restart', async () => {
319
+ const mapper = new FakeMapper();
320
+ mapper.gateway = null;
321
+ const { svc, nodes } = rig({ mapper });
322
+ nodes.add('a', { p2p: 10003, ws: 10004 });
323
+ await startAndSettle(svc);
324
+ expect(node(svc, 'a').reason).toContain('No UPnP router answered');
325
+
326
+ mapper.gateway = { lanAddress: '192.168.1.20', routerHost: '192.168.1.1' };
327
+ await svc.redetect();
328
+ expect(svc.getStatus().gateway).toMatchObject({ found: true, lanAddress: '192.168.1.20' });
329
+ expect(node(svc, 'a')).toMatchObject({ verdict: 'mapped', tcp: { externalPort: 10003, source: 'upnp' } });
330
+ });
331
+ });
332
+
333
+ describe('NatService — manual forwards and the UPnP toggle', () => {
334
+ it('a manual forward wins over UPnP and is re-requested when cleared', async () => {
335
+ const { svc, mapper, nodes } = rig();
336
+ nodes.add('a', { p2p: 10003, ws: 10004 });
337
+ await startAndSettle(svc);
338
+
339
+ let status = await svc.putForward('a', { tcp: 40000 });
340
+ expect(mapper.unmapCalls).toEqual([10003]);
341
+ expect(status.nodes[0]).toMatchObject({
342
+ verdict: 'manual',
343
+ tcp: { externalPort: 40000, source: 'manual' },
344
+ ws: { externalPort: 10004, source: 'upnp' },
345
+ publicAddrs: ['/ip4/203.0.113.10/tcp/40000', '/ip4/203.0.113.10/tcp/10004/ws'],
346
+ });
347
+ expect(JSON.parse(readFileSync(join(tmpRoot, 'nat.json'), 'utf8')).forwards).toEqual({ a: { tcp: 40000 } });
348
+
349
+ status = await svc.putForward('a', { tcp: null });
350
+ expect(status.nodes[0]).toMatchObject({ verdict: 'mapped', tcp: { externalPort: 10003, source: 'upnp' } });
351
+ expect(svc.getSettings().forwards).toEqual({});
352
+ });
353
+
354
+ it('putForward refuses an unknown node and an out-of-range port', async () => {
355
+ const { svc, nodes } = rig();
356
+ nodes.add('a', { p2p: 10003, ws: 10004 });
357
+ await startAndSettle(svc);
358
+
359
+ await expect(svc.putForward('nope', { tcp: 1 })).rejects.toMatchObject({ code: 'unknown_node' });
360
+ await expect(svc.putForward('a', { ws: 70000 })).rejects.toMatchObject({ code: 'invalid_config' });
361
+ });
362
+
363
+ it('turning UPnP off releases upnp routes and keeps manual ones', async () => {
364
+ const { svc, mapper, nodes } = rig();
365
+ nodes.add('a', { p2p: 10003, ws: 10004 });
366
+ await startAndSettle(svc);
367
+ await svc.putForward('a', { ws: 40004 });
368
+
369
+ const status = await svc.putSettings({ upnpEnabled: false });
370
+ expect(mapper.unmapCalls).toEqual([10004, 10003]);
371
+ const a = status.nodes[0]!;
372
+ expect(a.ws).toMatchObject({ externalPort: 40004, source: 'manual' });
373
+ expect(a.tcp).toMatchObject({ externalPort: null, source: null });
374
+ expect(a.verdict).toBe('unreachable');
375
+ expect(a.reason).toContain('UPnP is off');
376
+ expect(a.reason).toContain('TCP port 10003');
377
+
378
+ await svc.putSettings({ upnpEnabled: true });
379
+ expect(node(svc, 'a')).toMatchObject({ verdict: 'manual', tcp: { externalPort: 10003, source: 'upnp' } });
380
+ });
381
+ });
382
+
383
+ describe('NatService — public addresses', () => {
384
+ it('predicts the identity mapping for a port with no route yet, but not after a failed attempt', async () => {
385
+ const mapper = new FakeMapper();
386
+ mapper.cap = 1;
387
+ const { svc, nodes } = rig({ mapper });
388
+ await startAndSettle(svc);
389
+
390
+ expect(svc.publicAddressesFor('new', { p2p: 20000, ws: 20001 })).toEqual([
391
+ '/ip4/203.0.113.10/tcp/20000',
392
+ '/ip4/203.0.113.10/tcp/20001/ws',
393
+ ]);
394
+
395
+ nodes.add('new', { p2p: 20000, ws: 20001 });
396
+ await svc.reconcile();
397
+ expect(svc.publicAddressesFor('new', { p2p: 20000, ws: 20001 })).toEqual(['/ip4/203.0.113.10/tcp/20000']);
398
+ });
399
+
400
+ it('predicts nothing without a gateway, and a manual forward regardless', async () => {
401
+ const mapper = new FakeMapper();
402
+ mapper.gateway = null;
403
+ const { svc, nodes } = rig({ mapper });
404
+ nodes.add('a', { p2p: 10003, ws: 10004 });
405
+ await startAndSettle(svc);
406
+
407
+ expect(svc.getStatus().gateway).toMatchObject({ found: false, lastError: expect.stringContaining('no UPnP gateway') });
408
+ expect(svc.publicAddressesFor('a', { p2p: 10003, ws: 10004 })).toEqual([]);
409
+ expect(node(svc, 'a').reason).toContain('No UPnP router answered');
410
+
411
+ await svc.putForward('a', { tcp: 40000, ws: 40001 });
412
+ expect(svc.publicAddressesFor('a', { p2p: 10003, ws: 10004 })).toEqual([
413
+ '/ip4/203.0.113.10/tcp/40000',
414
+ '/ip4/203.0.113.10/tcp/40001/ws',
415
+ ]);
416
+ expect(node(svc, 'a').verdict).toBe('manual');
417
+ });
418
+
419
+ it('uses the full DDNS name of the provider as the host part, even when typed as a bare subdomain', async () => {
420
+ const { svc, nodes } = rig({
421
+ secrets: makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' }),
422
+ fetch: okFetch(),
423
+ });
424
+ nodes.add('a', { p2p: 10003, ws: 10004 });
425
+ await startAndSettle(svc);
426
+
427
+ const status = await svc.putDdns({ providerId: 'duckdns', hostname: 'foo', config: { token: 'T' } });
428
+ expect(status.ddns).toMatchObject({ providerId: 'duckdns', hostname: 'foo.duckdns.org' });
429
+ expect(status.nodes[0]!.publicAddrs).toEqual(['/dns4/foo.duckdns.org/tcp/10003', '/dns4/foo.duckdns.org/tcp/10004/ws']);
430
+ });
431
+
432
+ it('under CGNAT a upnp route yields no address and the host rolls up to cgnat; a manual route still counts', async () => {
433
+ const { svc, nodes } = rig({ detector: makeDetector({ router: '100.64.0.5', pub: '203.0.113.10' }) });
434
+ nodes.add('a', { p2p: 10003, ws: 10004 });
435
+ await startAndSettle(svc);
436
+
437
+ const status = svc.getStatus();
438
+ expect(status.cgnatDetected).toBe(true);
439
+ expect(status.directReachability).toBe('cgnat');
440
+ expect(status.nodes[0]).toMatchObject({ verdict: 'unreachable', publicAddrs: [] });
441
+ expect(status.nodes[0]!.reason).toContain('carrier-grade NAT');
442
+
443
+ const after = await svc.putForward('a', { tcp: 40000, ws: 40001 });
444
+ expect(after.nodes[0]).toMatchObject({
445
+ verdict: 'manual',
446
+ publicAddrs: ['/ip4/203.0.113.10/tcp/40000', '/ip4/203.0.113.10/tcp/40001/ws'],
447
+ });
448
+ expect(after.directReachability).toBe('reachable');
449
+ });
450
+ });
451
+
452
+ describe('NatService — stale announced addresses', () => {
453
+ it('asks to restart only the node whose router-assigned port differs from the one it announced, and only once', async () => {
454
+ const mapper = new FakeMapper();
455
+ mapper.reassigned.set(10006, 30006);
456
+ const { svc, nodes } = rig({ mapper });
457
+ await startAndSettle(svc);
458
+ const stale: string[] = [];
459
+ svc.onNodeAddressesStale((id) => { stale.push(id); });
460
+
461
+ // Both spawn announcing the predicted identity mapping.
462
+ nodes.add('same', { p2p: 10003, ws: 10004 });
463
+ nodes.add('moved', { p2p: 10005, ws: 10006 });
464
+ await svc.reconcile();
465
+ expect(stale).toEqual(['moved']);
466
+
467
+ nodes.restart('moved');
468
+ clock += NAT_ADDRESS_RESTART_MIN_INTERVAL_MS;
469
+ await svc.reconcile();
470
+ expect(nodes.listNodes().find((n) => n.id === 'moved')!.announcedAddrs)
471
+ .toEqual(['/ip4/203.0.113.10/tcp/10005', '/ip4/203.0.113.10/tcp/30006/ws']);
472
+ expect(stale).toEqual(['moved']);
473
+ });
474
+
475
+ it('defers a second restart of one node until the rate-limit window has passed', async () => {
476
+ const mapper = new FakeMapper();
477
+ mapper.reassigned.set(10004, 30004);
478
+ const { svc, nodes } = rig({ mapper });
479
+ await startAndSettle(svc);
480
+ const stale: string[] = [];
481
+ svc.onNodeAddressesStale((id) => { stale.push(id); });
482
+
483
+ nodes.add('a', { p2p: 10003, ws: 10004 });
484
+ await svc.reconcile();
485
+ expect(stale).toEqual(['a']);
486
+
487
+ // Never restarted, so the difference stands at every later pass.
488
+ clock += NAT_ADDRESS_RESTART_MIN_INTERVAL_MS - 1;
489
+ await svc.reconcile();
490
+ expect(stale).toEqual(['a']);
491
+
492
+ clock += 1;
493
+ await svc.reconcile();
494
+ expect(stale).toEqual(['a', 'a']);
495
+ });
496
+ });
497
+
498
+ describe('NatService — external IP and change notification', () => {
499
+ it('a re-detection that loses the public probe keeps the previous result, CGNAT flag included', async () => {
500
+ let offline = false;
501
+ const detector = new ExternalIpDetector({
502
+ routerProbe: async () => '100.64.0.5',
503
+ fetch: (async () => {
504
+ if (offline) throw new Error('offline');
505
+ return { ok: true, status: 200, async text() { return '203.0.113.10'; } };
506
+ }) as unknown as typeof fetch,
507
+ publicIpUrls: ['https://stub.test'],
508
+ });
509
+ const { svc } = rig({ detector });
510
+ await startAndSettle(svc);
511
+ expect(svc.getStatus()).toMatchObject({ externalIp: '203.0.113.10', cgnatDetected: true });
512
+
513
+ // Only the router answers now: taking that result would drop the CGNAT verdict
514
+ // and read the carrier address as the host's.
515
+ offline = true;
516
+ const status = await svc.testReachability();
517
+ expect(status).toMatchObject({ externalIp: '203.0.113.10', cgnatDetected: true });
518
+ expect(status.lastTestedAt).not.toBeNull();
519
+ });
520
+
521
+ it('onChange fires when the snapshot changes and stays quiet across an idle pass', async () => {
522
+ const { svc, nodes } = rig();
523
+ const seen: string[] = [];
524
+ svc.onChange((snap) => { seen.push(snap.directReachability); });
525
+ await startAndSettle(svc);
526
+ const afterStart = seen.length;
527
+ expect(afterStart).toBeGreaterThan(0);
528
+
529
+ await svc.reconcile();
530
+ expect(seen.length).toBe(afterStart);
531
+
532
+ nodes.add('a', { p2p: 10003, ws: 10004 });
533
+ await svc.reconcile();
534
+ expect(seen.length).toBeGreaterThan(afterStart);
535
+ expect(seen[seen.length - 1]).toBe('reachable');
536
+ });
537
+
538
+ it('putDdns with an unknown provider → ddns_provider_unknown', async () => {
539
+ const { svc } = rig();
540
+ await startAndSettle(svc);
541
+ await expect(svc.putDdns({ providerId: 'no-such-provider', hostname: 'x', config: {} }))
542
+ .rejects.toMatchObject({ code: 'ddns_provider_unknown' });
543
+ });
544
+ });
545
+
546
+ describe('NatService network mode', () => {
547
+ it('auto: a public IPv4 on an interface means public mode: direct routes, no UPnP, the interface IP announced', async () => {
548
+ const { svc, mapper, nodes } = rig({ publicInterfaceIp: '198.51.100.7', detector: makeDetector({ pub: '198.51.100.7' }) });
549
+ nodes.add('n1', { p2p: 10002, ws: 10003 });
550
+ await startAndSettle(svc);
551
+
552
+ const status = svc.getStatus();
553
+ expect(status.network).toEqual({ setting: 'auto', mode: 'public', publicInterfaceIp: '198.51.100.7' });
554
+ expect(status.gateway.found).toBe(false);
555
+ expect(mapper.mapCalls).toEqual([]);
556
+ const n1 = node(svc, 'n1');
557
+ expect(n1.verdict).toBe('direct');
558
+ expect(n1.reason).toMatch(/sudo ufw allow 10002:10003\/tcp/);
559
+ expect(n1.tcp).toMatchObject({ internalPort: 10002, externalPort: 10002, source: 'direct' });
560
+ expect(n1.publicAddrs).toEqual(['/ip4/198.51.100.7/tcp/10002', '/ip4/198.51.100.7/tcp/10003/ws']);
561
+ expect(svc.publicAddressesFor('n1', { p2p: 10002, ws: 10003 })).toEqual(n1.publicAddrs);
562
+ expect(status.directReachability).toBe('reachable');
563
+ expect(status.cgnatDetected).toBe(false);
564
+ });
565
+
566
+ it('public mode announces the hostname when one is set, and a manual forward still wins', async () => {
567
+ const { svc, nodes } = rig({ publicInterfaceIp: '198.51.100.7' });
568
+ nodes.add('n1', { p2p: 10002, ws: 10003 });
569
+ await startAndSettle(svc);
570
+ await svc.putSettings({ ddns: { providerId: 'external', hostname: 'kjeib.example.org', externallyManaged: true, intervalMs: 300_000 } });
571
+ expect(node(svc, 'n1').publicAddrs).toEqual(['/dns4/kjeib.example.org/tcp/10002', '/dns4/kjeib.example.org/tcp/10003/ws']);
572
+
573
+ await svc.putForward('n1', { ws: 443 });
574
+ expect(node(svc, 'n1').ws).toMatchObject({ externalPort: 443, source: 'manual' });
575
+ expect(node(svc, 'n1').verdict).toBe('manual');
576
+ });
577
+
578
+ it('auto with only private addresses stays in LAN mode and maps over UPnP as before', async () => {
579
+ const { svc, mapper, nodes } = rig();
580
+ nodes.add('n1', { p2p: 10002, ws: 10003 });
581
+ await startAndSettle(svc);
582
+ expect(svc.getStatus().network.mode).toBe('lan');
583
+ expect(mapper.mapCalls).toEqual(expect.arrayContaining([10002, 10003]));
584
+ expect(node(svc, 'n1').verdict).toBe('mapped');
585
+ });
586
+
587
+ it('switching the setting re-routes: lan → public releases UPnP mappings; public → lan maps again', async () => {
588
+ const { svc, mapper, nodes } = rig();
589
+ nodes.add('n1', { p2p: 10002, ws: 10003 });
590
+ await startAndSettle(svc);
591
+ expect(node(svc, 'n1').verdict).toBe('mapped');
592
+
593
+ await svc.putSettings({ network: 'public' });
594
+ expect(svc.getStatus().network).toMatchObject({ setting: 'public', mode: 'public' });
595
+ expect(mapper.unmapCalls).toEqual(expect.arrayContaining([10002, 10003]));
596
+ expect(node(svc, 'n1').verdict).toBe('direct');
597
+
598
+ await svc.putSettings({ network: 'lan' });
599
+ expect(svc.getStatus().network.mode).toBe('lan');
600
+ expect(node(svc, 'n1').verdict).toBe('mapped');
601
+ });
602
+
603
+ it('refuses an unknown network setting', async () => {
604
+ const { svc } = rig();
605
+ await svc.start();
606
+ await expect(svc.putSettings({ network: 'vpn' as never })).rejects.toMatchObject({ code: 'invalid_config' });
607
+ });
608
+ });