@serfab/cadre-host 0.7.1

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 (300) hide show
  1. package/README.md +101 -0
  2. package/dist/auth/duration.d.ts +1 -0
  3. package/dist/auth/duration.js +32 -0
  4. package/dist/auth/duration.js.map +1 -0
  5. package/dist/auth/index.d.ts +9 -0
  6. package/dist/auth/index.js +8 -0
  7. package/dist/auth/index.js.map +1 -0
  8. package/dist/auth/trust-circle-store.d.ts +26 -0
  9. package/dist/auth/trust-circle-store.js +129 -0
  10. package/dist/auth/trust-circle-store.js.map +1 -0
  11. package/dist/auth/trust-circle.d.ts +91 -0
  12. package/dist/auth/trust-circle.js +230 -0
  13. package/dist/auth/trust-circle.js.map +1 -0
  14. package/dist/auth/types.d.ts +81 -0
  15. package/dist/auth/types.js +24 -0
  16. package/dist/auth/types.js.map +1 -0
  17. package/dist/bin/host.d.ts +13 -0
  18. package/dist/bin/host.js +826 -0
  19. package/dist/bin/host.js.map +1 -0
  20. package/dist/index.d.ts +32 -0
  21. package/dist/index.js +27 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/installer/browser.d.ts +14 -0
  24. package/dist/installer/browser.js +35 -0
  25. package/dist/installer/browser.js.map +1 -0
  26. package/dist/installer/config.d.ts +43 -0
  27. package/dist/installer/config.js +113 -0
  28. package/dist/installer/config.js.map +1 -0
  29. package/dist/installer/identity.d.ts +23 -0
  30. package/dist/installer/identity.js +36 -0
  31. package/dist/installer/identity.js.map +1 -0
  32. package/dist/installer/index.d.ts +83 -0
  33. package/dist/installer/index.js +204 -0
  34. package/dist/installer/index.js.map +1 -0
  35. package/dist/installer/paths.d.ts +33 -0
  36. package/dist/installer/paths.js +84 -0
  37. package/dist/installer/paths.js.map +1 -0
  38. package/dist/installer/platform.d.ts +9 -0
  39. package/dist/installer/platform.js +21 -0
  40. package/dist/installer/platform.js.map +1 -0
  41. package/dist/installer/service-host/index.d.ts +11 -0
  42. package/dist/installer/service-host/index.js +16 -0
  43. package/dist/installer/service-host/index.js.map +1 -0
  44. package/dist/installer/service-host/launchd.d.ts +16 -0
  45. package/dist/installer/service-host/launchd.js +79 -0
  46. package/dist/installer/service-host/launchd.js.map +1 -0
  47. package/dist/installer/service-host/nssm.d.ts +20 -0
  48. package/dist/installer/service-host/nssm.js +88 -0
  49. package/dist/installer/service-host/nssm.js.map +1 -0
  50. package/dist/installer/service-host/systemd.d.ts +16 -0
  51. package/dist/installer/service-host/systemd.js +78 -0
  52. package/dist/installer/service-host/systemd.js.map +1 -0
  53. package/dist/installer/service-host/template.d.ts +6 -0
  54. package/dist/installer/service-host/template.js +20 -0
  55. package/dist/installer/service-host/template.js.map +1 -0
  56. package/dist/installer/service-host/types.d.ts +40 -0
  57. package/dist/installer/service-host/types.js +6 -0
  58. package/dist/installer/service-host/types.js.map +1 -0
  59. package/dist/installer/wizard.d.ts +31 -0
  60. package/dist/installer/wizard.js +67 -0
  61. package/dist/installer/wizard.js.map +1 -0
  62. package/dist/nat/address-resolver.d.ts +22 -0
  63. package/dist/nat/address-resolver.js +31 -0
  64. package/dist/nat/address-resolver.js.map +1 -0
  65. package/dist/nat/ddns/duckdns.d.ts +12 -0
  66. package/dist/nat/ddns/duckdns.js +57 -0
  67. package/dist/nat/ddns/duckdns.js.map +1 -0
  68. package/dist/nat/ddns/index.d.ts +30 -0
  69. package/dist/nat/ddns/index.js +21 -0
  70. package/dist/nat/ddns/index.js.map +1 -0
  71. package/dist/nat/ddns/updater.d.ts +68 -0
  72. package/dist/nat/ddns/updater.js +194 -0
  73. package/dist/nat/ddns/updater.js.map +1 -0
  74. package/dist/nat/external-ip.d.ts +62 -0
  75. package/dist/nat/external-ip.js +114 -0
  76. package/dist/nat/external-ip.js.map +1 -0
  77. package/dist/nat/index.d.ts +22 -0
  78. package/dist/nat/index.js +16 -0
  79. package/dist/nat/index.js.map +1 -0
  80. package/dist/nat/nat-service.d.ts +101 -0
  81. package/dist/nat/nat-service.js +284 -0
  82. package/dist/nat/nat-service.js.map +1 -0
  83. package/dist/nat/nat-store.d.ts +19 -0
  84. package/dist/nat/nat-store.js +136 -0
  85. package/dist/nat/nat-store.js.map +1 -0
  86. package/dist/nat/port-mapper.d.ts +98 -0
  87. package/dist/nat/port-mapper.js +226 -0
  88. package/dist/nat/port-mapper.js.map +1 -0
  89. package/dist/nat/reachability.d.ts +19 -0
  90. package/dist/nat/reachability.js +29 -0
  91. package/dist/nat/reachability.js.map +1 -0
  92. package/dist/nat/secrets/file-store.d.ts +23 -0
  93. package/dist/nat/secrets/file-store.js +102 -0
  94. package/dist/nat/secrets/file-store.js.map +1 -0
  95. package/dist/nat/secrets/index.d.ts +36 -0
  96. package/dist/nat/secrets/index.js +56 -0
  97. package/dist/nat/secrets/index.js.map +1 -0
  98. package/dist/nat/secrets/keytar-store.d.ts +18 -0
  99. package/dist/nat/secrets/keytar-store.js +52 -0
  100. package/dist/nat/secrets/keytar-store.js.map +1 -0
  101. package/dist/nat/types.d.ts +120 -0
  102. package/dist/nat/types.js +24 -0
  103. package/dist/nat/types.js.map +1 -0
  104. package/dist/orchestrator/host-process-orchestrator.d.ts +87 -0
  105. package/dist/orchestrator/host-process-orchestrator.js +557 -0
  106. package/dist/orchestrator/host-process-orchestrator.js.map +1 -0
  107. package/dist/orchestrator/index.d.ts +3 -0
  108. package/dist/orchestrator/index.js +2 -0
  109. package/dist/orchestrator/index.js.map +1 -0
  110. package/dist/orchestrator/log-rotator.d.ts +25 -0
  111. package/dist/orchestrator/log-rotator.js +56 -0
  112. package/dist/orchestrator/log-rotator.js.map +1 -0
  113. package/dist/orchestrator/pid-liveness.d.ts +11 -0
  114. package/dist/orchestrator/pid-liveness.js +26 -0
  115. package/dist/orchestrator/pid-liveness.js.map +1 -0
  116. package/dist/orchestrator/port-allocator.d.ts +16 -0
  117. package/dist/orchestrator/port-allocator.js +38 -0
  118. package/dist/orchestrator/port-allocator.js.map +1 -0
  119. package/dist/orchestrator/state-store.d.ts +32 -0
  120. package/dist/orchestrator/state-store.js +44 -0
  121. package/dist/orchestrator/state-store.js.map +1 -0
  122. package/dist/orchestrator/types.d.ts +85 -0
  123. package/dist/orchestrator/types.js +31 -0
  124. package/dist/orchestrator/types.js.map +1 -0
  125. package/dist/server/error-handler.d.ts +26 -0
  126. package/dist/server/error-handler.js +95 -0
  127. package/dist/server/error-handler.js.map +1 -0
  128. package/dist/server/events/bus.d.ts +17 -0
  129. package/dist/server/events/bus.js +36 -0
  130. package/dist/server/events/bus.js.map +1 -0
  131. package/dist/server/events/sse-route.d.ts +22 -0
  132. package/dist/server/events/sse-route.js +65 -0
  133. package/dist/server/events/sse-route.js.map +1 -0
  134. package/dist/server/events/types.d.ts +25 -0
  135. package/dist/server/events/types.js +8 -0
  136. package/dist/server/events/types.js.map +1 -0
  137. package/dist/server/index.d.ts +67 -0
  138. package/dist/server/index.js +149 -0
  139. package/dist/server/index.js.map +1 -0
  140. package/dist/server/origin-guard.d.ts +24 -0
  141. package/dist/server/origin-guard.js +72 -0
  142. package/dist/server/origin-guard.js.map +1 -0
  143. package/dist/server/routes/nat.d.ts +16 -0
  144. package/dist/server/routes/nat.js +42 -0
  145. package/dist/server/routes/nat.js.map +1 -0
  146. package/dist/server/routes/nodes.d.ts +23 -0
  147. package/dist/server/routes/nodes.js +128 -0
  148. package/dist/server/routes/nodes.js.map +1 -0
  149. package/dist/server/routes/settings.d.ts +26 -0
  150. package/dist/server/routes/settings.js +97 -0
  151. package/dist/server/routes/settings.js.map +1 -0
  152. package/dist/server/routes/status.d.ts +42 -0
  153. package/dist/server/routes/status.js +76 -0
  154. package/dist/server/routes/status.js.map +1 -0
  155. package/dist/server/routes/trust-circle.d.ts +17 -0
  156. package/dist/server/routes/trust-circle.js +35 -0
  157. package/dist/server/routes/trust-circle.js.map +1 -0
  158. package/dist/server/routes/update.d.ts +16 -0
  159. package/dist/server/routes/update.js +29 -0
  160. package/dist/server/routes/update.js.map +1 -0
  161. package/dist/server/server.d.ts +40 -0
  162. package/dist/server/server.js +90 -0
  163. package/dist/server/server.js.map +1 -0
  164. package/dist/server/settings-store.d.ts +24 -0
  165. package/dist/server/settings-store.js +27 -0
  166. package/dist/server/settings-store.js.map +1 -0
  167. package/dist/server/static.d.ts +25 -0
  168. package/dist/server/static.js +177 -0
  169. package/dist/server/static.js.map +1 -0
  170. package/dist/ui/assets/index-BwU5mJGF.css +1 -0
  171. package/dist/ui/assets/index-C9uChk2P.js +14 -0
  172. package/dist/ui/assets/index-C9uChk2P.js.map +1 -0
  173. package/dist/ui/index.html +14 -0
  174. package/dist/update/apply.d.ts +37 -0
  175. package/dist/update/apply.js +102 -0
  176. package/dist/update/apply.js.map +1 -0
  177. package/dist/update/index.d.ts +101 -0
  178. package/dist/update/index.js +228 -0
  179. package/dist/update/index.js.map +1 -0
  180. package/dist/update/manifest.d.ts +48 -0
  181. package/dist/update/manifest.js +152 -0
  182. package/dist/update/manifest.js.map +1 -0
  183. package/dist/update/release-key.d.ts +24 -0
  184. package/dist/update/release-key.js +52 -0
  185. package/dist/update/release-key.js.map +1 -0
  186. package/dist/update/store.d.ts +22 -0
  187. package/dist/update/store.js +81 -0
  188. package/dist/update/store.js.map +1 -0
  189. package/dist/update/types.d.ts +90 -0
  190. package/dist/update/types.js +22 -0
  191. package/dist/update/types.js.map +1 -0
  192. package/dist/update/version.d.ts +18 -0
  193. package/dist/update/version.js +76 -0
  194. package/dist/update/version.js.map +1 -0
  195. package/package.json +80 -0
  196. package/service/README.md +86 -0
  197. package/service/cadre-host.service.tmpl +16 -0
  198. package/service/com.serfab.cadre-host.plist.tmpl +35 -0
  199. package/service/install-service.ps1 +98 -0
  200. package/service/uninstall-service.ps1 +19 -0
  201. package/src/__tests__/cli-invite.smoke.test.ts +187 -0
  202. package/src/__tests__/cli-nat.smoke.test.ts +207 -0
  203. package/src/__tests__/cli.smoke.test.ts +41 -0
  204. package/src/__tests__/orchestrator.test.ts +570 -0
  205. package/src/auth/__tests__/duration.test.ts +28 -0
  206. package/src/auth/__tests__/trust-circle-integration.test.ts +128 -0
  207. package/src/auth/__tests__/trust-circle-store.test.ts +115 -0
  208. package/src/auth/__tests__/trust-circle.test.ts +334 -0
  209. package/src/auth/duration.ts +33 -0
  210. package/src/auth/index.ts +19 -0
  211. package/src/auth/trust-circle-store.ts +155 -0
  212. package/src/auth/trust-circle.ts +304 -0
  213. package/src/auth/types.ts +96 -0
  214. package/src/bin/host.ts +905 -0
  215. package/src/index.ts +167 -0
  216. package/src/installer/__tests__/config.test.ts +86 -0
  217. package/src/installer/__tests__/identity.test.ts +34 -0
  218. package/src/installer/__tests__/installer.smoke.test.ts +124 -0
  219. package/src/installer/__tests__/paths.test.ts +47 -0
  220. package/src/installer/__tests__/service-host.test.ts +62 -0
  221. package/src/installer/__tests__/wizard.test.ts +60 -0
  222. package/src/installer/browser.ts +42 -0
  223. package/src/installer/config.ts +153 -0
  224. package/src/installer/identity.ts +49 -0
  225. package/src/installer/index.ts +303 -0
  226. package/src/installer/paths.ts +109 -0
  227. package/src/installer/platform.ts +27 -0
  228. package/src/installer/service-host/index.ts +21 -0
  229. package/src/installer/service-host/launchd.ts +90 -0
  230. package/src/installer/service-host/nssm.ts +100 -0
  231. package/src/installer/service-host/systemd.ts +88 -0
  232. package/src/installer/service-host/template.ts +21 -0
  233. package/src/installer/service-host/types.ts +48 -0
  234. package/src/installer/wizard.ts +92 -0
  235. package/src/nat/__tests__/address-resolver.test.ts +82 -0
  236. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -0
  237. package/src/nat/__tests__/ddns-updater.test.ts +253 -0
  238. package/src/nat/__tests__/external-ip.test.ts +124 -0
  239. package/src/nat/__tests__/nat-service.test.ts +305 -0
  240. package/src/nat/__tests__/nat-store.test.ts +93 -0
  241. package/src/nat/__tests__/port-mapper.test.ts +191 -0
  242. package/src/nat/__tests__/reachability.test.ts +24 -0
  243. package/src/nat/__tests__/secrets.test.ts +156 -0
  244. package/src/nat/address-resolver.ts +51 -0
  245. package/src/nat/ddns/duckdns.ts +76 -0
  246. package/src/nat/ddns/index.ts +55 -0
  247. package/src/nat/ddns/updater.ts +243 -0
  248. package/src/nat/external-ip.ts +154 -0
  249. package/src/nat/index.ts +71 -0
  250. package/src/nat/nat-service.ts +375 -0
  251. package/src/nat/nat-store.ts +154 -0
  252. package/src/nat/port-mapper.ts +329 -0
  253. package/src/nat/reachability.ts +34 -0
  254. package/src/nat/secrets/file-store.ts +130 -0
  255. package/src/nat/secrets/index.ts +82 -0
  256. package/src/nat/secrets/keytar-store.ts +67 -0
  257. package/src/nat/types.ts +157 -0
  258. package/src/orchestrator/host-process-orchestrator.ts +626 -0
  259. package/src/orchestrator/index.ts +3 -0
  260. package/src/orchestrator/log-rotator.ts +53 -0
  261. package/src/orchestrator/pid-liveness.ts +24 -0
  262. package/src/orchestrator/port-allocator.ts +43 -0
  263. package/src/orchestrator/state-store.ts +72 -0
  264. package/src/orchestrator/types.ts +95 -0
  265. package/src/server/__tests__/bus.test.ts +38 -0
  266. package/src/server/__tests__/error-handler.test.ts +96 -0
  267. package/src/server/__tests__/nodes-route.test.ts +179 -0
  268. package/src/server/__tests__/origin-guard.test.ts +72 -0
  269. package/src/server/__tests__/publishers.test.ts +216 -0
  270. package/src/server/__tests__/server.smoke.test.ts +178 -0
  271. package/src/server/__tests__/settings-route.test.ts +177 -0
  272. package/src/server/__tests__/sse-route.test.ts +119 -0
  273. package/src/server/__tests__/status-route.test.ts +113 -0
  274. package/src/server/error-handler.ts +108 -0
  275. package/src/server/events/bus.ts +42 -0
  276. package/src/server/events/sse-route.ts +81 -0
  277. package/src/server/events/types.ts +22 -0
  278. package/src/server/index.ts +209 -0
  279. package/src/server/origin-guard.ts +84 -0
  280. package/src/server/routes/nat.ts +63 -0
  281. package/src/server/routes/nodes.ts +147 -0
  282. package/src/server/routes/settings.ts +115 -0
  283. package/src/server/routes/status.ts +108 -0
  284. package/src/server/routes/trust-circle.ts +49 -0
  285. package/src/server/routes/update.ts +43 -0
  286. package/src/server/server.ts +122 -0
  287. package/src/server/settings-store.ts +37 -0
  288. package/src/server/static.ts +191 -0
  289. package/src/update/__tests__/apply.test.ts +101 -0
  290. package/src/update/__tests__/manifest.test.ts +93 -0
  291. package/src/update/__tests__/store.test.ts +81 -0
  292. package/src/update/__tests__/update-service.integration.test.ts +221 -0
  293. package/src/update/__tests__/version.test.ts +31 -0
  294. package/src/update/apply.ts +148 -0
  295. package/src/update/index.ts +303 -0
  296. package/src/update/manifest.ts +172 -0
  297. package/src/update/release-key.ts +55 -0
  298. package/src/update/store.ts +101 -0
  299. package/src/update/types.ts +109 -0
  300. package/src/update/version.ts +80 -0
