@crvouga/dev-fleet 0.0.0-stage → 0.2.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 (363) hide show
  1. package/.mise/locks/aws-cdk/2.1146.1/aube-lock.yaml +24 -0
  2. package/.mise/locks/aws-cdk/2.1146.1/package.json +7 -0
  3. package/.mise/locks/azure-cli/2.91.0~a837e014/pyproject.toml +5 -0
  4. package/.mise/locks/azure-cli/2.91.0~a837e014/uv.lock +2794 -0
  5. package/.mise/locks/gemini/0.66.0~84aac8e6/aube-lock.yaml +123 -0
  6. package/.mise/locks/gemini/0.66.0~84aac8e6/package.json +12 -0
  7. package/.mise/locks/npm-devcontainers-cli/0.90.0/aube-lock.yaml +24 -0
  8. package/.mise/locks/npm-devcontainers-cli/0.90.0/package.json +7 -0
  9. package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/aube-lock.yaml +1432 -0
  10. package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/package.json +7 -0
  11. package/.mise/locks/npm-vercel/65.8.0/aube-lock.yaml +3595 -0
  12. package/.mise/locks/npm-vercel/65.8.0/package.json +7 -0
  13. package/.mise/locks/npm-wrangler/4.156.0/aube-lock.yaml +895 -0
  14. package/.mise/locks/npm-wrangler/4.156.0/package.json +7 -0
  15. package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/aube-lock.yaml +24 -0
  16. package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/package.json +7 -0
  17. package/LICENSE +21 -0
  18. package/README.md +357 -2
  19. package/agents/antigravity-proxy.ts +3 -0
  20. package/agents/audit.md +89 -0
  21. package/agents/claude-proxy +5 -0
  22. package/agents/claude-proxy.ts +3 -0
  23. package/agents/codex-proxy.ts +3 -0
  24. package/agents/grok-proxy.ts +3 -0
  25. package/agents/managed-proxy.ts +55 -0
  26. package/agents/opencode-proxy.ts +3 -0
  27. package/agents/registry.json +209 -0
  28. package/agents/registry.schema.json +149 -0
  29. package/agents/setup.md +158 -0
  30. package/agents/t3.json +40 -0
  31. package/ansible/macos/manager.yml +30 -0
  32. package/ansible/macos/policy.json +9 -0
  33. package/ansible/monitoring/README.md +215 -0
  34. package/ansible/monitoring/agent-darwin.yml +198 -0
  35. package/ansible/monitoring/agent.yml +172 -0
  36. package/ansible/monitoring/beszel-agent.service +42 -0
  37. package/ansible/monitoring/compose.yml +74 -0
  38. package/ansible/monitoring/hub-darwin.yml +102 -0
  39. package/ansible/monitoring/hub-route-darwin.yml +51 -0
  40. package/ansible/monitoring/lock.json +16 -0
  41. package/ansible/net/remote-probe.sh +13 -0
  42. package/ansible/pyproject.toml +12 -0
  43. package/ansible/ubuntu/authorize-sudo.sh +51 -0
  44. package/ansible/ubuntu/baseline.yml +89 -0
  45. package/ansible/ubuntu/disk-gc.py +336 -0
  46. package/ansible/ubuntu/disk-guard.py +104 -0
  47. package/ansible/ubuntu/policy.json +52 -0
  48. package/ansible/ubuntu/recovery.yml +15 -0
  49. package/ansible/ubuntu/t3/policy.json +7 -0
  50. package/ansible/ubuntu/t3-desktop.yml +220 -0
  51. package/ansible/ubuntu/t3-health.sh +33 -0
  52. package/ansible/ubuntu/t3-policy.yml +76 -0
  53. package/ansible/ubuntu/t3-route.yml +28 -0
  54. package/ansible/ubuntu/tailscale.yml +78 -0
  55. package/ansible/ubuntu/tasks/credentials.yml +47 -0
  56. package/ansible/ubuntu/tasks/docker.yml +31 -0
  57. package/ansible/ubuntu/tasks/packages.yml +56 -0
  58. package/ansible/ubuntu/tasks/power.yml +51 -0
  59. package/ansible/ubuntu/tasks/recovery.yml +39 -0
  60. package/ansible/ubuntu/tasks/resources.yml +312 -0
  61. package/ansible/ubuntu/tasks/services.yml +31 -0
  62. package/ansible/ubuntu/tasks/ssh.yml +71 -0
  63. package/ansible/ubuntu/tasks/user.yml +120 -0
  64. package/ansible/uv.lock +190 -0
  65. package/bin/devfleet.mjs +36 -0
  66. package/bootstrap/bootstrap.ps1 +50 -0
  67. package/bootstrap/bootstrap.sh +80 -0
  68. package/bootstrap/devfleet +7 -0
  69. package/bootstrap/docker-desktop.sh +33 -0
  70. package/bootstrap/doctor.ts +234 -0
  71. package/bootstrap/fleet-manager-run.sh +103 -0
  72. package/bootstrap/fleet-remote-bootstrap.sh +258 -0
  73. package/bootstrap/global-config.ts +171 -0
  74. package/bootstrap/install-launcher.sh +10 -0
  75. package/bootstrap/native-aws.ts +86 -0
  76. package/bootstrap/native-t3.ts +154 -0
  77. package/bootstrap/npm.ts +358 -0
  78. package/bootstrap/release-version.ts +157 -0
  79. package/bootstrap/setup-status.ts +51 -0
  80. package/bootstrap/setup.ts +88 -0
  81. package/bootstrap/shell-path.ts +105 -0
  82. package/bootstrap/verify-t3-archive.py +49 -0
  83. package/bootstrap/worker-auth.ts +65 -0
  84. package/bootstrap/worker-github.ts +47 -0
  85. package/bootstrap/worker-tools.ts +24 -0
  86. package/cliproxy/AGENTS.md +11 -0
  87. package/cliproxy/README.md +269 -0
  88. package/cliproxy/clients/bootstrap.py +143 -0
  89. package/cliproxy/clients/launcher.py +57 -0
  90. package/cliproxy/compose.yaml +90 -0
  91. package/cliproxy/config.template.yaml +70 -0
  92. package/cliproxy/docs/cloud-adoption.md +16 -0
  93. package/cliproxy/docs/deployment.png +0 -0
  94. package/cliproxy/docs/deployment.svg +109 -0
  95. package/cliproxy/docs/health-workflow.example.yml +185 -0
  96. package/cliproxy/docs/migration.md +131 -0
  97. package/cliproxy/docs/model-health-report.schema.json +364 -0
  98. package/cliproxy/docs/model-health.md +393 -0
  99. package/cliproxy/image.lock.json +5 -0
  100. package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
  101. package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
  102. package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
  103. package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
  104. package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
  105. package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
  106. package/cliproxy/models.json +1618 -0
  107. package/cliproxy/requirements.lock +78 -0
  108. package/cliproxy/scripts/accounts.py +38 -0
  109. package/cliproxy/scripts/audit.py +123 -0
  110. package/cliproxy/scripts/backup +234 -0
  111. package/cliproxy/scripts/check.py +34 -0
  112. package/cliproxy/scripts/checkpoint.py +226 -0
  113. package/cliproxy/scripts/cli.py +64 -0
  114. package/cliproxy/scripts/cliproxy_host.py +257 -0
  115. package/cliproxy/scripts/cloud_state.py +150 -0
  116. package/cliproxy/scripts/container-health +6 -0
  117. package/cliproxy/scripts/docker +24 -0
  118. package/cliproxy/scripts/external-health.py +79 -0
  119. package/cliproxy/scripts/fleet_config.py +43 -0
  120. package/cliproxy/scripts/fleet_sync.py +274 -0
  121. package/cliproxy/scripts/initialize +84 -0
  122. package/cliproxy/scripts/install-dependencies +48 -0
  123. package/cliproxy/scripts/install.py +406 -0
  124. package/cliproxy/scripts/model_health.py +16 -0
  125. package/cliproxy/scripts/modelhealth/__init__.py +5 -0
  126. package/cliproxy/scripts/modelhealth/checks.py +429 -0
  127. package/cliproxy/scripts/modelhealth/cli.py +117 -0
  128. package/cliproxy/scripts/modelhealth/config.py +186 -0
  129. package/cliproxy/scripts/modelhealth/emulator.py +536 -0
  130. package/cliproxy/scripts/modelhealth/expect.py +88 -0
  131. package/cliproxy/scripts/modelhealth/findings.py +183 -0
  132. package/cliproxy/scripts/modelhealth/protocol.py +330 -0
  133. package/cliproxy/scripts/modelhealth/redact.py +28 -0
  134. package/cliproxy/scripts/modelhealth/report.py +150 -0
  135. package/cliproxy/scripts/modelhealth/runner.py +390 -0
  136. package/cliproxy/scripts/modelhealth/scenario.py +131 -0
  137. package/cliproxy/scripts/modelhealth/schema.py +63 -0
  138. package/cliproxy/scripts/modelhealth/simulate.py +111 -0
  139. package/cliproxy/scripts/modelhealth/transport.py +213 -0
  140. package/cliproxy/scripts/monitor +68 -0
  141. package/cliproxy/scripts/ops +527 -0
  142. package/cliproxy/scripts/public-endpoint +127 -0
  143. package/cliproxy/scripts/recover_node.py +177 -0
  144. package/cliproxy/scripts/restore-verify +60 -0
  145. package/cliproxy/scripts/retention.py +144 -0
  146. package/cliproxy/scripts/service-start +7 -0
  147. package/cliproxy/scripts/validate-config +56 -0
  148. package/cliproxy/scripts/vault-access.py +57 -0
  149. package/cliproxy/scripts/vault-backup-login +55 -0
  150. package/cliproxy/scripts/vault-recovery +56 -0
  151. package/cliproxy/scripts/vault-renew +30 -0
  152. package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
  153. package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
  154. package/cliproxy/systemd/cliproxy-backup.service +13 -0
  155. package/cliproxy/systemd/cliproxy-backup.timer +8 -0
  156. package/cliproxy/systemd/cliproxy-fleet.service +17 -0
  157. package/cliproxy/systemd/cliproxy-gc.service +13 -0
  158. package/cliproxy/systemd/cliproxy-gc.timer +8 -0
  159. package/cliproxy/systemd/cliproxy-monitor.service +8 -0
  160. package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
  161. package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
  162. package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
  163. package/cliproxy/vendor/LICENSE +22 -0
  164. package/cliproxy/vendor/config.upstream.yaml +1349 -0
  165. package/docs/consumer-setup.md +408 -0
  166. package/docs/declared.md +89 -0
  167. package/docs/machine-setup.md +399 -0
  168. package/docs/operations.md +276 -0
  169. package/docs/releasing.md +103 -0
  170. package/docs/state.md +254 -0
  171. package/docs/topology.md +67 -0
  172. package/docs/ubuntu-sync.md +351 -0
  173. package/docs/worker-contract.md +67 -0
  174. package/examples/fleet.json +100 -0
  175. package/examples/roster.json +13 -0
  176. package/llms.txt +35 -0
  177. package/mise.lock +1521 -0
  178. package/mise.toml +110 -0
  179. package/package.json +74 -4
  180. package/schema/fleet.schema.json +420 -0
  181. package/schema/roster.schema.json +77 -0
  182. package/src/agents/account-relay.ts +111 -0
  183. package/src/agents/accounts.ts +85 -0
  184. package/src/agents/audit.ts +77 -0
  185. package/src/agents/cli.ts +13 -0
  186. package/src/agents/command.ts +204 -0
  187. package/src/agents/converge.ts +123 -0
  188. package/src/agents/doctor.ts +107 -0
  189. package/src/agents/errors.ts +2 -0
  190. package/src/agents/files.ts +73 -0
  191. package/src/agents/fleet-verify.ts +165 -0
  192. package/src/agents/launch.ts +290 -0
  193. package/src/agents/native-verify.ts +371 -0
  194. package/src/agents/provider-errors.ts +57 -0
  195. package/src/agents/registry.ts +61 -0
  196. package/src/agents/render.ts +110 -0
  197. package/src/agents/round-trip.ts +192 -0
  198. package/src/agents/secrets.ts +13 -0
  199. package/src/agents/toolchain.ts +34 -0
  200. package/src/agents/verify-cache.ts +103 -0
  201. package/src/agents/verify.ts +127 -0
  202. package/src/auth-checks.ts +163 -0
  203. package/src/cli/agent-guide.ts +129 -0
  204. package/src/cli/app.ts +122 -0
  205. package/src/cli/cli.ts +28 -0
  206. package/src/cli/command-index.ts +57 -0
  207. package/src/cli/contracts.ts +25 -0
  208. package/src/cli/fleet-reference.ts +17 -0
  209. package/src/cli/fleet-state.ts +499 -0
  210. package/src/cli/fleet-sync-checkout.ts +130 -0
  211. package/src/cli/fleet-sync-enrollment.ts +140 -0
  212. package/src/cli/fleet-sync-forward.ts +134 -0
  213. package/src/cli/fleet-sync-manager.ts +249 -0
  214. package/src/cli/fleet-sync-monitoring.ts +185 -0
  215. package/src/cli/fleet-sync-output.ts +317 -0
  216. package/src/cli/fleet-sync-privilege.ts +27 -0
  217. package/src/cli/fleet-sync-progress.ts +109 -0
  218. package/src/cli/fleet-sync-remote.ts +242 -0
  219. package/src/cli/fleet-sync-report.ts +26 -0
  220. package/src/cli/fleet-sync-schedule.ts +138 -0
  221. package/src/cli/fleet-sync-state.ts +86 -0
  222. package/src/cli/fleet-sync-vault.ts +166 -0
  223. package/src/cli/fleet-sync.ts +375 -0
  224. package/src/cli/fleet.ts +165 -0
  225. package/src/cli/managed-health.ts +219 -0
  226. package/src/cli/node-deployment.ts +34 -0
  227. package/src/cli/node-sync.ts +121 -0
  228. package/src/cli/node-t3.ts +31 -0
  229. package/src/cli/options.ts +141 -0
  230. package/src/cli/probe-source.ts +7 -0
  231. package/src/cli/program.ts +185 -0
  232. package/src/cli/project.ts +51 -0
  233. package/src/cli/register-fleet.ts +296 -0
  234. package/src/cli/registry.ts +185 -0
  235. package/src/cli/repair-apply.ts +67 -0
  236. package/src/cli/repair-guard.ts +11 -0
  237. package/src/cli/repair-inspect.ts +150 -0
  238. package/src/cli/repair-script.ts +76 -0
  239. package/src/cli/repair.ts +209 -0
  240. package/src/cli/roster.ts +105 -0
  241. package/src/cli/search.ts +35 -0
  242. package/src/cli/settings.ts +67 -0
  243. package/src/cli/source-operations.ts +102 -0
  244. package/src/cli/t3/command.ts +90 -0
  245. package/src/cli/t3/register.ts +55 -0
  246. package/src/cli/tailnet.ts +127 -0
  247. package/src/cli/ui-actions.ts +133 -0
  248. package/src/cli/ui-controller.ts +113 -0
  249. package/src/cli/ui-data.ts +106 -0
  250. package/src/cli/ui-execute.ts +163 -0
  251. package/src/cli/ui-repairs.ts +34 -0
  252. package/src/cli/ui-reports.ts +211 -0
  253. package/src/cli/ui-targets.ts +100 -0
  254. package/src/cli/ui-terminal.ts +109 -0
  255. package/src/cli/ui-types.ts +49 -0
  256. package/src/cli/ui-wizard.ts +101 -0
  257. package/src/cli/ui.ts +9 -0
  258. package/src/cli/worker-health-output.ts +37 -0
  259. package/src/config.ts +186 -0
  260. package/src/configure.ts +255 -0
  261. package/src/definition.ts +290 -0
  262. package/src/diagnostics.ts +92 -0
  263. package/src/doctor-check.ts +10 -0
  264. package/src/errors.ts +1 -0
  265. package/src/fingerprint.ts +120 -0
  266. package/src/fleet-config.ts +174 -0
  267. package/src/input-hashes.ts +73 -0
  268. package/src/machine/local-source.ts +52 -0
  269. package/src/machine/lock-error.ts +2 -0
  270. package/src/machine/platform.ts +44 -0
  271. package/src/machine/source.ts +68 -0
  272. package/src/machine/state.ts +173 -0
  273. package/src/machine-health.ts +95 -0
  274. package/src/managed-source.ts +47 -0
  275. package/src/manager-failure.ts +48 -0
  276. package/src/manager-sync.ts +405 -0
  277. package/src/manager.ts +62 -0
  278. package/src/monitoring/agent-evidence.ts +146 -0
  279. package/src/monitoring/apply.ts +183 -0
  280. package/src/monitoring/beszel.ts +198 -0
  281. package/src/monitoring/enroll.ts +111 -0
  282. package/src/monitoring/hub-access.ts +114 -0
  283. package/src/monitoring/hub-backup.ts +121 -0
  284. package/src/monitoring/hub-viewer.ts +142 -0
  285. package/src/monitoring/hub.ts +93 -0
  286. package/src/monitoring/manager-agent.ts +73 -0
  287. package/src/monitoring/manager-hub.ts +248 -0
  288. package/src/monitoring/remote.ts +65 -0
  289. package/src/network-definition.ts +83 -0
  290. package/src/node-converge-linux.ts +374 -0
  291. package/src/node-converge-shared.ts +83 -0
  292. package/src/node-converge.ts +40 -0
  293. package/src/onboard.ts +35 -0
  294. package/src/package-info.ts +45 -0
  295. package/src/platform/ansible-receipt.ts +128 -0
  296. package/src/platform/ansible.ts +66 -0
  297. package/src/platform/darwin.ts +203 -0
  298. package/src/platform/detect.ts +11 -0
  299. package/src/platform/fallback.ts +110 -0
  300. package/src/platform/index.ts +38 -0
  301. package/src/platform/launchd.ts +98 -0
  302. package/src/platform/linux.ts +139 -0
  303. package/src/platform/ps.ts +45 -0
  304. package/src/platform/resource-verify.ts +132 -0
  305. package/src/platform/systemd-user.ts +95 -0
  306. package/src/platform/t3-policy.ts +137 -0
  307. package/src/platform/t3-route.ts +68 -0
  308. package/src/platform/t3-service.ts +75 -0
  309. package/src/platform/types.ts +123 -0
  310. package/src/platform/ubuntu-audit.ts +66 -0
  311. package/src/platform/ubuntu-verify.ts +105 -0
  312. package/src/platform/windows.ts +148 -0
  313. package/src/power-recovery.ts +55 -0
  314. package/src/probe.ts +51 -0
  315. package/src/protocol.ts +119 -0
  316. package/src/proxy-converge.ts +85 -0
  317. package/src/remote-bootstrap.ts +210 -0
  318. package/src/remote-entry.ts +89 -0
  319. package/src/remote-lock.ts +12 -0
  320. package/src/repair-catalog.ts +206 -0
  321. package/src/repair-plan.ts +78 -0
  322. package/src/repair-runner.ts +84 -0
  323. package/src/report.ts +71 -0
  324. package/src/runner.ts +104 -0
  325. package/src/secrets.ts +169 -0
  326. package/src/service-auth.ts +115 -0
  327. package/src/ssh.ts +135 -0
  328. package/src/state/bucket.ts +208 -0
  329. package/src/state/check.ts +74 -0
  330. package/src/state/connection.ts +92 -0
  331. package/src/state/enrollment.ts +45 -0
  332. package/src/state/legacy.ts +89 -0
  333. package/src/state/litestream.ts +143 -0
  334. package/src/state/operator.ts +20 -0
  335. package/src/state/paths.ts +40 -0
  336. package/src/state/roster.ts +62 -0
  337. package/src/state/store.ts +169 -0
  338. package/src/state/sync.ts +352 -0
  339. package/src/sync.ts +104 -0
  340. package/src/t3/accounts.ts +81 -0
  341. package/src/t3/desired.ts +165 -0
  342. package/src/t3/files.ts +81 -0
  343. package/src/t3/names-check.ts +53 -0
  344. package/src/t3/names.ts +155 -0
  345. package/src/t3/plan.ts +82 -0
  346. package/src/t3/providers.ts +74 -0
  347. package/src/t3/reconcile.ts +123 -0
  348. package/src/t3-endpoint.ts +104 -0
  349. package/src/t3-linked-check.ts +123 -0
  350. package/src/tailnet-health.ts +231 -0
  351. package/src/tailnet-matching.ts +37 -0
  352. package/src/tailscale.ts +141 -0
  353. package/src/toolchain-checks.ts +108 -0
  354. package/src/topology.ts +24 -0
  355. package/src/transport.ts +122 -0
  356. package/src/types.ts +99 -0
  357. package/src/vault-config.ts +38 -0
  358. package/src/worker-access-checks.ts +168 -0
  359. package/src/worker-capacity.ts +34 -0
  360. package/src/worker-host-checks.ts +181 -0
  361. package/src/worker-policy.ts +64 -0
  362. package/src/worker-readiness.ts +64 -0
  363. package/src/workstation-checks.ts +29 -0
