@crvouga/dev-fleet 0.0.0-stage → 0.1.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 (333) 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 +157 -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 +128 -0
  30. package/agents/t3.json +40 -0
  31. package/ansible/macos/manager.yml +30 -0
  32. package/ansible/macos/policy.json +17 -0
  33. package/ansible/monitoring/README.md +154 -0
  34. package/ansible/monitoring/agent-darwin.yml +198 -0
  35. package/ansible/monitoring/agent.yml +134 -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/lock.json +11 -0
  40. package/ansible/net/remote-probe.sh +13 -0
  41. package/ansible/ubuntu/authorize-sudo.sh +51 -0
  42. package/ansible/ubuntu/baseline.yml +89 -0
  43. package/ansible/ubuntu/disk-guard.py +104 -0
  44. package/ansible/ubuntu/policy.json +43 -0
  45. package/ansible/ubuntu/recovery.yml +15 -0
  46. package/ansible/ubuntu/t3/policy.json +7 -0
  47. package/ansible/ubuntu/t3-desktop.yml +220 -0
  48. package/ansible/ubuntu/t3-health.sh +33 -0
  49. package/ansible/ubuntu/t3-policy.yml +76 -0
  50. package/ansible/ubuntu/t3-route.yml +28 -0
  51. package/ansible/ubuntu/tailscale.yml +75 -0
  52. package/ansible/ubuntu/tasks/credentials.yml +47 -0
  53. package/ansible/ubuntu/tasks/docker.yml +31 -0
  54. package/ansible/ubuntu/tasks/packages.yml +56 -0
  55. package/ansible/ubuntu/tasks/power.yml +51 -0
  56. package/ansible/ubuntu/tasks/recovery.yml +39 -0
  57. package/ansible/ubuntu/tasks/resources.yml +252 -0
  58. package/ansible/ubuntu/tasks/services.yml +31 -0
  59. package/ansible/ubuntu/tasks/ssh.yml +71 -0
  60. package/ansible/ubuntu/tasks/user.yml +120 -0
  61. package/bin/devfleet.mjs +36 -0
  62. package/bootstrap/bootstrap.ps1 +50 -0
  63. package/bootstrap/bootstrap.sh +73 -0
  64. package/bootstrap/devfleet +7 -0
  65. package/bootstrap/docker-desktop.sh +33 -0
  66. package/bootstrap/doctor.ts +189 -0
  67. package/bootstrap/fleet-manager-run.sh +76 -0
  68. package/bootstrap/fleet-remote-bootstrap.sh +185 -0
  69. package/bootstrap/global-config.ts +113 -0
  70. package/bootstrap/install-launcher.sh +7 -0
  71. package/bootstrap/native-aws.ts +86 -0
  72. package/bootstrap/native-t3.ts +154 -0
  73. package/bootstrap/npm.ts +350 -0
  74. package/bootstrap/setup-status.ts +51 -0
  75. package/bootstrap/setup.ts +88 -0
  76. package/bootstrap/shell-path.ts +105 -0
  77. package/bootstrap/verify-t3-archive.py +49 -0
  78. package/bootstrap/worker-auth.ts +65 -0
  79. package/bootstrap/worker-github.ts +47 -0
  80. package/bootstrap/worker-tools.ts +24 -0
  81. package/cliproxy/AGENTS.md +11 -0
  82. package/cliproxy/README.md +269 -0
  83. package/cliproxy/clients/bootstrap.py +143 -0
  84. package/cliproxy/clients/launcher.py +57 -0
  85. package/cliproxy/compose.yaml +90 -0
  86. package/cliproxy/config.template.yaml +70 -0
  87. package/cliproxy/docs/cloud-adoption.md +16 -0
  88. package/cliproxy/docs/deployment.png +0 -0
  89. package/cliproxy/docs/deployment.svg +109 -0
  90. package/cliproxy/docs/health-workflow.example.yml +185 -0
  91. package/cliproxy/docs/migration.md +131 -0
  92. package/cliproxy/docs/model-health-report.schema.json +364 -0
  93. package/cliproxy/docs/model-health.md +393 -0
  94. package/cliproxy/image.lock.json +5 -0
  95. package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
  96. package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
  97. package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
  98. package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
  99. package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
  100. package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
  101. package/cliproxy/models.json +1618 -0
  102. package/cliproxy/requirements.lock +78 -0
  103. package/cliproxy/scripts/accounts.py +38 -0
  104. package/cliproxy/scripts/audit.py +123 -0
  105. package/cliproxy/scripts/backup +234 -0
  106. package/cliproxy/scripts/check.py +34 -0
  107. package/cliproxy/scripts/checkpoint.py +226 -0
  108. package/cliproxy/scripts/cli.py +64 -0
  109. package/cliproxy/scripts/cliproxy_host.py +257 -0
  110. package/cliproxy/scripts/cloud_state.py +150 -0
  111. package/cliproxy/scripts/container-health +6 -0
  112. package/cliproxy/scripts/docker +24 -0
  113. package/cliproxy/scripts/external-health.py +79 -0
  114. package/cliproxy/scripts/fleet_config.py +43 -0
  115. package/cliproxy/scripts/fleet_sync.py +274 -0
  116. package/cliproxy/scripts/initialize +84 -0
  117. package/cliproxy/scripts/install-dependencies +48 -0
  118. package/cliproxy/scripts/install.py +406 -0
  119. package/cliproxy/scripts/model_health.py +16 -0
  120. package/cliproxy/scripts/modelhealth/__init__.py +5 -0
  121. package/cliproxy/scripts/modelhealth/checks.py +429 -0
  122. package/cliproxy/scripts/modelhealth/cli.py +117 -0
  123. package/cliproxy/scripts/modelhealth/config.py +186 -0
  124. package/cliproxy/scripts/modelhealth/emulator.py +536 -0
  125. package/cliproxy/scripts/modelhealth/expect.py +88 -0
  126. package/cliproxy/scripts/modelhealth/findings.py +183 -0
  127. package/cliproxy/scripts/modelhealth/protocol.py +330 -0
  128. package/cliproxy/scripts/modelhealth/redact.py +28 -0
  129. package/cliproxy/scripts/modelhealth/report.py +150 -0
  130. package/cliproxy/scripts/modelhealth/runner.py +390 -0
  131. package/cliproxy/scripts/modelhealth/scenario.py +131 -0
  132. package/cliproxy/scripts/modelhealth/schema.py +63 -0
  133. package/cliproxy/scripts/modelhealth/simulate.py +111 -0
  134. package/cliproxy/scripts/modelhealth/transport.py +213 -0
  135. package/cliproxy/scripts/monitor +68 -0
  136. package/cliproxy/scripts/ops +527 -0
  137. package/cliproxy/scripts/public-endpoint +127 -0
  138. package/cliproxy/scripts/recover_node.py +177 -0
  139. package/cliproxy/scripts/restore-verify +60 -0
  140. package/cliproxy/scripts/retention.py +144 -0
  141. package/cliproxy/scripts/service-start +7 -0
  142. package/cliproxy/scripts/validate-config +56 -0
  143. package/cliproxy/scripts/vault-access.py +57 -0
  144. package/cliproxy/scripts/vault-backup-login +55 -0
  145. package/cliproxy/scripts/vault-recovery +56 -0
  146. package/cliproxy/scripts/vault-renew +30 -0
  147. package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
  148. package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
  149. package/cliproxy/systemd/cliproxy-backup.service +13 -0
  150. package/cliproxy/systemd/cliproxy-backup.timer +8 -0
  151. package/cliproxy/systemd/cliproxy-fleet.service +17 -0
  152. package/cliproxy/systemd/cliproxy-gc.service +13 -0
  153. package/cliproxy/systemd/cliproxy-gc.timer +8 -0
  154. package/cliproxy/systemd/cliproxy-monitor.service +8 -0
  155. package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
  156. package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
  157. package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
  158. package/cliproxy/vendor/LICENSE +22 -0
  159. package/cliproxy/vendor/config.upstream.yaml +1349 -0
  160. package/examples/fleet.json +95 -0
  161. package/examples/roster.json +13 -0
  162. package/mise.lock +1492 -0
  163. package/mise.toml +99 -0
  164. package/package.json +65 -4
  165. package/schema/fleet.schema.json +364 -0
  166. package/schema/roster.schema.json +77 -0
  167. package/src/agents/account-relay.ts +111 -0
  168. package/src/agents/accounts.ts +85 -0
  169. package/src/agents/audit.ts +77 -0
  170. package/src/agents/cli.ts +13 -0
  171. package/src/agents/command.ts +204 -0
  172. package/src/agents/converge.ts +123 -0
  173. package/src/agents/doctor.ts +107 -0
  174. package/src/agents/errors.ts +2 -0
  175. package/src/agents/files.ts +73 -0
  176. package/src/agents/fleet-verify.ts +156 -0
  177. package/src/agents/launch.ts +290 -0
  178. package/src/agents/native-verify.ts +226 -0
  179. package/src/agents/provider-errors.ts +50 -0
  180. package/src/agents/registry.ts +55 -0
  181. package/src/agents/render.ts +110 -0
  182. package/src/agents/round-trip.ts +192 -0
  183. package/src/agents/secrets.ts +13 -0
  184. package/src/agents/toolchain.ts +34 -0
  185. package/src/agents/verify-cache.ts +103 -0
  186. package/src/agents/verify.ts +127 -0
  187. package/src/auth-checks.ts +163 -0
  188. package/src/cli/agent-guide.ts +95 -0
  189. package/src/cli/app.ts +115 -0
  190. package/src/cli/cli.ts +21 -0
  191. package/src/cli/command-index.ts +50 -0
  192. package/src/cli/contracts.ts +25 -0
  193. package/src/cli/fleet-reference.ts +17 -0
  194. package/src/cli/fleet-sync-checkout.ts +91 -0
  195. package/src/cli/fleet-sync-enrollment.ts +144 -0
  196. package/src/cli/fleet-sync-forward.ts +134 -0
  197. package/src/cli/fleet-sync-manager.ts +193 -0
  198. package/src/cli/fleet-sync-monitoring.ts +185 -0
  199. package/src/cli/fleet-sync-output.ts +314 -0
  200. package/src/cli/fleet-sync-privilege.ts +27 -0
  201. package/src/cli/fleet-sync-progress.ts +109 -0
  202. package/src/cli/fleet-sync-remote.ts +236 -0
  203. package/src/cli/fleet-sync-report.ts +26 -0
  204. package/src/cli/fleet-sync-schedule.ts +138 -0
  205. package/src/cli/fleet-sync-vault.ts +166 -0
  206. package/src/cli/fleet-sync.ts +309 -0
  207. package/src/cli/fleet.ts +163 -0
  208. package/src/cli/managed-health.ts +219 -0
  209. package/src/cli/node-deployment.ts +34 -0
  210. package/src/cli/node-sync.ts +114 -0
  211. package/src/cli/node-t3.ts +31 -0
  212. package/src/cli/options.ts +141 -0
  213. package/src/cli/probe-source.ts +7 -0
  214. package/src/cli/program.ts +169 -0
  215. package/src/cli/register-fleet.ts +246 -0
  216. package/src/cli/registry.ts +158 -0
  217. package/src/cli/repair-apply.ts +67 -0
  218. package/src/cli/repair-guard.ts +11 -0
  219. package/src/cli/repair-inspect.ts +150 -0
  220. package/src/cli/repair-script.ts +76 -0
  221. package/src/cli/repair.ts +209 -0
  222. package/src/cli/roster.ts +89 -0
  223. package/src/cli/search.ts +35 -0
  224. package/src/cli/settings.ts +64 -0
  225. package/src/cli/source-operations.ts +78 -0
  226. package/src/cli/t3/command.ts +90 -0
  227. package/src/cli/t3/register.ts +55 -0
  228. package/src/cli/tailnet.ts +127 -0
  229. package/src/cli/ui-actions.ts +133 -0
  230. package/src/cli/ui-controller.ts +113 -0
  231. package/src/cli/ui-data.ts +106 -0
  232. package/src/cli/ui-execute.ts +163 -0
  233. package/src/cli/ui-repairs.ts +34 -0
  234. package/src/cli/ui-reports.ts +211 -0
  235. package/src/cli/ui-targets.ts +100 -0
  236. package/src/cli/ui-terminal.ts +109 -0
  237. package/src/cli/ui-types.ts +49 -0
  238. package/src/cli/ui-wizard.ts +101 -0
  239. package/src/cli/ui.ts +9 -0
  240. package/src/cli/worker-health-output.ts +37 -0
  241. package/src/config.ts +186 -0
  242. package/src/configure.ts +255 -0
  243. package/src/definition.ts +260 -0
  244. package/src/diagnostics.ts +92 -0
  245. package/src/doctor-check.ts +10 -0
  246. package/src/errors.ts +1 -0
  247. package/src/fingerprint.ts +120 -0
  248. package/src/fleet-config.ts +88 -0
  249. package/src/index.ts +6 -0
  250. package/src/input-hashes.ts +73 -0
  251. package/src/machine/local-source.ts +52 -0
  252. package/src/machine/lock-error.ts +2 -0
  253. package/src/machine/platform.ts +44 -0
  254. package/src/machine/source.ts +68 -0
  255. package/src/machine/state.ts +185 -0
  256. package/src/machine-health.ts +95 -0
  257. package/src/managed-source.ts +47 -0
  258. package/src/manager-failure.ts +48 -0
  259. package/src/manager-sync.ts +400 -0
  260. package/src/manager.ts +62 -0
  261. package/src/monitoring/apply.ts +171 -0
  262. package/src/monitoring/beszel.ts +191 -0
  263. package/src/monitoring/enroll.ts +110 -0
  264. package/src/monitoring/hub-access.ts +113 -0
  265. package/src/monitoring/hub-viewer.ts +142 -0
  266. package/src/monitoring/hub.ts +93 -0
  267. package/src/monitoring/manager-agent.ts +73 -0
  268. package/src/monitoring/manager-hub.ts +232 -0
  269. package/src/monitoring/remote.ts +65 -0
  270. package/src/network-definition.ts +83 -0
  271. package/src/node-converge-linux.ts +372 -0
  272. package/src/node-converge-shared.ts +83 -0
  273. package/src/node-converge.ts +40 -0
  274. package/src/onboard.ts +35 -0
  275. package/src/platform/ansible-receipt.ts +128 -0
  276. package/src/platform/ansible.ts +62 -0
  277. package/src/platform/darwin.ts +203 -0
  278. package/src/platform/detect.ts +11 -0
  279. package/src/platform/fallback.ts +110 -0
  280. package/src/platform/index.ts +38 -0
  281. package/src/platform/launchd.ts +98 -0
  282. package/src/platform/linux.ts +139 -0
  283. package/src/platform/ps.ts +45 -0
  284. package/src/platform/resource-verify.ts +128 -0
  285. package/src/platform/systemd-user.ts +95 -0
  286. package/src/platform/t3-policy.ts +137 -0
  287. package/src/platform/t3-route.ts +68 -0
  288. package/src/platform/t3-service.ts +75 -0
  289. package/src/platform/types.ts +123 -0
  290. package/src/platform/ubuntu-audit.ts +63 -0
  291. package/src/platform/ubuntu-verify.ts +105 -0
  292. package/src/platform/windows.ts +148 -0
  293. package/src/power-recovery.ts +55 -0
  294. package/src/probe.ts +51 -0
  295. package/src/protocol.ts +119 -0
  296. package/src/proxy-converge.ts +85 -0
  297. package/src/remote-bootstrap.ts +212 -0
  298. package/src/remote-entry.ts +89 -0
  299. package/src/remote-lock.ts +12 -0
  300. package/src/repair-catalog.ts +206 -0
  301. package/src/repair-plan.ts +78 -0
  302. package/src/repair-runner.ts +84 -0
  303. package/src/report.ts +71 -0
  304. package/src/runner.ts +104 -0
  305. package/src/secrets.ts +169 -0
  306. package/src/service-auth.ts +115 -0
  307. package/src/ssh.ts +135 -0
  308. package/src/sync.ts +104 -0
  309. package/src/t3/accounts.ts +81 -0
  310. package/src/t3/desired.ts +165 -0
  311. package/src/t3/files.ts +81 -0
  312. package/src/t3/names-check.ts +53 -0
  313. package/src/t3/names.ts +155 -0
  314. package/src/t3/plan.ts +82 -0
  315. package/src/t3/providers.ts +74 -0
  316. package/src/t3/reconcile.ts +123 -0
  317. package/src/t3-endpoint.ts +48 -0
  318. package/src/t3-linked-check.ts +101 -0
  319. package/src/tailnet-health.ts +231 -0
  320. package/src/tailnet-matching.ts +37 -0
  321. package/src/tailscale.ts +141 -0
  322. package/src/test-fixtures.ts +36 -0
  323. package/src/toolchain-checks.ts +83 -0
  324. package/src/topology.ts +24 -0
  325. package/src/transport.ts +122 -0
  326. package/src/types.ts +95 -0
  327. package/src/vault-config.ts +38 -0
  328. package/src/worker-access-checks.ts +115 -0
  329. package/src/worker-capacity.ts +34 -0
  330. package/src/worker-host-checks.ts +176 -0
  331. package/src/worker-policy.ts +64 -0
  332. package/src/worker-readiness.ts +64 -0
  333. package/src/workstation-checks.ts +29 -0