@@ -0,0 +1,253 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { DdnsUpdater } from '../ddns/updater.js';
4
+ import type { DdnsProvider } from '../ddns/index.js';
5
+ import type { SecretsStore } from '../secrets/index.js';
6
+ import { ddnsAccount } from '../secrets/index.js';
7
+ import { NatError, type NatDdnsSettings } from '../types.js';
8
+
9
+ function makeSecrets(seed: Record<string, string> = {}): SecretsStore {
10
+ const map = new Map(Object.entries(seed));
11
+ return {
12
+ async set(a, v) { map.set(a, v); },
13
+ async get(a) { return map.has(a) ? map.get(a)! : null; },
14
+ async delete(a) { return map.delete(a); },
15
+ async list() { return Array.from(map.keys()); },
16
+ };
17
+ }
18
+
19
+ function makeProvider(overrides: Partial<DdnsProvider> = {}): DdnsProvider & {
20
+ calls: Array<{ hostname: string; ip: string; config: Record<string, string> }>;
21
+ shouldThrow: Error | null;
22
+ } {
23
+ const calls: Array<{ hostname: string; ip: string; config: Record<string, string> }> = [];
24
+ let shouldThrow: Error | null = null;
25
+ return {
26
+ id: 'duckdns',
27
+ displayName: 'DuckDNS',
28
+ configFields: [{ key: 'token', label: 'DuckDNS token', secret: true }],
29
+ async update(config, hostname, ip) {
30
+ calls.push({ hostname, ip, config });
31
+ if (shouldThrow) throw shouldThrow;
32
+ },
33
+ ...overrides,
34
+ get calls() { return calls; },
35
+ get shouldThrow() { return shouldThrow; },
36
+ set shouldThrow(e: Error | null) { shouldThrow = e; },
37
+ } as DdnsProvider & {
38
+ calls: Array<{ hostname: string; ip: string; config: Record<string, string> }>;
39
+ shouldThrow: Error | null;
40
+ };
41
+ }
42
+
43
+ function fakeIntervalTimers(): {
44
+ setInterval: (fn: () => void, ms: number) => NodeJS.Timeout;
45
+ clearInterval: (h: NodeJS.Timeout) => void;
46
+ fire(): Promise<void>;
47
+ cleared: number;
48
+ } {
49
+ let cb: (() => void) | null = null;
50
+ let cleared = 0;
51
+ return {
52
+ setInterval: (fn) => { cb = fn; return { _id: 1 } as unknown as NodeJS.Timeout; },
53
+ clearInterval: () => { cb = null; cleared++; },
54
+ async fire() { const f = cb; if (f) { f(); await new Promise((r) => setTimeout(r, 0)); } },
55
+ get cleared() { return cleared; },
56
+ };
57
+ }
58
+
59
+ const baseSettings: NatDdnsSettings = {
60
+ providerId: 'duckdns',
61
+ hostname: 'foo.duckdns.org',
62
+ externallyManaged: false,
63
+ intervalMs: 60_000,
64
+ };
65
+
66
+ describe('DdnsUpdater', () => {
67
+ it('no-op when externallyManaged', async () => {
68
+ const provider = makeProvider();
69
+ const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
70
+ let ip: string | null = '1.2.3.4';
71
+ const u = new DdnsUpdater({
72
+ settings: { ...baseSettings, externallyManaged: true },
73
+ secrets,
74
+ getExternalIp: () => ip,
75
+ resolveProvider: () => provider,
76
+ ...fakeIntervalTimers(),
77
+ });
78
+ await u.start();
79
+ expect(provider.calls).toHaveLength(0);
80
+ });
81
+
82
+ it('no-op when providerId is null', async () => {
83
+ const provider = makeProvider();
84
+ const u = new DdnsUpdater({
85
+ settings: { ...baseSettings, providerId: null },
86
+ secrets: makeSecrets(),
87
+ getExternalIp: () => '1.2.3.4',
88
+ resolveProvider: () => provider,
89
+ ...fakeIntervalTimers(),
90
+ });
91
+ await u.start();
92
+ expect(provider.calls).toHaveLength(0);
93
+ });
94
+
95
+ it('pushes the IP on start, records lastUpdateOk=true', async () => {
96
+ const provider = makeProvider();
97
+ const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
98
+ const u = new DdnsUpdater({
99
+ settings: baseSettings,
100
+ secrets,
101
+ getExternalIp: () => '1.2.3.4',
102
+ resolveProvider: () => provider,
103
+ ...fakeIntervalTimers(),
104
+ });
105
+ const outcome = await u.start();
106
+ expect(outcome.ok).toBe(true);
107
+ expect(outcome.ip).toBe('1.2.3.4');
108
+ expect(provider.calls).toEqual([
109
+ { hostname: 'foo.duckdns.org', ip: '1.2.3.4', config: { token: 'T' } },
110
+ ]);
111
+ const status = u.getStatus();
112
+ expect(status.lastUpdateOk).toBe(true);
113
+ expect(status.lastError).toBeNull();
114
+ });
115
+
116
+ it('does not re-push when IP is unchanged', async () => {
117
+ const provider = makeProvider();
118
+ const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
119
+ const timers = fakeIntervalTimers();
120
+ let ip = '1.2.3.4';
121
+ const u = new DdnsUpdater({
122
+ settings: baseSettings,
123
+ secrets,
124
+ getExternalIp: () => ip,
125
+ resolveProvider: () => provider,
126
+ setInterval: timers.setInterval,
127
+ clearInterval: timers.clearInterval,
128
+ });
129
+ await u.start();
130
+ expect(provider.calls).toHaveLength(1);
131
+
132
+ await timers.fire();
133
+ expect(provider.calls).toHaveLength(1);
134
+
135
+ // Change the IP — next tick should call.
136
+ ip = '5.6.7.8';
137
+ await timers.fire();
138
+ expect(provider.calls).toHaveLength(2);
139
+ expect(provider.calls[1]?.ip).toBe('5.6.7.8');
140
+ });
141
+
142
+ it('records error but keeps timer running on provider throw', async () => {
143
+ const provider = makeProvider();
144
+ const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
145
+ const timers = fakeIntervalTimers();
146
+ let ip = '1.2.3.4';
147
+
148
+ provider.shouldThrow = new NatError('ddns_update_failed', 'boom');
149
+
150
+ const u = new DdnsUpdater({
151
+ settings: baseSettings,
152
+ secrets,
153
+ getExternalIp: () => ip,
154
+ resolveProvider: () => provider,
155
+ setInterval: timers.setInterval,
156
+ clearInterval: timers.clearInterval,
157
+ });
158
+
159
+ await u.start();
160
+ expect(u.getStatus().lastUpdateOk).toBe(false);
161
+ expect(u.getStatus().lastError).toContain('boom');
162
+
163
+ // Provider stops throwing — next change → success
164
+ provider.shouldThrow = null;
165
+ ip = '5.6.7.8';
166
+ await timers.fire();
167
+
168
+ expect(provider.calls.length).toBeGreaterThanOrEqual(2);
169
+ expect(u.getStatus().lastUpdateOk).toBe(true);
170
+ expect(u.getStatus().lastError).toBeNull();
171
+ });
172
+
173
+ it('reports ddns_credentials_missing when secrets are empty', async () => {
174
+ const provider = makeProvider();
175
+ const u = new DdnsUpdater({
176
+ settings: baseSettings,
177
+ secrets: makeSecrets(),
178
+ getExternalIp: () => '1.2.3.4',
179
+ resolveProvider: () => provider,
180
+ ...fakeIntervalTimers(),
181
+ });
182
+ const outcome = await u.start();
183
+ expect(outcome.ok).toBe(false);
184
+ expect(outcome.error).toMatch(/credential/i);
185
+ expect(provider.calls).toHaveLength(0);
186
+ });
187
+
188
+ it('forceUpdate ignores unchanged-IP shortcut', async () => {
189
+ const provider = makeProvider();
190
+ const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
191
+ const u = new DdnsUpdater({
192
+ settings: baseSettings,
193
+ secrets,
194
+ getExternalIp: () => '1.2.3.4',
195
+ resolveProvider: () => provider,
196
+ ...fakeIntervalTimers(),
197
+ });
198
+ await u.start();
199
+ await u.forceUpdate();
200
+ expect(provider.calls).toHaveLength(2);
201
+ });
202
+
203
+ it('stop() clears the timer; idempotent', async () => {
204
+ const provider = makeProvider();
205
+ const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
206
+ const timers = fakeIntervalTimers();
207
+ const u = new DdnsUpdater({
208
+ settings: baseSettings,
209
+ secrets,
210
+ getExternalIp: () => '1.2.3.4',
211
+ resolveProvider: () => provider,
212
+ setInterval: timers.setInterval,
213
+ clearInterval: timers.clearInterval,
214
+ });
215
+ await u.start();
216
+ u.stop();
217
+ u.stop();
218
+ expect(timers.cleared).toBe(1);
219
+ });
220
+
221
+ it('updateSettings restarts with new config', async () => {
222
+ const provider = makeProvider();
223
+ const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
224
+ const u = new DdnsUpdater({
225
+ settings: baseSettings,
226
+ secrets,
227
+ getExternalIp: () => '1.2.3.4',
228
+ resolveProvider: () => provider,
229
+ ...fakeIntervalTimers(),
230
+ });
231
+ await u.start();
232
+ expect(provider.calls).toHaveLength(1);
233
+
234
+ await u.updateSettings({ ...baseSettings, hostname: 'bar.duckdns.org' });
235
+ expect(provider.calls).toHaveLength(2);
236
+ expect(provider.calls[1]?.hostname).toBe('bar.duckdns.org');
237
+ });
238
+
239
+ it('records "external IP unknown" when getExternalIp returns null', async () => {
240
+ const provider = makeProvider();
241
+ const u = new DdnsUpdater({
242
+ settings: baseSettings,
243
+ secrets: makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' }),
244
+ getExternalIp: () => null,
245
+ resolveProvider: () => provider,
246
+ ...fakeIntervalTimers(),
247
+ });
248
+ const outcome = await u.start();
249
+ expect(outcome.attempted).toBe(false);
250
+ expect(outcome.error).toMatch(/unknown/i);
251
+ expect(provider.calls).toHaveLength(0);
252
+ });
253
+ });
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { ExternalIpDetector, isPlausibleIp } from '../external-ip.js';
4
+
5
+ function makeFetchSequence(responses: Array<{ ok: boolean; body: string }>): typeof fetch {
6
+ let i = 0;
7
+ return (async () => {
8
+ const r = responses[i++] ?? { ok: false, body: '' };
9
+ return {
10
+ ok: r.ok,
11
+ status: r.ok ? 200 : 500,
12
+ async text() { return r.body; },
13
+ } as unknown as Response;
14
+ }) as typeof fetch;
15
+ }
16
+
17
+ describe('isPlausibleIp()', () => {
18
+ it('accepts IPv4', () => {
19
+ expect(isPlausibleIp('1.2.3.4')).toBe(true);
20
+ expect(isPlausibleIp('255.255.255.255')).toBe(true);
21
+ });
22
+ it('rejects bad IPv4', () => {
23
+ expect(isPlausibleIp('256.0.0.1')).toBe(false);
24
+ expect(isPlausibleIp('1.2.3')).toBe(false);
25
+ });
26
+ it('accepts IPv6 liberally', () => {
27
+ expect(isPlausibleIp('::1')).toBe(true);
28
+ expect(isPlausibleIp('2001:db8::1')).toBe(true);
29
+ });
30
+ it('rejects junk', () => {
31
+ expect(isPlausibleIp('not an ip')).toBe(false);
32
+ expect(isPlausibleIp('')).toBe(false);
33
+ });
34
+ });
35
+
36
+ describe('ExternalIpDetector', () => {
37
+ it('returns null routerIp when no probe configured', async () => {
38
+ const det = new ExternalIpDetector({
39
+ fetch: makeFetchSequence([{ ok: true, body: '1.2.3.4' }]),
40
+ publicIpUrls: ['https://example.test'],
41
+ });
42
+ const r = await det.detect();
43
+ expect(r.routerIp).toBeNull();
44
+ expect(r.publicIp).toBe('1.2.3.4');
45
+ expect(r.cgnat).toBe(false);
46
+ });
47
+
48
+ it('detects CGNAT when router and public IP disagree', async () => {
49
+ const det = new ExternalIpDetector({
50
+ routerProbe: async () => '100.64.0.5', // CGNAT space — fine for testing
51
+ fetch: makeFetchSequence([{ ok: true, body: '203.0.113.10' }]),
52
+ publicIpUrls: ['https://example.test'],
53
+ });
54
+ const r = await det.detect();
55
+ expect(r.routerIp).toBe('100.64.0.5');
56
+ expect(r.publicIp).toBe('203.0.113.10');
57
+ expect(r.cgnat).toBe(true);
58
+ });
59
+
60
+ it('does NOT flag CGNAT when IPs agree', async () => {
61
+ const det = new ExternalIpDetector({
62
+ routerProbe: async () => '1.2.3.4',
63
+ fetch: makeFetchSequence([{ ok: true, body: '1.2.3.4' }]),
64
+ publicIpUrls: ['https://example.test'],
65
+ });
66
+ const r = await det.detect();
67
+ expect(r.cgnat).toBe(false);
68
+ });
69
+
70
+ it('does NOT flag CGNAT when one probe fails', async () => {
71
+ const det = new ExternalIpDetector({
72
+ routerProbe: async () => '1.2.3.4',
73
+ fetch: makeFetchSequence([{ ok: false, body: '' }]),
74
+ publicIpUrls: ['https://example.test'],
75
+ });
76
+ const r = await det.detect();
77
+ expect(r.routerIp).toBe('1.2.3.4');
78
+ expect(r.publicIp).toBeNull();
79
+ expect(r.cgnat).toBe(false);
80
+ });
81
+
82
+ it('falls through public probe URLs until one succeeds', async () => {
83
+ const det = new ExternalIpDetector({
84
+ fetch: makeFetchSequence([
85
+ { ok: false, body: '' },
86
+ { ok: true, body: '8.8.8.8' },
87
+ ]),
88
+ publicIpUrls: ['https://a.test', 'https://b.test'],
89
+ });
90
+ const r = await det.detect();
91
+ expect(r.publicIp).toBe('8.8.8.8');
92
+ });
93
+
94
+ it('treats router probe throw as null', async () => {
95
+ const det = new ExternalIpDetector({
96
+ routerProbe: async () => { throw new Error('upnp not reachable'); },
97
+ fetch: makeFetchSequence([{ ok: true, body: '1.2.3.4' }]),
98
+ publicIpUrls: ['https://a.test'],
99
+ });
100
+ const r = await det.detect();
101
+ expect(r.routerIp).toBeNull();
102
+ expect(r.publicIp).toBe('1.2.3.4');
103
+ expect(r.cgnat).toBe(false);
104
+ });
105
+
106
+ it('rejects implausible router-probe IP', async () => {
107
+ const det = new ExternalIpDetector({
108
+ routerProbe: async () => 'not-an-ip',
109
+ fetch: makeFetchSequence([{ ok: true, body: '1.2.3.4' }]),
110
+ publicIpUrls: ['https://a.test'],
111
+ });
112
+ const r = await det.detect();
113
+ expect(r.routerIp).toBeNull();
114
+ });
115
+
116
+ it('rejects non-IP public-service body', async () => {
117
+ const det = new ExternalIpDetector({
118
+ fetch: makeFetchSequence([{ ok: true, body: '<html>error</html>' }]),
119
+ publicIpUrls: ['https://a.test'],
120
+ });
121
+ const r = await det.detect();
122
+ expect(r.publicIp).toBeNull();
123
+ });
124
+ });
@@ -0,0 +1,305 @@
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
+
13
+ class StubMapper implements PortMapper {
14
+ mapCalls = 0;
15
+ unmapCalls = 0;
16
+ stopCalls = 0;
17
+ externalIpValue: string | null = '203.0.113.10';
18
+ mapFails = false;
19
+ async map(o: { externalPort: number; internalPort: number; protocol?: 'tcp' | 'udp'; ttlMs?: number }): Promise<PortMappingResult> {
20
+ this.mapCalls++;
21
+ if (this.mapFails) throw new Error('router refused');
22
+ return {
23
+ mode: 'auto-upnp',
24
+ externalIp: this.externalIpValue,
25
+ externalPort: o.externalPort,
26
+ internalPort: o.internalPort,
27
+ protocol: o.protocol ?? 'tcp',
28
+ leaseExpiresAt: new Date(Date.now() + (o.ttlMs ?? 60_000)),
29
+ };
30
+ }
31
+ async verify() { return true; }
32
+ async externalIp() { return this.externalIpValue; }
33
+ async unmap() { this.unmapCalls++; }
34
+ async stop() { this.stopCalls++; }
35
+ }
36
+
37
+ function makeSecrets(seed: Record<string, string> = {}): SecretsStore {
38
+ const m = new Map(Object.entries(seed));
39
+ return {
40
+ async set(a, v) { m.set(a, v); },
41
+ async get(a) { return m.has(a) ? m.get(a)! : null; },
42
+ async delete(a) { return m.delete(a); },
43
+ async list() { return [...m.keys()]; },
44
+ };
45
+ }
46
+
47
+ function makeNode(peerId = '12D3KooWHost', addrs: string[] = ['/ip4/192.168.1.10/tcp/4001']): CadreNodeLike {
48
+ return {
49
+ getPeerId: () => peerId,
50
+ getMultiaddrs: () => addrs,
51
+ };
52
+ }
53
+
54
+ function makeDetector(opts: { router?: string | null; pub?: string | null }): ExternalIpDetector {
55
+ return new ExternalIpDetector({
56
+ routerProbe: opts.router === undefined ? undefined : async () => opts.router ?? null,
57
+ fetch: (async () => ({
58
+ ok: opts.pub != null,
59
+ status: opts.pub != null ? 200 : 500,
60
+ async text() { return opts.pub ?? ''; },
61
+ })) as unknown as typeof fetch,
62
+ publicIpUrls: ['https://stub.test'],
63
+ });
64
+ }
65
+
66
+ let tmpRoot: string;
67
+ beforeEach(() => {
68
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-nat-svc-'));
69
+ });
70
+ afterEach(() => {
71
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
72
+ });
73
+
74
+ describe('NatService', () => {
75
+ it('start() runs port mapping, detects IP, status reflects auto-upnp', async () => {
76
+ const mapper = new StubMapper();
77
+ const svc = new NatService({
78
+ rootDir: tmpRoot,
79
+ cadreNode: makeNode(),
80
+ secretsStore: makeSecrets(),
81
+ portMapper: mapper,
82
+ externalIpDetector: makeDetector({ router: '203.0.113.10', pub: '203.0.113.10' }),
83
+ });
84
+ await svc.start();
85
+ const s = svc.getStatus();
86
+ expect(s.portMode).toBe('auto-upnp');
87
+ expect(s.externalPort).toBe(4001);
88
+ expect(s.externalIp).toBe('203.0.113.10');
89
+ expect(s.directReachability).toBe('reachable');
90
+ expect(mapper.mapCalls).toBe(1);
91
+ });
92
+
93
+ it('detects CGNAT and flags directReachability=cgnat', async () => {
94
+ const mapper = new StubMapper();
95
+ const svc = new NatService({
96
+ rootDir: tmpRoot,
97
+ cadreNode: makeNode(),
98
+ secretsStore: makeSecrets(),
99
+ portMapper: mapper,
100
+ externalIpDetector: makeDetector({ router: '100.64.0.5', pub: '203.0.113.10' }),
101
+ });
102
+ await svc.start();
103
+ const s = svc.getStatus();
104
+ expect(s.cgnatDetected).toBe(true);
105
+ expect(s.directReachability).toBe('cgnat');
106
+ });
107
+
108
+ it('flips portMode to failed when mapper throws', async () => {
109
+ const mapper = new StubMapper();
110
+ mapper.mapFails = true;
111
+ const svc = new NatService({
112
+ rootDir: tmpRoot,
113
+ cadreNode: makeNode(),
114
+ secretsStore: makeSecrets(),
115
+ portMapper: mapper,
116
+ externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
117
+ });
118
+ await svc.start();
119
+ expect(svc.getStatus().portMode).toBe('failed');
120
+ expect(svc.getStatus().directReachability).toBe('unreachable');
121
+ });
122
+
123
+ it('stop() unmaps and is idempotent', async () => {
124
+ const mapper = new StubMapper();
125
+ const svc = new NatService({
126
+ rootDir: tmpRoot,
127
+ cadreNode: makeNode(),
128
+ secretsStore: makeSecrets(),
129
+ portMapper: mapper,
130
+ externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
131
+ });
132
+ await svc.start();
133
+ await svc.stop();
134
+ await svc.stop();
135
+ expect(mapper.unmapCalls).toBe(1);
136
+ expect(mapper.stopCalls).toBe(1);
137
+ });
138
+
139
+ it('getInviteAddresses → dns4 when DDNS configured + reachable', async () => {
140
+ const mapper = new StubMapper();
141
+ const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
142
+ const svc = new NatService({
143
+ rootDir: tmpRoot,
144
+ cadreNode: makeNode('12D3KooWHost'),
145
+ secretsStore: secrets,
146
+ portMapper: mapper,
147
+ externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
148
+ });
149
+ await svc.start();
150
+ await svc.putDdns({
151
+ providerId: 'duckdns',
152
+ hostname: 'foo.duckdns.org',
153
+ config: { token: 'T' },
154
+ });
155
+ const addrs = await svc.getInviteAddresses();
156
+ expect(addrs).toEqual(['/dns4/foo.duckdns.org/tcp/4001/p2p/12D3KooWHost']);
157
+ });
158
+
159
+ it('getInviteAddresses → ip4 when reachable but no DDNS', async () => {
160
+ const mapper = new StubMapper();
161
+ const svc = new NatService({
162
+ rootDir: tmpRoot,
163
+ cadreNode: makeNode('12D3KooWHost'),
164
+ secretsStore: makeSecrets(),
165
+ portMapper: mapper,
166
+ externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
167
+ });
168
+ await svc.start();
169
+ const addrs = await svc.getInviteAddresses();
170
+ expect(addrs).toEqual(['/ip4/203.0.113.42/tcp/4001/p2p/12D3KooWHost']);
171
+ });
172
+
173
+ it('getInviteAddresses → libp2p fallback when unreachable', async () => {
174
+ const mapper = new StubMapper();
175
+ mapper.mapFails = true;
176
+ const svc = new NatService({
177
+ rootDir: tmpRoot,
178
+ cadreNode: makeNode('12D3KooWHost', ['/ip4/10.0.0.5/tcp/4001']),
179
+ secretsStore: makeSecrets(),
180
+ portMapper: mapper,
181
+ externalIpDetector: makeDetector({ pub: null }),
182
+ });
183
+ await svc.start();
184
+ const addrs = await svc.getInviteAddresses();
185
+ expect(addrs).toEqual(['/ip4/10.0.0.5/tcp/4001/p2p/12D3KooWHost']);
186
+ });
187
+
188
+ it('putDdns persists secrets and updates status', async () => {
189
+ const secrets = makeSecrets();
190
+ const mapper = new StubMapper();
191
+ const svc = new NatService({
192
+ rootDir: tmpRoot,
193
+ cadreNode: makeNode(),
194
+ secretsStore: secrets,
195
+ portMapper: mapper,
196
+ externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
197
+ });
198
+ await svc.start();
199
+ const status = await svc.putDdns({
200
+ providerId: 'duckdns',
201
+ hostname: 'h.duckdns.org',
202
+ config: { token: 'TOK' },
203
+ });
204
+ expect(status.ddns.providerId).toBe('duckdns');
205
+ expect(status.ddns.hostname).toBe('h.duckdns.org');
206
+ expect(await secrets.get(ddnsAccount('duckdns', 'token'))).toBe('TOK');
207
+ });
208
+
209
+ it('putDdns with unknown provider → ddns_provider_unknown', async () => {
210
+ const mapper = new StubMapper();
211
+ const svc = new NatService({
212
+ rootDir: tmpRoot,
213
+ cadreNode: makeNode(),
214
+ secretsStore: makeSecrets(),
215
+ portMapper: mapper,
216
+ externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
217
+ });
218
+ await svc.start();
219
+ await expect(svc.putDdns({
220
+ providerId: 'no-such-provider',
221
+ hostname: 'x',
222
+ config: {},
223
+ })).rejects.toMatchObject({ code: 'ddns_provider_unknown' });
224
+ });
225
+
226
+ it('putSettings can disable UPnP and re-enable it', async () => {
227
+ const mapper = new StubMapper();
228
+ const svc = new NatService({
229
+ rootDir: tmpRoot,
230
+ cadreNode: makeNode(),
231
+ secretsStore: makeSecrets(),
232
+ portMapper: mapper,
233
+ externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
234
+ });
235
+ await svc.start();
236
+ expect(mapper.mapCalls).toBe(1);
237
+
238
+ let s = await svc.putSettings({ upnpEnabled: false });
239
+ expect(s.portMode).toBe('disabled');
240
+
241
+ s = await svc.putSettings({ upnpEnabled: true });
242
+ expect(s.portMode).toBe('auto-upnp');
243
+ expect(mapper.mapCalls).toBeGreaterThanOrEqual(2);
244
+ });
245
+
246
+ it('testReachability re-runs IP detect and updates lastTestedAt', async () => {
247
+ const mapper = new StubMapper();
248
+ const svc = new NatService({
249
+ rootDir: tmpRoot,
250
+ cadreNode: makeNode(),
251
+ secretsStore: makeSecrets(),
252
+ portMapper: mapper,
253
+ externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
254
+ });
255
+ await svc.start();
256
+ expect(svc.getStatus().lastTestedAt).toBeNull();
257
+ await svc.testReachability();
258
+ expect(svc.getStatus().lastTestedAt).not.toBeNull();
259
+ });
260
+
261
+ it('listDdnsProviders returns at least DuckDNS', () => {
262
+ const svc = new NatService({
263
+ rootDir: tmpRoot,
264
+ cadreNode: makeNode(),
265
+ secretsStore: makeSecrets(),
266
+ portMapper: new StubMapper(),
267
+ externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
268
+ });
269
+ const providers = svc.listDdnsProviders();
270
+ const duck = providers.find((p) => p.id === 'duckdns');
271
+ expect(duck).toBeDefined();
272
+ expect(duck!.configFields[0]).toMatchObject({ key: 'token', secret: true });
273
+ });
274
+ });
275
+
276
+ describe('createNatHandlers', () => {
277
+ it('every handler delegates to the service', async () => {
278
+ const mapper = new StubMapper();
279
+ const svc = new NatService({
280
+ rootDir: tmpRoot,
281
+ cadreNode: makeNode(),
282
+ secretsStore: makeSecrets(),
283
+ portMapper: mapper,
284
+ externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
285
+ });
286
+ await svc.start();
287
+ const h = createNatHandlers(svc);
288
+
289
+ expect((await h.getStatus()).portMode).toBe('auto-upnp');
290
+ expect((await h.listDdnsProviders()).find((p) => p.id === 'duckdns')).toBeDefined();
291
+
292
+ const status = await h.testReachability();
293
+ expect(status.lastTestedAt).not.toBeNull();
294
+
295
+ const after = await h.putSettings({ externalPort: 4002 });
296
+ expect(after.externalPort).toBe(4002);
297
+
298
+ const ddnsStatus = await h.putDdns({
299
+ providerId: 'duckdns',
300
+ hostname: 'a.duckdns.org',
301
+ config: { token: 'T' },
302
+ });
303
+ expect(ddnsStatus.ddns.hostname).toBe('a.duckdns.org');
304
+ });
305
+ });