@@ -0,0 +1,121 @@
1
+ import { record } from '../config';
2
+ import type { Check } from '../types';
3
+ import {
4
+ LOCAL_HUB,
5
+ api,
6
+ superuserToken,
7
+ type HubLogin,
8
+ type Session,
9
+ } from './hub-viewer';
10
+
11
+ /** A scheduled backup older than this means the schedule is not running. */
12
+ export const BACKUP_MAX_AGE_MS = 48 * 60 * 60 * 1000;
13
+
14
+ function backupCheck(
15
+ status: Check['status'],
16
+ summary: string,
17
+ action?: string
18
+ ): Check {
19
+ return {
20
+ id: 'beszel:backup',
21
+ status,
22
+ summary,
23
+ ...(action ? { remedy: { action } } : {}),
24
+ };
25
+ }
26
+
27
+ const SETTINGS =
28
+ 'Open the hub as its superuser (/_/ on the manager), Settings > Backups.';
29
+
30
+ /** Reads, never changes, the hub's own backup settings and its list of
31
+ * backups through the stock PocketBase API of the pinned release (superuser
32
+ * only: GET /api/settings and GET /api/backups). Backups are configured in
33
+ * the hub's UI; this says whether they are scheduled, kept off the manager
34
+ * and recent. Storage names, keys and endpoints are never reported.
35
+ */
36
+ export async function verifyHubBackups(
37
+ login: HubLogin | null,
38
+ fetcher: typeof fetch = fetch,
39
+ base: string = LOCAL_HUB,
40
+ now: number = Date.now()
41
+ ): Promise<Check> {
42
+ if (!login)
43
+ return backupCheck(
44
+ 'skip',
45
+ 'BESZEL_BACKUP_UNVERIFIED',
46
+ "The fleet admin login is unavailable, so the hub's backups were not read. Log the manager's Vault session in and rerun fleet-sync."
47
+ );
48
+ let settings: unknown;
49
+ let backups: unknown;
50
+ try {
51
+ const anonymous: Session = { fetcher, base, token: '' };
52
+ const session = {
53
+ ...anonymous,
54
+ token: await superuserToken(anonymous, login),
55
+ };
56
+ settings = await api(session, 'GET', '/api/settings');
57
+ backups = await api(session, 'GET', '/api/backups');
58
+ } catch {
59
+ return backupCheck(
60
+ 'warn',
61
+ 'BESZEL_BACKUP_UNVERIFIED',
62
+ `The hub's backup settings could not be read with the fleet admin login. ${SETTINGS} Details are withheld.`
63
+ );
64
+ }
65
+ const policy = backupPolicy(settings);
66
+ if (!policy || !Array.isArray(backups))
67
+ return backupCheck(
68
+ 'warn',
69
+ 'BESZEL_BACKUP_UNVERIFIED',
70
+ `The hub answered in a form this release does not expect. ${SETTINGS}`
71
+ );
72
+ if (!policy.cron)
73
+ return backupCheck(
74
+ 'warn',
75
+ 'BESZEL_BACKUP_UNSCHEDULED',
76
+ `The hub takes no scheduled backups, so its history, settings and machine identities are lost with the manager. ${SETTINGS} Set a schedule and how many to keep.`
77
+ );
78
+ if (!policy.offHost)
79
+ return backupCheck(
80
+ 'warn',
81
+ 'BESZEL_BACKUP_OFFHOST_REQUIRED',
82
+ `Scheduled backups stay on the manager's disk, which is not disaster recovery. ${SETTINGS} Store them in existing encrypted S3-compatible storage you already approved; do not create new storage or reuse unrelated credentials.`
83
+ );
84
+ const newest = newestBackup(backups);
85
+ if (newest === null || now - newest > BACKUP_MAX_AGE_MS)
86
+ return backupCheck(
87
+ 'warn',
88
+ 'BESZEL_BACKUP_STALE',
89
+ `No backup newer than ${BACKUP_MAX_AGE_MS / 3_600_000} hours is listed although a schedule is set. ${SETTINGS} Check the schedule and the storage's credentials, then create a backup by hand.`
90
+ );
91
+ return backupCheck(
92
+ 'pass',
93
+ `Scheduled backups go to off-host storage, keeping ${policy.keep}; the newest is from ${new Date(newest).toISOString()}.`
94
+ );
95
+ }
96
+
97
+ function backupPolicy(
98
+ settings: unknown
99
+ ): { cron: string; keep: number; offHost: boolean } | null {
100
+ if (!record(settings) || !record(settings['backups'])) return null;
101
+ const backups = settings['backups'];
102
+ const s3 = record(backups['s3']) ? backups['s3'] : {};
103
+ return {
104
+ cron: typeof backups['cron'] === 'string' ? backups['cron'].trim() : '',
105
+ keep:
106
+ typeof backups['cronMaxKeep'] === 'number' ? backups['cronMaxKeep'] : 0,
107
+ offHost: s3['enabled'] === true,
108
+ };
109
+ }
110
+
111
+ /** PocketBase lists `modified` as "2006-01-02 15:04:05.000Z". */
112
+ function newestBackup(backups: unknown[]): number | null {
113
+ const times = backups
114
+ .map((entry) =>
115
+ record(entry) && typeof entry['modified'] === 'string'
116
+ ? Date.parse(entry['modified'].replace(' ', 'T'))
117
+ : NaN
118
+ )
119
+ .filter(Number.isFinite);
120
+ return times.length ? Math.max(...times) : null;
121
+ }
@@ -0,0 +1,142 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { record } from '../config';
3
+ import type { Check } from '../types';
4
+ import { publicViewerIdentity } from './hub-access';
5
+
6
+ /** Loopback address of the hub container on the manager. */
7
+ export const LOCAL_HUB = 'http://127.0.0.1:8090';
8
+
9
+ type Fetcher = typeof fetch;
10
+ export interface HubLogin {
11
+ identity: string;
12
+ password: string;
13
+ }
14
+
15
+ function viewerCheck(
16
+ status: Check['status'],
17
+ summary: string,
18
+ action?: string
19
+ ): Check {
20
+ return {
21
+ id: 'beszel:public-viewer',
22
+ status,
23
+ summary,
24
+ ...(action ? { remedy: { action } } : {}),
25
+ };
26
+ }
27
+
28
+ export interface Session {
29
+ fetcher: Fetcher;
30
+ base: string;
31
+ token: string;
32
+ }
33
+
34
+ export async function api(
35
+ session: Session,
36
+ method: string,
37
+ path: string,
38
+ body?: object
39
+ ): Promise<unknown> {
40
+ const response = await session.fetcher(`${session.base}${path}`, {
41
+ method,
42
+ headers: {
43
+ Authorization: session.token,
44
+ 'Content-Type': 'application/json',
45
+ },
46
+ ...(body ? { body: JSON.stringify(body) } : {}),
47
+ redirect: 'error',
48
+ signal: AbortSignal.timeout(10_000),
49
+ });
50
+ // Bodies can echo emails or tokens: only the status is ever surfaced.
51
+ if (!response.ok) throw new Error(`HTTP_${response.status}`);
52
+ return response.json() as Promise<unknown>;
53
+ }
54
+
55
+ export async function superuserToken(
56
+ session: Session,
57
+ login: HubLogin
58
+ ): Promise<string> {
59
+ const auth = await api(
60
+ session,
61
+ 'POST',
62
+ '/api/collections/_superusers/auth-with-password',
63
+ login
64
+ );
65
+ if (!record(auth) || typeof auth['token'] !== 'string')
66
+ throw new Error('BESZEL_PROTOCOL');
67
+ return auth['token'];
68
+ }
69
+
70
+ async function findViewer(
71
+ session: Session
72
+ ): Promise<{ id: string; role: unknown } | null> {
73
+ const filter = encodeURIComponent(`email="${publicViewerIdentity()}"`);
74
+ const list = await api(
75
+ session,
76
+ 'GET',
77
+ `/api/collections/users/records?filter=${filter}&perPage=2`
78
+ );
79
+ const items =
80
+ record(list) && Array.isArray(list['items']) ? list['items'] : [];
81
+ const first: unknown = items[0];
82
+ if (!record(first) || typeof first['id'] !== 'string') return null;
83
+ return { id: first['id'], role: first['role'] };
84
+ }
85
+
86
+ async function createViewer(session: Session): Promise<void> {
87
+ const password = randomBytes(24).toString('base64url');
88
+ await api(session, 'POST', '/api/collections/users/records', {
89
+ email: publicViewerIdentity(),
90
+ username: 'public-viewer',
91
+ password,
92
+ passwordConfirm: password,
93
+ verified: true,
94
+ role: 'readonly',
95
+ });
96
+ }
97
+
98
+ /** Idempotently makes sure the read-only viewer exists, then re-reads it to
99
+ * prove its role. AUTO_LOGIN signs every visitor in as this account, so an
100
+ * existing account with any other role is demoted rather than trusted. The
101
+ * password is random, never stored or printed, and is not needed to sign in.
102
+ */
103
+ export async function ensurePublicViewer(
104
+ login: HubLogin | null,
105
+ fetcher: Fetcher = fetch,
106
+ base: string = LOCAL_HUB
107
+ ): Promise<Check> {
108
+ if (!login)
109
+ return viewerCheck(
110
+ 'warn',
111
+ 'BESZEL_VIEWER_UNPROVISIONED',
112
+ "The fleet admin login is unavailable. Log the manager's Vault session in with read access to BESZEL_FLEET_AUTH and rerun fleet-sync; until then the public dashboard shows the login page."
113
+ );
114
+ try {
115
+ const anonymous: Session = { fetcher, base, token: '' };
116
+ const session = {
117
+ ...anonymous,
118
+ token: await superuserToken(anonymous, login),
119
+ };
120
+ const existing = await findViewer(session);
121
+ if (!existing) await createViewer(session);
122
+ else if (existing.role !== 'readonly')
123
+ await api(
124
+ session,
125
+ 'PATCH',
126
+ `/api/collections/users/records/${existing.id}`,
127
+ { role: 'readonly' }
128
+ );
129
+ const verified = await findViewer(session);
130
+ if (verified?.role !== 'readonly') throw new Error('BESZEL_VIEWER_ROLE');
131
+ return viewerCheck(
132
+ 'pass',
133
+ `Visitors are signed in automatically as ${publicViewerIdentity()} (read-only).`
134
+ );
135
+ } catch {
136
+ return viewerCheck(
137
+ 'warn',
138
+ 'BESZEL_VIEWER_UNVERIFIED',
139
+ `Could not create or verify the read-only ${publicViewerIdentity()} user with the fleet admin login. Create it in the hub (Settings > Users, role Read-only); never make it an admin. Details are withheld.`
140
+ );
141
+ }
142
+ }
@@ -0,0 +1,93 @@
1
+ import { record } from '../config';
2
+ import type { Check, Runner } from '../types';
3
+ import { publicHubUrl } from './beszel';
4
+ import { secretVia } from '../fleet-config';
5
+
6
+ /** Shared Beszel hub helpers. The hub runs only on the manager; see
7
+ * manager-hub.ts. Workers never host it.
8
+ */
9
+ export function publicEndpointCheck(connected: boolean): Check {
10
+ return connected
11
+ ? {
12
+ id: 'beszel:public-endpoint',
13
+ status: 'pass',
14
+ summary: `Cloudflare connector for ${publicHubUrl()} was started from the Vault credential.`,
15
+ }
16
+ : {
17
+ id: 'beszel:public-endpoint',
18
+ status: 'warn',
19
+ summary: 'BESZEL_TUNNEL_CREDENTIAL_UNAVAILABLE',
20
+ remedy: {
21
+ action: `Create the Cloudflare tunnel and store its token in the fleet's secrets, then log the manager's Vault session in with read access to secret/beszel/tunnel and rerun fleet-sync. The private dashboard is unaffected; ${publicHubUrl()} stays offline until then.`,
22
+ },
23
+ };
24
+ }
25
+
26
+ /** The dashboard connector token from this fleet's secrets provider. Null
27
+ * when it is unavailable. Never printed, logged or placed in argv.
28
+ */
29
+ export function tunnelToken(run: Runner): Promise<string | null> {
30
+ return secretVia(run)('monitoring-tunnel-token');
31
+ }
32
+ export function failure(code: string, action: string): Check[] {
33
+ return [
34
+ { id: 'beszel:hub', status: 'fail', summary: code, remedy: { action } },
35
+ ];
36
+ }
37
+
38
+ export function inspectRoute(
39
+ output: string,
40
+ hub: string,
41
+ target = { port: '8443', proxy: 'http://127.0.0.1:8090' }
42
+ ): boolean | Check[] {
43
+ let configured = false;
44
+ try {
45
+ const value: unknown = JSON.parse(output);
46
+ if (!record(value)) throw new Error();
47
+ if (publicOrConflictingTcp(value, hub, target.port))
48
+ return failure(
49
+ 'BESZEL_PRIVATE_ROUTE_CONFLICT',
50
+ 'Existing Funnel/public or TCP routing conflicts with private Beszel access. Review the source-owned route; do not reset Serve.'
51
+ );
52
+ const web = value['Web'];
53
+ if (record(web)) {
54
+ const entry = web[new URL(hub).hostname + ':' + target.port];
55
+ if (entry !== undefined) {
56
+ if (!expectedHandler(entry, target.proxy))
57
+ return failure(
58
+ 'BESZEL_PRIVATE_ROUTE_CONFLICT',
59
+ 'Port 8443 is owned by a different Tailscale Serve handler. Review a source-controlled route migration; do not reset Serve.'
60
+ );
61
+ configured = true;
62
+ }
63
+ }
64
+ } catch {
65
+ return failure(
66
+ 'BESZEL_PRIVATE_ROUTE_UNVERIFIED',
67
+ 'Review Tailscale Serve status without exposing raw configuration.'
68
+ );
69
+ }
70
+ return configured;
71
+ }
72
+
73
+ function publicOrConflictingTcp(
74
+ value: Record<string, unknown>,
75
+ hub: string,
76
+ port: string
77
+ ): boolean {
78
+ const funnel = value['AllowFunnel'];
79
+ if (record(funnel) && funnel[new URL(hub).hostname + ':' + port] === true)
80
+ return true;
81
+ const tcp = value['TCP'];
82
+ if (!record(tcp) || tcp[port] === undefined) return false;
83
+ return !record(tcp[port]) || tcp[port]['HTTPS'] !== true;
84
+ }
85
+
86
+ function expectedHandler(entry: unknown, proxy: string): boolean {
87
+ return (
88
+ record(entry) &&
89
+ record(entry['Handlers']) &&
90
+ record(entry['Handlers']['/']) &&
91
+ entry['Handlers']['/']['Proxy'] === proxy
92
+ );
93
+ }
@@ -0,0 +1,73 @@
1
+ import { userInfo } from 'node:os';
2
+ import { resolve } from 'node:path';
3
+ import macos from '../../ansible/macos/policy.json';
4
+ import { runAnsibleWithReceipt } from '../platform/ansible-receipt';
5
+ import { privateHub } from './beszel';
6
+ import type { AgentEnrollment } from './enroll';
7
+ import type { Check, Runner } from '../types';
8
+
9
+ /** Installs the manager's own Beszel agent locally (never over SSH) as a
10
+ * per-user launchd agent, so the manager is always a system on its own hub.
11
+ * No sudo: fleet-sync on the manager stays unattended.
12
+ */
13
+ export async function installManagerAgent(
14
+ root: string,
15
+ { node, enrollment }: { node: string; enrollment: AgentEnrollment },
16
+ run: Runner,
17
+ { uid = userInfo().uid, directory }: { uid?: number; directory?: string } = {}
18
+ ): Promise<Check[]> {
19
+ const monitoring = resolve(root, 'ansible/monitoring');
20
+ const result = await runAnsibleWithReceipt(
21
+ run,
22
+ 'agent-darwin.yml',
23
+ {
24
+ cwd: monitoring,
25
+ timeoutMs: 300_000,
26
+ input: JSON.stringify({
27
+ beszel_node: node,
28
+ beszel_hub_url: privateHub(enrollment.hubUrl),
29
+ beszel_public_key: enrollment.publicKey,
30
+ beszel_agent_token: enrollment.token,
31
+ }),
32
+ },
33
+ {
34
+ name: 'beszel-manager-agent',
35
+ sources: [monitoring, resolve(root, 'ansible/macos')],
36
+ verify: async () => agentRunning(run, uid),
37
+ ...(directory ? { directory } : {}),
38
+ }
39
+ );
40
+ if (!result.ok)
41
+ return [
42
+ {
43
+ id: 'beszel:installation',
44
+ status: 'fail',
45
+ summary: 'BESZEL_INSTALLATION_INCOMPLETE',
46
+ remedy: {
47
+ action:
48
+ 'Inspect signed artifact access, free disk and ~/Library/LaunchAgents on the manager. A leftover root daemon must be removed once (sudo launchctl bootout system/' +
49
+ macos.beszelDaemon +
50
+ '). Agent data under ~/Library/Application Support/personal-beszel-agent is preserved.',
51
+ },
52
+ },
53
+ ];
54
+ const running = await agentRunning(run, uid);
55
+ return [
56
+ {
57
+ id: 'beszel:service',
58
+ status: running ? 'pass' : 'fail',
59
+ summary: running
60
+ ? 'The manager runs its own Beszel agent as an always-on launch agent; hub metrics remain a separate gate.'
61
+ : 'BESZEL_SERVICE_STOPPED',
62
+ },
63
+ ];
64
+ }
65
+
66
+ async function agentRunning(run: Runner, uid: number): Promise<boolean> {
67
+ const state = await run([
68
+ '/bin/launchctl',
69
+ 'print',
70
+ `gui/${uid}/${macos.beszelDaemon}`,
71
+ ]);
72
+ return state.ok && /state = running/.test(state.stdout);
73
+ }
@@ -0,0 +1,248 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { delimiter, join, resolve } from 'node:path';
4
+ import { runAnsible } from '../platform/ansible';
5
+ import { runAnsibleWithReceipt } from '../platform/ansible-receipt';
6
+ import { failure, inspectRoute, publicEndpointCheck, tunnelToken } from './hub';
7
+ import { publicViewerIdentity, fleetAccount } from './hub-access';
8
+ import { ensurePublicViewer, type HubLogin } from './hub-viewer';
9
+ import { verifyHubBackups } from './hub-backup';
10
+ import type { FleetDefinition } from '../definition';
11
+ import { hostId, hubUrl } from '../topology';
12
+ import { publicHubUrl, privateHub } from './beszel';
13
+ import type { Check, Runner } from '../types';
14
+
15
+ /** Bundled CLI of the Tailscale macOS app when no `tailscale` is on PATH. */
16
+ export const TAILSCALE_APP_CLI =
17
+ '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
18
+ const LOCAL_HUB = 'http://127.0.0.1:8090';
19
+ /** The one place the route is declared; `hub-route-darwin.yml` receives it. */
20
+ export const HUB_ROUTE = { port: '8443', proxy: LOCAL_HUB };
21
+
22
+ /** Per-user hub paths on the manager. Data stays in the human's home (and its
23
+ * normal backups); it is never copied to a worker.
24
+ */
25
+ export function managerHubPaths(home = homedir()): {
26
+ data: string;
27
+ tunnel: string;
28
+ } {
29
+ const support = join(home, 'Library', 'Application Support');
30
+ return {
31
+ data: join(support, 'personal-beszel'),
32
+ tunnel: join(support, 'personal-beszel-tunnel'),
33
+ };
34
+ }
35
+
36
+ /** `tailscale` on PATH (Homebrew or the app's installed CLI), else the app
37
+ * bundle's executable; null when neither exists.
38
+ */
39
+ export function tailscaleCommand(
40
+ path = process.env['PATH'] ?? '',
41
+ exists: (candidate: string) => boolean = existsSync
42
+ ): string | null {
43
+ for (const directory of path.split(delimiter).filter(Boolean)) {
44
+ const candidate = join(directory, 'tailscale');
45
+ if (exists(candidate)) return candidate;
46
+ }
47
+ return exists(TAILSCALE_APP_CLI) ? TAILSCALE_APP_CLI : null;
48
+ }
49
+
50
+ export interface ManagerHubOptions {
51
+ home?: string;
52
+ tailscale?: string | null;
53
+ /** Fleet login for a fresh hub; defaults to the manager's Vault session. */
54
+ account?: () => Promise<HubLogin | null>;
55
+ /** Receipt directory for skipping an unchanged hub apply. */
56
+ receipts?: string;
57
+ /** Ensures the read-only AUTO_LOGIN user; defaults to the loopback hub API. */
58
+ viewer?: (login: HubLogin | null) => Promise<Check>;
59
+ /** Reads the hub's backup settings; defaults to the loopback hub API. */
60
+ backups?: (login: HubLogin | null) => Promise<Check>;
61
+ }
62
+
63
+ /** Converges the singleton Beszel hub locally on the manager: pinned Compose
64
+ * under Docker Desktop, the connector token from the manager's own Vault
65
+ * session (private variables file, never argv or output), a private Tailscale
66
+ * Serve route (its own playbook), then independent health checks locally and
67
+ * over the tailnet.
68
+ */
69
+ export async function convergeManagerHub(
70
+ root: string,
71
+ definition: FleetDefinition,
72
+ run: Runner,
73
+ options: ManagerHubOptions = {}
74
+ ): Promise<Check[]> {
75
+ const hub = privateHub(hubUrl(definition));
76
+ const refused = await preconditions(definition, run);
77
+ if (refused) return refused;
78
+ const paths = managerHubPaths(options.home);
79
+ const tunnel = await tunnelToken(run);
80
+ const account = await (options.account ?? fleetAccount)();
81
+ const monitoring = resolve(root, 'ansible/monitoring');
82
+ const result = await runAnsibleWithReceipt(
83
+ run,
84
+ 'hub-darwin.yml',
85
+ {
86
+ cwd: monitoring,
87
+ input: JSON.stringify(hubVariables(hub, paths, tunnel, account)),
88
+ timeoutMs: 300_000,
89
+ },
90
+ {
91
+ name: 'beszel-hub',
92
+ sources: [monitoring],
93
+ // Health is verified independently below on every run.
94
+ verify: async () =>
95
+ (
96
+ await run([
97
+ 'curl',
98
+ '-fsS',
99
+ '--max-time',
100
+ '5',
101
+ '-o',
102
+ '/dev/null',
103
+ `${LOCAL_HUB}/api/health`,
104
+ ])
105
+ ).ok,
106
+ ...(options.receipts ? { directory: options.receipts } : {}),
107
+ }
108
+ );
109
+ if (!result.ok)
110
+ return failure(
111
+ 'BESZEL_HUB_INSTALLATION_INCOMPLETE',
112
+ 'Inspect Docker Desktop, the Compose image, file sharing for ~/Library/Application Support and disk space. Existing data is never deleted.'
113
+ );
114
+ const route = await servePrivateRoute(
115
+ { hub, monitoring },
116
+ run,
117
+ options.tailscale === undefined ? tailscaleCommand() : options.tailscale
118
+ );
119
+ if (route) return route;
120
+ const health = await verifyHealth(hub, run);
121
+ if (health) return health;
122
+ return [
123
+ {
124
+ id: 'beszel:hub',
125
+ status: 'pass',
126
+ summary: `Pinned hub on the manager is healthy locally and at ${hub}; authentication and agent metrics are separate checks.`,
127
+ },
128
+ publicEndpointCheck(tunnel !== null),
129
+ await ensureViewer(options, account),
130
+ await (options.backups ?? verifyHubBackups)(account),
131
+ ];
132
+ }
133
+
134
+ function hubVariables(
135
+ hub: string,
136
+ paths: { data: string; tunnel: string },
137
+ tunnel: string | null,
138
+ account: HubLogin | null
139
+ ): Record<string, string> {
140
+ return {
141
+ beszel_hub_url: hub,
142
+ beszel_public_url: publicHubUrl(),
143
+ beszel_auto_login: publicViewerIdentity(),
144
+ beszel_data_dir: paths.data,
145
+ beszel_tunnel_dir: paths.tunnel,
146
+ ...(tunnel ? { beszel_tunnel_token: tunnel } : {}),
147
+ ...(account
148
+ ? {
149
+ beszel_fleet_identity: account.identity,
150
+ beszel_fleet_password: account.password,
151
+ }
152
+ : {}),
153
+ };
154
+ }
155
+
156
+ function ensureViewer(
157
+ options: ManagerHubOptions,
158
+ account: HubLogin | null
159
+ ): Promise<Check> {
160
+ return (options.viewer ?? ensurePublicViewer)(account);
161
+ }
162
+
163
+ /** Runs only on the manager, and only with Docker Desktop's engine up. */
164
+ async function preconditions(
165
+ definition: FleetDefinition,
166
+ run: Runner
167
+ ): Promise<Check[] | null> {
168
+ const local = await run(['/usr/sbin/scutil', '--get', 'LocalHostName']);
169
+ if (!local.ok || hostId(local.stdout.trim()) !== definition.manager.id)
170
+ return failure(
171
+ 'BESZEL_NOT_MANAGER',
172
+ `The Beszel hub runs only on the manager (${definition.manager.id}). Run fleet-sync from the manager; workers only run the agent.`
173
+ );
174
+ const docker = await run(
175
+ ['docker', 'info', '--format', '{{.ServerVersion}}'],
176
+ { timeoutMs: 20_000 }
177
+ );
178
+ if (!docker.ok || docker.stdout.trim() === '')
179
+ return failure(
180
+ 'BESZEL_DOCKER_DESKTOP_REQUIRED',
181
+ 'Start Docker Desktop on the manager (and enable "Start Docker Desktop when you sign in"), then rerun fleet-sync. Existing hub data is never deleted.'
182
+ );
183
+ return null;
184
+ }
185
+
186
+ /** `hub-route-darwin.yml` publishes the route when its port has no owner; this
187
+ * refuses a conflicting owner beforehand and measures the result.
188
+ */
189
+ async function servePrivateRoute(
190
+ { hub, monitoring }: { hub: string; monitoring: string },
191
+ run: Runner,
192
+ cli: string | null
193
+ ): Promise<Check[] | null> {
194
+ if (!cli)
195
+ return failure(
196
+ 'BESZEL_TAILSCALE_REQUIRED',
197
+ `Install and sign in to Tailscale on the manager (Homebrew CLI or ${TAILSCALE_APP_CLI}).`
198
+ );
199
+ const status = await run([cli, 'serve', 'status', '--json']);
200
+ if (!status.ok)
201
+ return failure(
202
+ 'BESZEL_PRIVATE_ROUTE_UNVERIFIED',
203
+ 'Inspect Tailscale Serve permission and existing private routes before retry.'
204
+ );
205
+ const route = inspectRoute(status.stdout, hub, HUB_ROUTE);
206
+ if (Array.isArray(route)) return route;
207
+ if (!route) {
208
+ const published = await runAnsible(run, 'hub-route-darwin.yml', {
209
+ cwd: monitoring,
210
+ input: JSON.stringify({
211
+ beszel_tailscale_cli: cli,
212
+ beszel_route_port: HUB_ROUTE.port,
213
+ beszel_route_target: HUB_ROUTE.proxy,
214
+ }),
215
+ timeoutMs: 120_000,
216
+ });
217
+ if (!published.ok)
218
+ return failure(
219
+ 'BESZEL_PRIVATE_ROUTE_REQUIRED',
220
+ 'Allow Tailscale Serve (HTTPS certificates) for this tailnet and approve it in the Tailscale app if asked; no tailnet policy or public ingress is changed.'
221
+ );
222
+ }
223
+ const verified = await run([cli, 'serve', 'status', '--json']);
224
+ if (!verified.ok || inspectRoute(verified.stdout, hub, HUB_ROUTE) !== true)
225
+ return failure(
226
+ 'BESZEL_PRIVATE_ROUTE_UNVERIFIED',
227
+ 'The requested route was not independently observed after application. Inspect Serve permissions and configuration; installer success is insufficient.'
228
+ );
229
+ return null;
230
+ }
231
+
232
+ async function verifyHealth(hub: string, run: Runner): Promise<Check[] | null> {
233
+ const probe = (url: string) =>
234
+ run(['curl', '-fsS', '--max-time', '10', '-o', '/dev/null', url], {
235
+ timeoutMs: 15_000,
236
+ });
237
+ if (!(await probe(`${LOCAL_HUB}/api/health`)).ok)
238
+ return failure(
239
+ 'BESZEL_HUB_UNHEALTHY',
240
+ 'The hub container did not answer /api/health locally. Inspect Docker Desktop and the personal-beszel Compose project; preserve its data.'
241
+ );
242
+ if (!(await probe(`${hub}/api/health`)).ok)
243
+ return failure(
244
+ 'BESZEL_HUB_UNREACHABLE',
245
+ `The hub is healthy locally but not at ${hub}. Inspect Tailscale on the manager and its Serve route.`
246
+ );
247
+ return null;
248
+ }