@@ -0,0 +1,191 @@
1
+ import { fleet } from '../fleet-config';
2
+ import type { Check } from '../types';
3
+
4
+ export interface BeszelEvidence {
5
+ dashboardUrl: string;
6
+ checks: Check[];
7
+ receivedAt: string | null;
8
+ }
9
+ export type BeszelRequest = (path: string) => Promise<unknown>;
10
+ type Row = Record<string, unknown>;
11
+ function object(value: unknown): value is Row {
12
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
13
+ }
14
+ function items(value: unknown): Row[] {
15
+ if (
16
+ !object(value) ||
17
+ !Array.isArray(value['items']) ||
18
+ !value['items'].every(object)
19
+ )
20
+ throw new Error('BESZEL_PROTOCOL');
21
+ return value['items'];
22
+ }
23
+ function check(
24
+ id: string,
25
+ status: Check['status'],
26
+ summary: string,
27
+ action: string
28
+ ): Check {
29
+ return { id: `beszel:${id}`, status, summary, remedy: { action } };
30
+ }
31
+ /** Public dashboard hostname served through the Cloudflare
32
+ * tunnel `beszel`. Agents and the manager keep using the private hub URL.
33
+ */
34
+ export const PUBLIC_HUB_URL = fleet.services.monitoring.publicUrl;
35
+
36
+ export function privateHub(value: string): string {
37
+ const url = new URL(value);
38
+ if (
39
+ url.protocol !== 'https:' ||
40
+ !url.hostname.endsWith('.ts.net') ||
41
+ url.port !== '8443' ||
42
+ url.username ||
43
+ url.password ||
44
+ url.search ||
45
+ url.hash ||
46
+ url.pathname !== '/'
47
+ )
48
+ throw new Error('BESZEL_PRIVATE_URL');
49
+ return url.origin;
50
+ }
51
+
52
+ /** Read-only PocketBase endpoints, verified against upstream Beszel v0.21.1.
53
+ * Never return raw records: they may contain tokens, hosts or user information.
54
+ */
55
+ export async function verifyBeszel(
56
+ hub: string,
57
+ node: string,
58
+ request: BeszelRequest,
59
+ now = Date.now()
60
+ ): Promise<BeszelEvidence> {
61
+ const dashboardUrl = privateHub(hub);
62
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(node)) throw new Error('BESZEL_NODE_ID');
63
+ const result: BeszelEvidence = { dashboardUrl, checks: [], receivedAt: null };
64
+ try {
65
+ const info = await request('/api/beszel/info');
66
+ if (!object(info) || info['v'] !== '0.21.1') {
67
+ result.checks.push(
68
+ check(
69
+ 'version',
70
+ 'fail',
71
+ 'BESZEL_VERSION_MISMATCH',
72
+ 'Apply the pinned hub release before using its versioned API contract.'
73
+ )
74
+ );
75
+ return result;
76
+ }
77
+ result.checks.push(
78
+ check('version', 'pass', 'Authenticated hub reports 0.21.1.', 'None.')
79
+ );
80
+ const filter = encodeURIComponent(`name="${node}"`);
81
+ const records = items(
82
+ await request(
83
+ `/api/collections/systems/records?filter=${filter}&perPage=2`
84
+ )
85
+ );
86
+ if (records.length !== 1) {
87
+ result.checks.push(
88
+ check(
89
+ 'registration',
90
+ 'fail',
91
+ records.length ? 'BESZEL_DUPLICATE_NODE' : 'BESZEL_NODE_MISSING',
92
+ 'Review the canonical node in the hub. Preserve its fingerprint and history; do not create a replacement identity.'
93
+ )
94
+ );
95
+ return result;
96
+ }
97
+ await verifySample(records[0]!, result, request, now);
98
+ } catch {
99
+ result.checks.push(
100
+ check(
101
+ 'api',
102
+ 'fail',
103
+ 'BESZEL_API_UNVERIFIED',
104
+ 'Verify private reachability and a valid hub account with access to this node. Raw HTTP bodies are withheld.'
105
+ )
106
+ );
107
+ }
108
+ return result;
109
+ }
110
+
111
+ /** No redirects, browser, token minting, retries or response logging. */
112
+ export function beszelReader(
113
+ hub: string,
114
+ token: string,
115
+ fetcher: typeof fetch = fetch
116
+ ): BeszelRequest {
117
+ const origin = privateHub(hub);
118
+ return async (path) => {
119
+ if (!path.startsWith('/api/')) throw new Error('BESZEL_API_PATH');
120
+ const response = await fetcher(`${origin}${path}`, {
121
+ headers: { Authorization: token },
122
+ redirect: 'error',
123
+ signal: AbortSignal.timeout(10_000),
124
+ });
125
+ if (!response.ok) throw new Error('BESZEL_API_UNVERIFIED');
126
+ const text = await response.text();
127
+ if (text.length > 256_000) throw new Error('BESZEL_RESPONSE_LIMIT');
128
+ return JSON.parse(text) as unknown;
129
+ };
130
+ }
131
+
132
+ async function verifySample(
133
+ record: Row,
134
+ result: BeszelEvidence,
135
+ request: BeszelRequest,
136
+ now: number
137
+ ): Promise<void> {
138
+ const id = record['id'];
139
+ if (typeof id !== 'string' || !/^[a-z0-9]{15}$/.test(id))
140
+ throw new Error('BESZEL_PROTOCOL');
141
+ result.checks.push(
142
+ check(
143
+ 'registration',
144
+ 'pass',
145
+ 'Exactly one canonical node record is visible to this account.',
146
+ 'None.'
147
+ )
148
+ );
149
+ result.checks.push(
150
+ check(
151
+ 'connection',
152
+ record['status'] === 'up' ? 'pass' : 'fail',
153
+ record['status'] === 'up'
154
+ ? 'Hub reports node connected.'
155
+ : 'BESZEL_AGENT_DISCONNECTED',
156
+ 'Inspect agent service, private hub connectivity and node-specific token; preserve its DATA_DIR.'
157
+ )
158
+ );
159
+ await checkFreshness(id, result, request, now);
160
+ }
161
+ async function checkFreshness(
162
+ id: string,
163
+ result: BeszelEvidence,
164
+ request: BeszelRequest,
165
+ now: number
166
+ ): Promise<void> {
167
+ const stats = items(
168
+ await request(
169
+ `/api/collections/system_stats/records?filter=${encodeURIComponent(`system="${id}" && type="1m"`)}&sort=-created&perPage=1&fields=created`
170
+ )
171
+ );
172
+ const created = stats[0]?.['created'];
173
+ const timestamp = typeof created === 'string' ? Date.parse(created) : NaN;
174
+ const fresh =
175
+ Number.isFinite(timestamp) &&
176
+ now - timestamp <= 180_000 &&
177
+ now >= timestamp - 30_000;
178
+ result.receivedAt = Number.isFinite(timestamp)
179
+ ? new Date(timestamp).toISOString()
180
+ : null;
181
+ result.checks.push(
182
+ check(
183
+ 'freshness',
184
+ fresh ? 'pass' : 'fail',
185
+ fresh
186
+ ? `Hub received sample ${result.receivedAt}; within 180 seconds.`
187
+ : 'BESZEL_METRICS_STALE',
188
+ 'Check hub and node clocks, agent connection and disk capacity. Rerun after the next sampling interval.'
189
+ )
190
+ );
191
+ }
@@ -0,0 +1,110 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { record } from '../config';
3
+ import { privateHub } from './beszel';
4
+
5
+ export type HubRequest = (path: string, body?: object) => Promise<unknown>;
6
+ export interface AgentEnrollment {
7
+ systemId: string;
8
+ hubUrl: string;
9
+ publicKey: string;
10
+ token: string;
11
+ }
12
+ function rows(value: unknown): Record<string, unknown>[] {
13
+ if (
14
+ !record(value) ||
15
+ !Array.isArray(value['items']) ||
16
+ !value['items'].every(record)
17
+ )
18
+ throw new Error('BESZEL_PROTOCOL');
19
+ return value['items'];
20
+ }
21
+ function identifier(value: unknown): string {
22
+ if (typeof value !== 'string' || !/^[a-z0-9]{15}$/.test(value))
23
+ throw new Error('BESZEL_PROTOCOL');
24
+ return value;
25
+ }
26
+ /** Mutating API adapter: call only after fleet approval and under manager lock.
27
+ * Uses the same systems/fingerprints API as upstream Add System, never its DB.
28
+ * The returned token is secret material: pass only through private SSH stdin.
29
+ */
30
+ export async function enrollBeszel(
31
+ input: { node: string; hub: string; userId: string; host: string },
32
+ request: HubRequest
33
+ ): Promise<AgentEnrollment> {
34
+ const hubUrl = privateHub(input.hub);
35
+ if (
36
+ !/^[a-z][a-z0-9-]{0,62}$/.test(input.node) ||
37
+ !/^[a-z0-9.-]+\.ts\.net$/.test(input.host)
38
+ )
39
+ throw new Error('BESZEL_NODE_INVALID');
40
+ const info = await hubInfo(request);
41
+ const systemId = await ensureSystem(input, request);
42
+ const fingerprints = rows(
43
+ await request(
44
+ `/api/collections/fingerprints/records?filter=${encodeURIComponent(`system="${systemId}"`)}&perPage=2`
45
+ )
46
+ );
47
+ if (fingerprints.length > 1) throw new Error('BESZEL_DUPLICATE_FINGERPRINT');
48
+ let token = fingerprints[0]?.['token'];
49
+ if (fingerprints.length === 0) {
50
+ token = randomUUID();
51
+ await request('/api/collections/fingerprints/records', {
52
+ id: createHash('sha256')
53
+ .update(`personal:fingerprint:${systemId}`)
54
+ .digest('hex')
55
+ .slice(0, 15),
56
+ system: systemId,
57
+ token,
58
+ });
59
+ }
60
+ if (
61
+ typeof token !== 'string' ||
62
+ !/^[a-zA-Z0-9_-][a-zA-Z0-9_-]{15,63}$/.test(token)
63
+ )
64
+ throw new Error('BESZEL_TOKEN_INVALID');
65
+ return { systemId, hubUrl, publicKey: info['key'], token };
66
+ }
67
+ async function ensureSystem(
68
+ input: { node: string; userId: string; host: string },
69
+ request: HubRequest
70
+ ): Promise<string> {
71
+ const systems = rows(
72
+ await request(
73
+ `/api/collections/systems/records?filter=${encodeURIComponent(`name="${input.node}"`)}&perPage=2`
74
+ )
75
+ );
76
+ if (systems.length > 1) throw new Error('BESZEL_DUPLICATE_NODE');
77
+ if (systems[0]) {
78
+ if (systems[0]['host'] !== input.host)
79
+ throw new Error('BESZEL_HOST_CONFLICT');
80
+ return identifier(systems[0]['id']);
81
+ }
82
+ const id = createHash('sha256')
83
+ .update(`personal:system:${input.node}`)
84
+ .digest('hex')
85
+ .slice(0, 15);
86
+ const created = await request('/api/collections/systems/records', {
87
+ id,
88
+ name: input.node,
89
+ host: input.host,
90
+ port: '45876',
91
+ users: [identifier(input.userId)],
92
+ });
93
+ if (!record(created) || created['id'] !== id)
94
+ throw new Error('BESZEL_PROTOCOL');
95
+ return id;
96
+ }
97
+
98
+ async function hubInfo(
99
+ request: HubRequest
100
+ ): Promise<Record<string, unknown> & { key: string }> {
101
+ const info = await request('/api/beszel/info');
102
+ if (
103
+ !record(info) ||
104
+ info['v'] !== '0.21.1' ||
105
+ typeof info['key'] !== 'string' ||
106
+ !info['key'].startsWith('ssh-ed25519 ')
107
+ )
108
+ throw new Error('BESZEL_VERSION_OR_KEY');
109
+ return info as Record<string, unknown> & { key: string };
110
+ }
@@ -0,0 +1,113 @@
1
+ import { privateHub } from './beszel';
2
+ import type { HubRequest } from './enroll';
3
+ import { record } from '../config';
4
+ import { fleet, secret } from '../fleet-config';
5
+
6
+ /** The normal (non-superuser) Beszel account the manager enrolls nodes with.
7
+ * Its password is the fleet's monitoring-account-password secret. A fresh hub on the
8
+ * manager creates the account itself on first start (hub-darwin.yml).
9
+ */
10
+ export const BESZEL_FLEET_IDENTITY = fleet.services.monitoring.managerAccount;
11
+
12
+ /** Read-only Beszel user the hub signs every visitor in as (AUTO_LOGIN), so the
13
+ * dashboard needs no login. Create it once in the hub (Settings > Users, role
14
+ * "Read-only"); it holds no secret and must never be an admin. If the account
15
+ * does not exist the hub simply shows its normal login page.
16
+ */
17
+ export const BESZEL_PUBLIC_VIEWER_IDENTITY =
18
+ fleet.services.monitoring.viewerAccount;
19
+
20
+ /** Accept the plain password, or a legacy JSON identity/password pair. */
21
+ export function fleetCredentials(secret: string): {
22
+ identity: string;
23
+ password: string;
24
+ } {
25
+ const value = secret.trim();
26
+ if (!value) throw new Error('BESZEL_ACCOUNT_INVALID');
27
+ if (value.startsWith('{')) {
28
+ let parsed: unknown;
29
+ try {
30
+ parsed = JSON.parse(value);
31
+ } catch {
32
+ throw new Error('BESZEL_ACCOUNT_INVALID');
33
+ }
34
+ if (
35
+ !record(parsed) ||
36
+ typeof parsed['identity'] !== 'string' ||
37
+ typeof parsed['password'] !== 'string'
38
+ )
39
+ throw new Error('BESZEL_ACCOUNT_INVALID');
40
+ return { identity: parsed['identity'], password: parsed['password'] };
41
+ }
42
+ return { identity: BESZEL_FLEET_IDENTITY, password: value };
43
+ }
44
+
45
+ /** The fleet login from the manager's Vault session, or null when absent.
46
+ * Used to create the account on a hub's first start; never printed.
47
+ */
48
+ export async function fleetAccount(): Promise<{
49
+ identity: string;
50
+ password: string;
51
+ } | null> {
52
+ const password = await secret('monitoring-account-password');
53
+ try {
54
+ return password ? fleetCredentials(password) : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /** Reads the existing account password only. This never creates credentials. The normal Beszel account must have fleet-node access.
61
+ */
62
+ export async function hubAccess(
63
+ hub: string
64
+ ): Promise<{ userId: string; request: HubRequest }> {
65
+ const url = privateHub(hub);
66
+ const password = await secret('monitoring-account-password');
67
+ if (!password) throw new Error('BESZEL_ACCOUNT_REQUIRED');
68
+ const credentials = fleetCredentials(password);
69
+ const login = await call(
70
+ url,
71
+ '/api/collections/users/auth-with-password',
72
+ '',
73
+ credentials
74
+ );
75
+ if (
76
+ !record(login) ||
77
+ typeof login['token'] !== 'string' ||
78
+ !record(login['record']) ||
79
+ typeof login['record']['id'] !== 'string'
80
+ )
81
+ throw new Error('BESZEL_AUTH_REQUIRED');
82
+ const token = login['token'];
83
+ return {
84
+ userId: login['record']['id'],
85
+ request: (path, body) => call(url, path, token, body),
86
+ };
87
+ }
88
+ async function call(
89
+ url: string,
90
+ path: string,
91
+ token: string,
92
+ body?: object
93
+ ): Promise<unknown> {
94
+ if (!path.startsWith('/api/')) throw new Error('BESZEL_API_PATH');
95
+ const response = await fetch(`${url}${path}`, {
96
+ method: body ? 'POST' : 'GET',
97
+ headers: { Authorization: token, 'Content-Type': 'application/json' },
98
+ ...(body ? { body: JSON.stringify(body) } : {}),
99
+ redirect: 'error',
100
+ signal: AbortSignal.timeout(10_000),
101
+ });
102
+ if (!response.ok) throw new Error(failure(response.status, path));
103
+ return response.json() as Promise<unknown>;
104
+ }
105
+
106
+ /** PocketBase answers a rejected password login with 400, not 401. */
107
+ export function failure(status: number, path: string): string {
108
+ if (status === 400 && path.endsWith('/auth-with-password'))
109
+ return 'BESZEL_ACCOUNT_REQUIRED';
110
+ return status === 401 || status === 403
111
+ ? 'BESZEL_AUTH_REQUIRED'
112
+ : 'BESZEL_API_UNAVAILABLE';
113
+ }
@@ -0,0 +1,142 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { record } from '../config';
3
+ import type { Check } from '../types';
4
+ import { BESZEL_PUBLIC_VIEWER_IDENTITY } 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
+ interface Session {
29
+ fetcher: Fetcher;
30
+ base: string;
31
+ token: string;
32
+ }
33
+
34
+ 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
+ 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="${BESZEL_PUBLIC_VIEWER_IDENTITY}"`);
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: BESZEL_PUBLIC_VIEWER_IDENTITY,
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 ${BESZEL_PUBLIC_VIEWER_IDENTITY} (read-only).`
134
+ );
135
+ } catch {
136
+ return viewerCheck(
137
+ 'warn',
138
+ 'BESZEL_VIEWER_UNVERIFIED',
139
+ `Could not create or verify the read-only ${BESZEL_PUBLIC_VIEWER_IDENTITY} 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 { PUBLIC_HUB_URL } 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 ${PUBLIC_HUB_URL} 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; ${PUBLIC_HUB_URL} 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
+ }