@serfab/cadre-host 1.12.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,329 +1,241 @@
1
- import debug from 'debug';
2
-
3
- import { NatError, type PortForwardMode } from './types.js';
4
-
5
- const log = debug('cadre:host:nat-port-mapper');
6
-
7
- /** Default lease TTL: 1 hour. The router must keep our mapping at least this long. */
8
- export const DEFAULT_LEASE_TTL_MS = 60 * 60 * 1000;
9
-
10
- /** Default refresh cadence: half the TTL, so two refreshes per lease. */
11
- export const DEFAULT_REFRESH_MS = 30 * 60 * 1000;
12
-
13
- /** Result of a successful map(). */
14
- export interface PortMappingResult {
15
- mode: 'auto-upnp' | 'auto-natpmp';
16
- externalIp: string | null;
17
- externalPort: number;
18
- internalPort: number;
19
- protocol: 'tcp' | 'udp';
20
- leaseExpiresAt: Date;
21
- }
22
-
23
- export interface PortMapOptions {
24
- externalPort: number;
25
- internalPort: number;
26
- protocol?: 'tcp' | 'udp';
27
- /** Lease TTL in milliseconds. Defaults to DEFAULT_LEASE_TTL_MS. */
28
- ttlMs?: number;
29
- }
30
-
31
- /**
32
- * Wrapper around the underlying UPnP/NAT-PMP client. Abstract so tests can
33
- * stub the network behavior without spinning up a real router.
34
- */
35
- export interface PortMapper {
36
- /** Attempt to install a port mapping. Throws NatError(mapping_failed) on failure. */
37
- map(opts: PortMapOptions): Promise<PortMappingResult>;
38
- /** Re-query the router; returns true if the mapping is still present. */
39
- verify(externalPort: number, protocol?: 'tcp' | 'udp'): Promise<boolean>;
40
- /** Query the router's WAN/external IP, if exposed. */
41
- externalIp(): Promise<string | null>;
42
- /** Remove a mapping installed by this mapper. */
43
- unmap(externalPort: number, protocol?: 'tcp' | 'udp'): Promise<void>;
44
- /** Release client resources (timers, sockets). Idempotent. */
45
- stop(): Promise<void>;
46
- }
47
-
48
- /** Constructor for the default mapper. Loads `@achingbrain/nat-port-mapper` dynamically. */
49
- export type PortMapperFactory = () => Promise<PortMapper>;
50
-
51
- interface NatPortMapperClient {
52
- externalIp(): Promise<string>;
53
- map(opts: Record<string, unknown>): Promise<unknown>;
54
- unmap(opts: Record<string, unknown>): Promise<void>;
55
- stop(): Promise<void>;
56
- }
57
-
58
- interface NatPortMapperModule {
59
- upnpNat(opts?: Record<string, unknown>): Promise<NatPortMapperClient>;
60
- }
61
-
62
- /**
63
- * Default factory: loads @achingbrain/nat-port-mapper. Wrapped in dynamic
64
- * import so a missing dep at install time doesn't crash the package.
65
- */
66
- export async function createDefaultPortMapper(opts: {
67
- description?: string;
68
- ttlMs?: number;
69
- } = {}): Promise<PortMapper> {
70
- let mod: NatPortMapperModule;
71
- try {
72
- mod = (await import('@achingbrain/nat-port-mapper' as string)) as NatPortMapperModule;
73
- } catch (err) {
74
- throw new NatError(
75
- 'mapping_failed',
76
- `@achingbrain/nat-port-mapper is not installed: ${(err as Error).message}`,
77
- );
78
- }
79
-
80
- let client: NatPortMapperClient;
81
- try {
82
- client = await mod.upnpNat({
83
- description: opts.description ?? 'sereus-cadre-host',
84
- ttl: Math.round((opts.ttlMs ?? DEFAULT_LEASE_TTL_MS) / 1000),
85
- });
86
- } catch (err) {
87
- throw new NatError(
88
- 'mapping_failed',
89
- `upnpNat() failed: ${(err as Error).message}`,
90
- );
91
- }
92
-
93
- return new NatPortMapperAdapter(client, opts.ttlMs ?? DEFAULT_LEASE_TTL_MS);
94
- }
95
-
96
- class NatPortMapperAdapter implements PortMapper {
97
- constructor(
98
- private readonly client: NatPortMapperClient,
99
- private readonly defaultTtlMs: number,
100
- ) {}
101
-
102
- async map(opts: PortMapOptions): Promise<PortMappingResult> {
103
- const ttlMs = opts.ttlMs ?? this.defaultTtlMs;
104
- const protocol = opts.protocol ?? 'tcp';
105
- let externalIp: string | null = null;
106
- try {
107
- externalIp = await this.client.externalIp();
108
- } catch (err) {
109
- log('externalIp() failed: %s', (err as Error).message);
110
- }
111
- try {
112
- await this.client.map({
113
- localPort: opts.internalPort,
114
- externalPort: opts.externalPort,
115
- protocol,
116
- ttl: Math.round(ttlMs / 1000),
117
- });
118
- } catch (err) {
119
- throw new NatError(
120
- 'mapping_failed',
121
- `router refused mapping for port ${opts.externalPort}: ${(err as Error).message}`,
122
- );
123
- }
124
- return {
125
- mode: 'auto-upnp',
126
- externalIp,
127
- externalPort: opts.externalPort,
128
- internalPort: opts.internalPort,
129
- protocol,
130
- leaseExpiresAt: new Date(Date.now() + ttlMs),
131
- };
132
- }
133
-
134
- async verify(externalPort: number, protocol: 'tcp' | 'udp' = 'tcp'): Promise<boolean> {
135
- void externalPort;
136
- void protocol;
137
- // The current library doesn't expose an introspection API. Treat a
138
- // working externalIp() as a proxy for "router still reachable" — refresh
139
- // failures will surface via re-running map().
140
- try {
141
- await this.client.externalIp();
142
- return true;
143
- } catch (err) {
144
- log('verify() failed: %s', (err as Error).message);
145
- return false;
146
- }
147
- }
148
-
149
- async externalIp(): Promise<string | null> {
150
- try {
151
- return await this.client.externalIp();
152
- } catch (err) {
153
- log('externalIp() failed: %s', (err as Error).message);
154
- return null;
155
- }
156
- }
157
-
158
- async unmap(externalPort: number, protocol: 'tcp' | 'udp' = 'tcp'): Promise<void> {
159
- try {
160
- await this.client.unmap({ externalPort, protocol });
161
- } catch (err) {
162
- log('unmap(%d) failed: %s', externalPort, (err as Error).message);
163
- }
164
- }
165
-
166
- async stop(): Promise<void> {
167
- try {
168
- await this.client.stop();
169
- } catch (err) {
170
- log('stop() failed: %s', (err as Error).message);
171
- }
172
- }
173
- }
174
-
175
- /** Public-facing port-mapper service: wraps a PortMapper with refresh + state. */
176
- export interface PortMapperServiceOptions {
177
- mapper: PortMapper;
178
- externalPort: number;
179
- internalPort: number;
180
- protocol?: 'tcp' | 'udp';
181
- ttlMs?: number;
182
- refreshMs?: number;
183
- /** Clock override for tests. */
184
- now?: () => Date;
185
- /** Timer overrides for tests (must return a handle usable by `clear`). */
186
- setTimer?: (fn: () => void, ms: number) => NodeJS.Timeout;
187
- clearTimer?: (h: NodeJS.Timeout) => void;
188
- }
189
-
190
- /** State snapshot returned by `PortMapperService.getStatus()`. */
191
- export interface PortMapperState {
192
- mode: PortForwardMode;
193
- externalPort: number;
194
- internalPort: number;
195
- routerExternalIp: string | null;
196
- leaseExpiresAt: Date | null;
197
- lastError: string | null;
198
- }
199
-
200
- /**
201
- * Long-running orchestrator over a PortMapper. Owns the refresh timer and
202
- * surfaces the latest state for the NatService snapshot.
203
- */
204
- export class PortMapperService {
205
- private readonly mapper: PortMapper;
206
- private readonly externalPort: number;
207
- private readonly internalPort: number;
208
- private readonly protocol: 'tcp' | 'udp';
209
- private readonly ttlMs: number;
210
- private readonly refreshMs: number;
211
- private readonly nowFn: () => Date;
212
- private readonly setTimer: (fn: () => void, ms: number) => NodeJS.Timeout;
213
- private readonly clearTimer: (h: NodeJS.Timeout) => void;
214
-
215
- private state: PortMapperState;
216
- private refreshHandle: NodeJS.Timeout | null = null;
217
- private started = false;
218
-
219
- constructor(opts: PortMapperServiceOptions) {
220
- this.mapper = opts.mapper;
221
- this.externalPort = opts.externalPort;
222
- this.internalPort = opts.internalPort;
223
- this.protocol = opts.protocol ?? 'tcp';
224
- this.ttlMs = opts.ttlMs ?? DEFAULT_LEASE_TTL_MS;
225
- this.refreshMs = opts.refreshMs ?? DEFAULT_REFRESH_MS;
226
- this.nowFn = opts.now ?? (() => new Date());
227
- this.setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
228
- this.clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
229
-
230
- this.state = {
231
- mode: 'disabled',
232
- externalPort: this.externalPort,
233
- internalPort: this.internalPort,
234
- routerExternalIp: null,
235
- leaseExpiresAt: null,
236
- lastError: null,
237
- };
238
- }
239
-
240
- getState(): PortMapperState {
241
- return { ...this.state };
242
- }
243
-
244
- /** Install the mapping and schedule periodic refresh. */
245
- async start(): Promise<void> {
246
- if (this.started) return;
247
- this.started = true;
248
- await this.installMapping();
249
- this.scheduleRefresh();
250
- }
251
-
252
- /** Release the mapping and clear timers. Idempotent. */
253
- async stop(): Promise<void> {
254
- if (!this.started) return;
255
- this.started = false;
256
- if (this.refreshHandle) {
257
- this.clearTimer(this.refreshHandle);
258
- this.refreshHandle = null;
259
- }
260
- try {
261
- await this.mapper.unmap(this.externalPort, this.protocol);
262
- } catch (err) {
263
- log('unmap during stop failed: %s', (err as Error).message);
264
- }
265
- try {
266
- await this.mapper.stop();
267
- } catch (err) {
268
- log('mapper.stop() failed: %s', (err as Error).message);
269
- }
270
- this.state = {
271
- ...this.state,
272
- mode: 'disabled',
273
- routerExternalIp: null,
274
- leaseExpiresAt: null,
275
- };
276
- }
277
-
278
- /** Force an immediate verify+refresh cycle (used by testReachability). */
279
- async refresh(): Promise<PortMapperState> {
280
- if (!this.started) return this.getState();
281
- const ok = await this.mapper.verify(this.externalPort, this.protocol).catch(() => false);
282
- if (!ok) {
283
- log('verify() returned false; re-mapping');
284
- await this.installMapping();
285
- return this.getState();
286
- }
287
- // Refresh: re-issue the mapping to extend the lease.
288
- await this.installMapping();
289
- return this.getState();
290
- }
291
-
292
- private scheduleRefresh(): void {
293
- if (this.refreshHandle) this.clearTimer(this.refreshHandle);
294
- this.refreshHandle = this.setTimer(() => {
295
- void this.refresh().catch((err) => log('refresh timer error: %s', (err as Error).message));
296
- // Re-arm after the call to avoid drift.
297
- this.scheduleRefresh();
298
- }, this.refreshMs);
299
- }
300
-
301
- private async installMapping(): Promise<void> {
302
- try {
303
- const r = await this.mapper.map({
304
- externalPort: this.externalPort,
305
- internalPort: this.internalPort,
306
- protocol: this.protocol,
307
- ttlMs: this.ttlMs,
308
- });
309
- this.state = {
310
- mode: r.mode,
311
- externalPort: r.externalPort,
312
- internalPort: r.internalPort,
313
- routerExternalIp: r.externalIp,
314
- leaseExpiresAt: r.leaseExpiresAt,
315
- lastError: null,
316
- };
317
- log('mapped port %d (mode=%s, externalIp=%s)', r.externalPort, r.mode, r.externalIp ?? '?');
318
- } catch (err) {
319
- const message = (err as Error).message;
320
- this.state = {
321
- ...this.state,
322
- mode: 'failed',
323
- leaseExpiresAt: null,
324
- lastError: message,
325
- };
326
- log('mapping failed: %s', message);
327
- }
328
- }
329
- }
1
+ import { BlockList } from 'node:net';
2
+ import { networkInterfaces, type NetworkInterfaceInfo } from 'node:os';
3
+ import debug from 'debug';
4
+ import { upnpNat, type Gateway, type UPnPNAT } from '@achingbrain/nat-port-mapper';
5
+
6
+ import { NatError } from './types.js';
7
+
8
+ const log = debug('cadre:host:nat-port-mapper');
9
+
10
+ /**
11
+ * Default lease TTL: 1 hour. `@achingbrain/nat-port-mapper` takes the TTL in
12
+ * milliseconds and floors the lease it asks the router for at 3600 s, so a
13
+ * shorter value here would still lease an hour.
14
+ */
15
+ export const DEFAULT_LEASE_TTL_MS = 60 * 60 * 1000;
16
+
17
+ /** Default renewal cadence: half the TTL, so two renewals per lease. */
18
+ export const DEFAULT_REFRESH_MS = 30 * 60 * 1000;
19
+
20
+ /** How long to listen for a UPnP gateway's SSDP answer before giving up. */
21
+ export const GATEWAY_DISCOVERY_TIMEOUT_MS = 10_000;
22
+
23
+ /** Bound on one SOAP call to the router (map, unmap, external IP). */
24
+ export const GATEWAY_OPERATION_TIMEOUT_MS = 10_000;
25
+
26
+ /** What `discover()` learned about the router. */
27
+ export interface GatewayInfo {
28
+ /** This machine's address on the router's subnet — the address mappings point at. */
29
+ lanAddress: string;
30
+ /** The router's own LAN address. */
31
+ routerHost: string;
32
+ }
33
+
34
+ export interface PortMapRequest {
35
+ internalPort: number;
36
+ protocol: 'tcp' | 'udp';
37
+ /** Lease TTL in milliseconds. */
38
+ ttlMs: number;
39
+ }
40
+
41
+ /** Result of a successful map(). */
42
+ export interface PortMapResult {
43
+ /** The external port the router granted — may differ from the internal port. */
44
+ externalPort: number;
45
+ leaseExpiresAt: Date;
46
+ }
47
+
48
+ /**
49
+ * Wrapper around the UPnP client. An interface so tests inject a fake instead
50
+ * of a router.
51
+ */
52
+ export interface PortMapper {
53
+ /**
54
+ * Find the router. Resolves null when no gateway answered within the
55
+ * discovery timeout; throws `NatError('router_unreachable')` when one did but
56
+ * cannot be used (no local interface shares its subnet).
57
+ */
58
+ discover(): Promise<GatewayInfo | null>;
59
+ /**
60
+ * Ask the router to map `internalPort` to the same external port. The router
61
+ * may grant another; the returned `externalPort` is the one that counts.
62
+ * Throws `NatError('mapping_failed')` on refusal.
63
+ */
64
+ map(req: PortMapRequest): Promise<PortMapResult>;
65
+ /** Remove a mapping installed by this process. Never throws. */
66
+ unmap(internalPort: number, protocol: 'tcp' | 'udp'): Promise<void>;
67
+ /** The WAN address the router reports, or null when it cannot be asked. */
68
+ externalIp(): Promise<string | null>;
69
+ /** Release client resources. Does not unmap. Idempotent. */
70
+ stop(): Promise<void>;
71
+ }
72
+
73
+ /** Signal that aborts after `ms`; the timer is cleared by `done()` so it never holds the process open. */
74
+ function deadline(ms: number): { signal: AbortSignal; done(): void } {
75
+ const controller = new AbortController();
76
+ const timer = setTimeout(() => controller.abort(new Error(`timed out after ${ms} ms`)), ms);
77
+ timer.unref();
78
+ return { signal: controller.signal, done: () => clearTimeout(timer) };
79
+ }
80
+
81
+ /**
82
+ * Pick the IPv4 address of the local interface whose subnet contains the router.
83
+ *
84
+ * Not `mapAll`: that maps on every local address and leaves stray mappings for
85
+ * VPN and container interfaces. The address returned is the one a user is told
86
+ * to forward to, so it must be the one on the router's own network.
87
+ */
88
+ export function pickLanAddress(
89
+ routerHost: string,
90
+ interfaces: Record<string, NetworkInterfaceInfo[] | undefined> = networkInterfaces(),
91
+ ): string | null {
92
+ for (const entries of Object.values(interfaces)) {
93
+ for (const entry of entries ?? []) {
94
+ if (entry.family !== 'IPv4' || entry.internal || !entry.cidr) continue;
95
+ const prefix = Number(entry.cidr.split('/')[1]);
96
+ if (!Number.isInteger(prefix)) continue;
97
+ const subnet = new BlockList();
98
+ try {
99
+ subnet.addSubnet(entry.address, prefix, 'ipv4');
100
+ if (subnet.check(routerHost, 'ipv4')) return entry.address;
101
+ } catch (err) {
102
+ log('skipping interface entry %s (%s): %s', entry.address, entry.cidr, (err as Error).message);
103
+ }
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * UPnP port mapper over `@achingbrain/nat-port-mapper` v4: `upnpNat()` hands
111
+ * out a client that discovers gateways; mapping, unmapping and the external IP
112
+ * live on the discovered `Gateway`.
113
+ *
114
+ * The library's auto-refresh stays off: cadre-host renews leases itself so it
115
+ * can report lease expiry and per-mapping failures.
116
+ */
117
+ export class UpnpPortMapper implements PortMapper {
118
+ private readonly client: UPnPNAT;
119
+ private gateway: Gateway | null = null;
120
+ private lanAddress: string | null = null;
121
+
122
+ constructor(opts: { description?: string } = {}) {
123
+ this.client = upnpNat({
124
+ description: opts.description ?? 'sereus-cadre-host',
125
+ autoRefresh: false,
126
+ });
127
+ }
128
+
129
+ async discover(): Promise<GatewayInfo | null> {
130
+ const gateway = await this.findIpv4Gateway();
131
+ if (!gateway) return null;
132
+ const lanAddress = pickLanAddress(gateway.host);
133
+ if (!lanAddress) {
134
+ throw new NatError(
135
+ 'router_unreachable',
136
+ `found a UPnP router at ${gateway.host} but no local IPv4 interface shares its subnet`,
137
+ );
138
+ }
139
+ this.gateway = gateway;
140
+ this.lanAddress = lanAddress;
141
+ log('discovered gateway %s (%s), mapping from %s', gateway.id, gateway.host, lanAddress);
142
+ return { lanAddress, routerHost: gateway.host };
143
+ }
144
+
145
+ async map(req: PortMapRequest): Promise<PortMapResult> {
146
+ const { gateway, lanAddress } = this.requireGateway();
147
+ const op = deadline(GATEWAY_OPERATION_TIMEOUT_MS);
148
+ try {
149
+ const mapping = await gateway.map(req.internalPort, lanAddress, {
150
+ externalPort: req.internalPort,
151
+ protocol: req.protocol,
152
+ ttl: req.ttlMs,
153
+ autoRefresh: false,
154
+ signal: op.signal,
155
+ });
156
+ return { externalPort: mapping.externalPort, leaseExpiresAt: new Date(Date.now() + req.ttlMs) };
157
+ } catch (err) {
158
+ throw new NatError(
159
+ 'mapping_failed',
160
+ `router refused the mapping for port ${req.internalPort}: ${(err as Error).message}`,
161
+ );
162
+ } finally {
163
+ op.done();
164
+ }
165
+ }
166
+
167
+ // NOTE: the library tracks the mappings this process made and `unmap` only sends a
168
+ // delete for those, so after a host restart a port mapped by the previous process is
169
+ // left to expire with its lease rather than deleted. It also names the INTERNAL port
170
+ // as the external one in that delete, so a mapping the router granted on another
171
+ // external port is not deleted either; it, too, expires with its lease (1 h). And it
172
+ // appends one tracked entry per successful `map` of a port, so after N renewals an
173
+ // unmap sends N deletes (the first is the one that counts); they all run under the
174
+ // one 10 s bound, which caps the cost of a process that has renewed for days.
175
+ async unmap(internalPort: number, _protocol: 'tcp' | 'udp'): Promise<void> {
176
+ if (!this.gateway) return;
177
+ const op = deadline(GATEWAY_OPERATION_TIMEOUT_MS);
178
+ try {
179
+ await this.gateway.unmap(internalPort, { signal: op.signal });
180
+ } catch (err) {
181
+ log('unmap(%d) failed: %s', internalPort, (err as Error).message);
182
+ } finally {
183
+ op.done();
184
+ }
185
+ }
186
+
187
+ async externalIp(): Promise<string | null> {
188
+ if (!this.gateway) return null;
189
+ const op = deadline(GATEWAY_OPERATION_TIMEOUT_MS);
190
+ try {
191
+ return await this.gateway.externalIp({ signal: op.signal });
192
+ } catch (err) {
193
+ log('externalIp() failed: %s', (err as Error).message);
194
+ return null;
195
+ } finally {
196
+ op.done();
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Forget the gateway. Deliberately not `gateway.stop()`: that deletes every
202
+ * mapping this process made, and hosted children keep running across a host
203
+ * restart, so their mappings must outlive this process.
204
+ */
205
+ async stop(): Promise<void> {
206
+ this.gateway = null;
207
+ this.lanAddress = null;
208
+ }
209
+
210
+ private requireGateway(): { gateway: Gateway; lanAddress: string } {
211
+ if (!this.gateway || !this.lanAddress) {
212
+ throw new NatError('router_unreachable', 'no UPnP gateway discovered');
213
+ }
214
+ return { gateway: this.gateway, lanAddress: this.lanAddress };
215
+ }
216
+
217
+ /**
218
+ * First IPv4 gateway to answer the SSDP search, or null when none did within
219
+ * the timeout.
220
+ *
221
+ * NOTE: the library searches for `InternetGatewayDevice:2` only (its
222
+ * `upnp/discovery.js`), and a router that implements only version 1 does not
223
+ * answer a version-2 search, so such a router reads as "not found". There is
224
+ * no library option for it; if it shows up on real hosts, the fix is a fork or
225
+ * another library.
226
+ */
227
+ private async findIpv4Gateway(): Promise<Gateway | null> {
228
+ const search = deadline(GATEWAY_DISCOVERY_TIMEOUT_MS);
229
+ try {
230
+ for await (const gateway of this.client.findGateways({ signal: search.signal })) {
231
+ if (gateway.family === 'IPv4') return gateway;
232
+ }
233
+ return null;
234
+ } catch (err) {
235
+ if (search.signal.aborted) return null;
236
+ throw new NatError('router_unreachable', `UPnP discovery failed: ${(err as Error).message}`);
237
+ } finally {
238
+ search.done();
239
+ }
240
+ }
241
+ }