@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.
- package/.mise/locks/aws-cdk/2.1146.1/aube-lock.yaml +24 -0
- package/.mise/locks/aws-cdk/2.1146.1/package.json +7 -0
- package/.mise/locks/azure-cli/2.91.0~a837e014/pyproject.toml +5 -0
- package/.mise/locks/azure-cli/2.91.0~a837e014/uv.lock +2794 -0
- package/.mise/locks/gemini/0.66.0~84aac8e6/aube-lock.yaml +123 -0
- package/.mise/locks/gemini/0.66.0~84aac8e6/package.json +12 -0
- package/.mise/locks/npm-devcontainers-cli/0.90.0/aube-lock.yaml +24 -0
- package/.mise/locks/npm-devcontainers-cli/0.90.0/package.json +7 -0
- package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/aube-lock.yaml +1432 -0
- package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/package.json +7 -0
- package/.mise/locks/npm-vercel/65.8.0/aube-lock.yaml +3595 -0
- package/.mise/locks/npm-vercel/65.8.0/package.json +7 -0
- package/.mise/locks/npm-wrangler/4.156.0/aube-lock.yaml +895 -0
- package/.mise/locks/npm-wrangler/4.156.0/package.json +7 -0
- package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/aube-lock.yaml +24 -0
- package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/package.json +7 -0
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/agents/antigravity-proxy.ts +3 -0
- package/agents/audit.md +89 -0
- package/agents/claude-proxy +5 -0
- package/agents/claude-proxy.ts +3 -0
- package/agents/codex-proxy.ts +3 -0
- package/agents/grok-proxy.ts +3 -0
- package/agents/managed-proxy.ts +55 -0
- package/agents/opencode-proxy.ts +3 -0
- package/agents/registry.json +209 -0
- package/agents/registry.schema.json +149 -0
- package/agents/setup.md +128 -0
- package/agents/t3.json +40 -0
- package/ansible/macos/manager.yml +30 -0
- package/ansible/macos/policy.json +17 -0
- package/ansible/monitoring/README.md +154 -0
- package/ansible/monitoring/agent-darwin.yml +198 -0
- package/ansible/monitoring/agent.yml +134 -0
- package/ansible/monitoring/beszel-agent.service +42 -0
- package/ansible/monitoring/compose.yml +74 -0
- package/ansible/monitoring/hub-darwin.yml +102 -0
- package/ansible/monitoring/lock.json +11 -0
- package/ansible/net/remote-probe.sh +13 -0
- package/ansible/ubuntu/authorize-sudo.sh +51 -0
- package/ansible/ubuntu/baseline.yml +89 -0
- package/ansible/ubuntu/disk-guard.py +104 -0
- package/ansible/ubuntu/policy.json +43 -0
- package/ansible/ubuntu/recovery.yml +15 -0
- package/ansible/ubuntu/t3/policy.json +7 -0
- package/ansible/ubuntu/t3-desktop.yml +220 -0
- package/ansible/ubuntu/t3-health.sh +33 -0
- package/ansible/ubuntu/t3-policy.yml +76 -0
- package/ansible/ubuntu/t3-route.yml +28 -0
- package/ansible/ubuntu/tailscale.yml +75 -0
- package/ansible/ubuntu/tasks/credentials.yml +47 -0
- package/ansible/ubuntu/tasks/docker.yml +31 -0
- package/ansible/ubuntu/tasks/packages.yml +56 -0
- package/ansible/ubuntu/tasks/power.yml +51 -0
- package/ansible/ubuntu/tasks/recovery.yml +39 -0
- package/ansible/ubuntu/tasks/resources.yml +252 -0
- package/ansible/ubuntu/tasks/services.yml +31 -0
- package/ansible/ubuntu/tasks/ssh.yml +71 -0
- package/ansible/ubuntu/tasks/user.yml +120 -0
- package/bin/devfleet.mjs +36 -0
- package/bootstrap/bootstrap.ps1 +50 -0
- package/bootstrap/bootstrap.sh +73 -0
- package/bootstrap/devfleet +7 -0
- package/bootstrap/docker-desktop.sh +33 -0
- package/bootstrap/doctor.ts +189 -0
- package/bootstrap/fleet-manager-run.sh +76 -0
- package/bootstrap/fleet-remote-bootstrap.sh +185 -0
- package/bootstrap/global-config.ts +113 -0
- package/bootstrap/install-launcher.sh +7 -0
- package/bootstrap/native-aws.ts +86 -0
- package/bootstrap/native-t3.ts +154 -0
- package/bootstrap/npm.ts +350 -0
- package/bootstrap/setup-status.ts +51 -0
- package/bootstrap/setup.ts +88 -0
- package/bootstrap/shell-path.ts +105 -0
- package/bootstrap/verify-t3-archive.py +49 -0
- package/bootstrap/worker-auth.ts +65 -0
- package/bootstrap/worker-github.ts +47 -0
- package/bootstrap/worker-tools.ts +24 -0
- package/cliproxy/AGENTS.md +11 -0
- package/cliproxy/README.md +269 -0
- package/cliproxy/clients/bootstrap.py +143 -0
- package/cliproxy/clients/launcher.py +57 -0
- package/cliproxy/compose.yaml +90 -0
- package/cliproxy/config.template.yaml +70 -0
- package/cliproxy/docs/cloud-adoption.md +16 -0
- package/cliproxy/docs/deployment.png +0 -0
- package/cliproxy/docs/deployment.svg +109 -0
- package/cliproxy/docs/health-workflow.example.yml +185 -0
- package/cliproxy/docs/migration.md +131 -0
- package/cliproxy/docs/model-health-report.schema.json +364 -0
- package/cliproxy/docs/model-health.md +393 -0
- package/cliproxy/image.lock.json +5 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
- package/cliproxy/models.json +1618 -0
- package/cliproxy/requirements.lock +78 -0
- package/cliproxy/scripts/accounts.py +38 -0
- package/cliproxy/scripts/audit.py +123 -0
- package/cliproxy/scripts/backup +234 -0
- package/cliproxy/scripts/check.py +34 -0
- package/cliproxy/scripts/checkpoint.py +226 -0
- package/cliproxy/scripts/cli.py +64 -0
- package/cliproxy/scripts/cliproxy_host.py +257 -0
- package/cliproxy/scripts/cloud_state.py +150 -0
- package/cliproxy/scripts/container-health +6 -0
- package/cliproxy/scripts/docker +24 -0
- package/cliproxy/scripts/external-health.py +79 -0
- package/cliproxy/scripts/fleet_config.py +43 -0
- package/cliproxy/scripts/fleet_sync.py +274 -0
- package/cliproxy/scripts/initialize +84 -0
- package/cliproxy/scripts/install-dependencies +48 -0
- package/cliproxy/scripts/install.py +406 -0
- package/cliproxy/scripts/model_health.py +16 -0
- package/cliproxy/scripts/modelhealth/__init__.py +5 -0
- package/cliproxy/scripts/modelhealth/checks.py +429 -0
- package/cliproxy/scripts/modelhealth/cli.py +117 -0
- package/cliproxy/scripts/modelhealth/config.py +186 -0
- package/cliproxy/scripts/modelhealth/emulator.py +536 -0
- package/cliproxy/scripts/modelhealth/expect.py +88 -0
- package/cliproxy/scripts/modelhealth/findings.py +183 -0
- package/cliproxy/scripts/modelhealth/protocol.py +330 -0
- package/cliproxy/scripts/modelhealth/redact.py +28 -0
- package/cliproxy/scripts/modelhealth/report.py +150 -0
- package/cliproxy/scripts/modelhealth/runner.py +390 -0
- package/cliproxy/scripts/modelhealth/scenario.py +131 -0
- package/cliproxy/scripts/modelhealth/schema.py +63 -0
- package/cliproxy/scripts/modelhealth/simulate.py +111 -0
- package/cliproxy/scripts/modelhealth/transport.py +213 -0
- package/cliproxy/scripts/monitor +68 -0
- package/cliproxy/scripts/ops +527 -0
- package/cliproxy/scripts/public-endpoint +127 -0
- package/cliproxy/scripts/recover_node.py +177 -0
- package/cliproxy/scripts/restore-verify +60 -0
- package/cliproxy/scripts/retention.py +144 -0
- package/cliproxy/scripts/service-start +7 -0
- package/cliproxy/scripts/validate-config +56 -0
- package/cliproxy/scripts/vault-access.py +57 -0
- package/cliproxy/scripts/vault-backup-login +55 -0
- package/cliproxy/scripts/vault-recovery +56 -0
- package/cliproxy/scripts/vault-renew +30 -0
- package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
- package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
- package/cliproxy/systemd/cliproxy-backup.service +13 -0
- package/cliproxy/systemd/cliproxy-backup.timer +8 -0
- package/cliproxy/systemd/cliproxy-fleet.service +17 -0
- package/cliproxy/systemd/cliproxy-gc.service +13 -0
- package/cliproxy/systemd/cliproxy-gc.timer +8 -0
- package/cliproxy/systemd/cliproxy-monitor.service +8 -0
- package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
- package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
- package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
- package/cliproxy/vendor/LICENSE +22 -0
- package/cliproxy/vendor/config.upstream.yaml +1349 -0
- package/examples/fleet.json +95 -0
- package/examples/roster.json +13 -0
- package/mise.lock +1492 -0
- package/mise.toml +99 -0
- package/package.json +65 -4
- package/schema/fleet.schema.json +364 -0
- package/schema/roster.schema.json +77 -0
- package/src/agents/account-relay.ts +111 -0
- package/src/agents/accounts.ts +85 -0
- package/src/agents/audit.ts +77 -0
- package/src/agents/cli.ts +13 -0
- package/src/agents/command.ts +204 -0
- package/src/agents/converge.ts +123 -0
- package/src/agents/doctor.ts +107 -0
- package/src/agents/errors.ts +2 -0
- package/src/agents/files.ts +73 -0
- package/src/agents/fleet-verify.ts +156 -0
- package/src/agents/launch.ts +290 -0
- package/src/agents/native-verify.ts +226 -0
- package/src/agents/provider-errors.ts +50 -0
- package/src/agents/registry.ts +55 -0
- package/src/agents/render.ts +110 -0
- package/src/agents/round-trip.ts +192 -0
- package/src/agents/secrets.ts +13 -0
- package/src/agents/toolchain.ts +34 -0
- package/src/agents/verify-cache.ts +103 -0
- package/src/agents/verify.ts +127 -0
- package/src/auth-checks.ts +163 -0
- package/src/cli/agent-guide.ts +95 -0
- package/src/cli/app.ts +115 -0
- package/src/cli/cli.ts +21 -0
- package/src/cli/command-index.ts +50 -0
- package/src/cli/contracts.ts +25 -0
- package/src/cli/fleet-reference.ts +17 -0
- package/src/cli/fleet-sync-checkout.ts +91 -0
- package/src/cli/fleet-sync-enrollment.ts +144 -0
- package/src/cli/fleet-sync-forward.ts +134 -0
- package/src/cli/fleet-sync-manager.ts +193 -0
- package/src/cli/fleet-sync-monitoring.ts +185 -0
- package/src/cli/fleet-sync-output.ts +314 -0
- package/src/cli/fleet-sync-privilege.ts +27 -0
- package/src/cli/fleet-sync-progress.ts +109 -0
- package/src/cli/fleet-sync-remote.ts +236 -0
- package/src/cli/fleet-sync-report.ts +26 -0
- package/src/cli/fleet-sync-schedule.ts +138 -0
- package/src/cli/fleet-sync-vault.ts +166 -0
- package/src/cli/fleet-sync.ts +309 -0
- package/src/cli/fleet.ts +163 -0
- package/src/cli/managed-health.ts +219 -0
- package/src/cli/node-deployment.ts +34 -0
- package/src/cli/node-sync.ts +114 -0
- package/src/cli/node-t3.ts +31 -0
- package/src/cli/options.ts +141 -0
- package/src/cli/probe-source.ts +7 -0
- package/src/cli/program.ts +169 -0
- package/src/cli/register-fleet.ts +246 -0
- package/src/cli/registry.ts +158 -0
- package/src/cli/repair-apply.ts +67 -0
- package/src/cli/repair-guard.ts +11 -0
- package/src/cli/repair-inspect.ts +150 -0
- package/src/cli/repair-script.ts +76 -0
- package/src/cli/repair.ts +209 -0
- package/src/cli/roster.ts +89 -0
- package/src/cli/search.ts +35 -0
- package/src/cli/settings.ts +64 -0
- package/src/cli/source-operations.ts +78 -0
- package/src/cli/t3/command.ts +90 -0
- package/src/cli/t3/register.ts +55 -0
- package/src/cli/tailnet.ts +127 -0
- package/src/cli/ui-actions.ts +133 -0
- package/src/cli/ui-controller.ts +113 -0
- package/src/cli/ui-data.ts +106 -0
- package/src/cli/ui-execute.ts +163 -0
- package/src/cli/ui-repairs.ts +34 -0
- package/src/cli/ui-reports.ts +211 -0
- package/src/cli/ui-targets.ts +100 -0
- package/src/cli/ui-terminal.ts +109 -0
- package/src/cli/ui-types.ts +49 -0
- package/src/cli/ui-wizard.ts +101 -0
- package/src/cli/ui.ts +9 -0
- package/src/cli/worker-health-output.ts +37 -0
- package/src/config.ts +186 -0
- package/src/configure.ts +255 -0
- package/src/definition.ts +260 -0
- package/src/diagnostics.ts +92 -0
- package/src/doctor-check.ts +10 -0
- package/src/errors.ts +1 -0
- package/src/fingerprint.ts +120 -0
- package/src/fleet-config.ts +88 -0
- package/src/index.ts +6 -0
- package/src/input-hashes.ts +73 -0
- package/src/machine/local-source.ts +52 -0
- package/src/machine/lock-error.ts +2 -0
- package/src/machine/platform.ts +44 -0
- package/src/machine/source.ts +68 -0
- package/src/machine/state.ts +185 -0
- package/src/machine-health.ts +95 -0
- package/src/managed-source.ts +47 -0
- package/src/manager-failure.ts +48 -0
- package/src/manager-sync.ts +400 -0
- package/src/manager.ts +62 -0
- package/src/monitoring/apply.ts +171 -0
- package/src/monitoring/beszel.ts +191 -0
- package/src/monitoring/enroll.ts +110 -0
- package/src/monitoring/hub-access.ts +113 -0
- package/src/monitoring/hub-viewer.ts +142 -0
- package/src/monitoring/hub.ts +93 -0
- package/src/monitoring/manager-agent.ts +73 -0
- package/src/monitoring/manager-hub.ts +232 -0
- package/src/monitoring/remote.ts +65 -0
- package/src/network-definition.ts +83 -0
- package/src/node-converge-linux.ts +372 -0
- package/src/node-converge-shared.ts +83 -0
- package/src/node-converge.ts +40 -0
- package/src/onboard.ts +35 -0
- package/src/platform/ansible-receipt.ts +128 -0
- package/src/platform/ansible.ts +62 -0
- package/src/platform/darwin.ts +203 -0
- package/src/platform/detect.ts +11 -0
- package/src/platform/fallback.ts +110 -0
- package/src/platform/index.ts +38 -0
- package/src/platform/launchd.ts +98 -0
- package/src/platform/linux.ts +139 -0
- package/src/platform/ps.ts +45 -0
- package/src/platform/resource-verify.ts +128 -0
- package/src/platform/systemd-user.ts +95 -0
- package/src/platform/t3-policy.ts +137 -0
- package/src/platform/t3-route.ts +68 -0
- package/src/platform/t3-service.ts +75 -0
- package/src/platform/types.ts +123 -0
- package/src/platform/ubuntu-audit.ts +63 -0
- package/src/platform/ubuntu-verify.ts +105 -0
- package/src/platform/windows.ts +148 -0
- package/src/power-recovery.ts +55 -0
- package/src/probe.ts +51 -0
- package/src/protocol.ts +119 -0
- package/src/proxy-converge.ts +85 -0
- package/src/remote-bootstrap.ts +212 -0
- package/src/remote-entry.ts +89 -0
- package/src/remote-lock.ts +12 -0
- package/src/repair-catalog.ts +206 -0
- package/src/repair-plan.ts +78 -0
- package/src/repair-runner.ts +84 -0
- package/src/report.ts +71 -0
- package/src/runner.ts +104 -0
- package/src/secrets.ts +169 -0
- package/src/service-auth.ts +115 -0
- package/src/ssh.ts +135 -0
- package/src/sync.ts +104 -0
- package/src/t3/accounts.ts +81 -0
- package/src/t3/desired.ts +165 -0
- package/src/t3/files.ts +81 -0
- package/src/t3/names-check.ts +53 -0
- package/src/t3/names.ts +155 -0
- package/src/t3/plan.ts +82 -0
- package/src/t3/providers.ts +74 -0
- package/src/t3/reconcile.ts +123 -0
- package/src/t3-endpoint.ts +48 -0
- package/src/t3-linked-check.ts +101 -0
- package/src/tailnet-health.ts +231 -0
- package/src/tailnet-matching.ts +37 -0
- package/src/tailscale.ts +141 -0
- package/src/test-fixtures.ts +36 -0
- package/src/toolchain-checks.ts +83 -0
- package/src/topology.ts +24 -0
- package/src/transport.ts +122 -0
- package/src/types.ts +95 -0
- package/src/vault-config.ts +38 -0
- package/src/worker-access-checks.ts +115 -0
- package/src/worker-capacity.ts +34 -0
- package/src/worker-host-checks.ts +176 -0
- package/src/worker-policy.ts +64 -0
- package/src/worker-readiness.ts +64 -0
- 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
|
+
}
|