@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,177 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prepare a complete offline node cache atomically; never stop/start the live stack."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
sys.dont_write_bytecode = True
|
|
7
|
+
|
|
8
|
+
import pathlib as _pathlib
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(_pathlib.Path(__file__).resolve().parent))
|
|
11
|
+
import cliproxy_host
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import ctypes
|
|
15
|
+
import datetime
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import pathlib
|
|
19
|
+
import stat
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import tempfile
|
|
23
|
+
import checkpoint
|
|
24
|
+
import cloud_state
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def publish(candidate, destination):
|
|
28
|
+
"""Atomic directory publication with no replacement, including races."""
|
|
29
|
+
library = ctypes.CDLL(None, use_errno=True)
|
|
30
|
+
if cliproxy_host.system() == "macos":
|
|
31
|
+
# renamex_np(from, to, RENAME_EXCL)
|
|
32
|
+
rename = library.renamex_np
|
|
33
|
+
rename.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint]
|
|
34
|
+
arguments = [os.fsencode(candidate), os.fsencode(destination), 0x4]
|
|
35
|
+
else:
|
|
36
|
+
# renameat2(AT_FDCWD, from, AT_FDCWD, to, RENAME_NOREPLACE)
|
|
37
|
+
rename = library.renameat2
|
|
38
|
+
rename.argtypes = [
|
|
39
|
+
ctypes.c_int,
|
|
40
|
+
ctypes.c_char_p,
|
|
41
|
+
ctypes.c_int,
|
|
42
|
+
ctypes.c_char_p,
|
|
43
|
+
ctypes.c_uint,
|
|
44
|
+
]
|
|
45
|
+
arguments = [-100, os.fsencode(candidate), -100, os.fsencode(destination), 1]
|
|
46
|
+
rename.restype = ctypes.c_int
|
|
47
|
+
if rename(*arguments) != 0:
|
|
48
|
+
raise OSError(ctypes.get_errno(), "Recovery destination could not be published")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify_source(payload):
|
|
52
|
+
subprocess.run(
|
|
53
|
+
[
|
|
54
|
+
sys.executable,
|
|
55
|
+
"-B",
|
|
56
|
+
str(pathlib.Path(__file__).parent / "restore-verify"),
|
|
57
|
+
str(payload),
|
|
58
|
+
],
|
|
59
|
+
check=True,
|
|
60
|
+
capture_output=True,
|
|
61
|
+
timeout=60,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def recover(
|
|
66
|
+
destination,
|
|
67
|
+
token_path,
|
|
68
|
+
restic_factory=cloud_state.client,
|
|
69
|
+
read_settings=cloud_state.settings,
|
|
70
|
+
):
|
|
71
|
+
# Refuse even an empty existing directory: no user data or deployment can be overwritten.
|
|
72
|
+
live_root = cliproxy_host.default_root().resolve()
|
|
73
|
+
canonical_destination = destination.parent.resolve() / destination.name
|
|
74
|
+
if live_root.exists() and canonical_destination.is_relative_to(live_root):
|
|
75
|
+
raise ValueError("Recovery destination must be outside the existing deployment")
|
|
76
|
+
if destination.exists() or destination.is_symlink():
|
|
77
|
+
raise ValueError("Recovery destination already exists")
|
|
78
|
+
if not destination.parent.is_dir():
|
|
79
|
+
raise ValueError("Recovery parent directory must exist")
|
|
80
|
+
metadata = token_path.lstat()
|
|
81
|
+
if not stat.S_ISREG(metadata.st_mode) or metadata.st_mode & 0o077:
|
|
82
|
+
raise ValueError("Vault token must be a private regular file")
|
|
83
|
+
token = token_path.read_text().strip()
|
|
84
|
+
if not token:
|
|
85
|
+
raise ValueError("Existing Vault login required")
|
|
86
|
+
data = read_settings(token)
|
|
87
|
+
restic = restic_factory(data)
|
|
88
|
+
snapshot = cloud_state.latest(restic)
|
|
89
|
+
os.umask(0o077)
|
|
90
|
+
with tempfile.TemporaryDirectory(
|
|
91
|
+
prefix=".cliproxy-recover-", dir=destination.parent
|
|
92
|
+
) as directory:
|
|
93
|
+
staging = pathlib.Path(directory)
|
|
94
|
+
restored = staging / "restored"
|
|
95
|
+
restic("restore", snapshot["id"], "--target", str(restored), "--verify")
|
|
96
|
+
payload = checkpoint.locate(restored)
|
|
97
|
+
# Validate the source manifest against trusted Git objects, never run restored code.
|
|
98
|
+
verify_source(payload)
|
|
99
|
+
repository = json.loads((payload / "state/repository.json").read_text())
|
|
100
|
+
if repository != {
|
|
101
|
+
key: data[key] for key in ("S3_ENDPOINT", "S3_BUCKET", "S3_REGION")
|
|
102
|
+
}:
|
|
103
|
+
raise ValueError("Recovered repository identity differs from Vault")
|
|
104
|
+
import yaml
|
|
105
|
+
|
|
106
|
+
config = yaml.safe_load((payload / "state/runtime/config.yaml").read_text())
|
|
107
|
+
if data["CLIPROXY_FLEET_CLIENT_API_KEY"] not in config["access"]["api-keys"]:
|
|
108
|
+
raise ValueError("Checkpoint client key differs from current Vault key")
|
|
109
|
+
candidate = staging / "candidate"
|
|
110
|
+
state = candidate / "state"
|
|
111
|
+
state.mkdir(parents=True, mode=0o700)
|
|
112
|
+
checkpoint.hydrate(payload, state)
|
|
113
|
+
secrets = state / "secrets"
|
|
114
|
+
secrets.mkdir(mode=0o700)
|
|
115
|
+
values = {
|
|
116
|
+
"vault-token": token,
|
|
117
|
+
"client-key": data["CLIPROXY_FLEET_CLIENT_API_KEY"],
|
|
118
|
+
"management-key": data["CLIPROXY_FLEET_MANAGEMENT_KEY"],
|
|
119
|
+
"restic-password": data["CLIPROXY_FLEET_RESTIC_PASSWORD"],
|
|
120
|
+
"recovery-confirmed": "Recovered independently from Vault.",
|
|
121
|
+
}
|
|
122
|
+
for name, value in values.items():
|
|
123
|
+
file = secrets / name
|
|
124
|
+
file.write_text(value + "\n")
|
|
125
|
+
file.chmod(0o600)
|
|
126
|
+
(state / "status").mkdir(mode=0o700)
|
|
127
|
+
gc_status = payload / "state/status/gc-success.json"
|
|
128
|
+
if gc_status.is_file():
|
|
129
|
+
shutil.copyfile(gc_status, state / "status/gc-success.json")
|
|
130
|
+
source = json.loads((payload / "INSTALLATION.json").read_text())
|
|
131
|
+
(candidate / "RECOVERY.json").write_text(
|
|
132
|
+
json.dumps(
|
|
133
|
+
{
|
|
134
|
+
"snapshot": snapshot["id"],
|
|
135
|
+
"source_revision": source["revision"],
|
|
136
|
+
"recovered_at": datetime.datetime.now(
|
|
137
|
+
datetime.timezone.utc
|
|
138
|
+
).isoformat(),
|
|
139
|
+
"activated": False,
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
+ "\n"
|
|
143
|
+
)
|
|
144
|
+
publish(candidate, destination)
|
|
145
|
+
return snapshot["id"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main():
|
|
149
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
150
|
+
parser.add_argument(
|
|
151
|
+
"destination",
|
|
152
|
+
type=pathlib.Path,
|
|
153
|
+
help="New, nonexistent deployment/cache directory",
|
|
154
|
+
)
|
|
155
|
+
parser.add_argument(
|
|
156
|
+
"--vault-token-file",
|
|
157
|
+
type=pathlib.Path,
|
|
158
|
+
default=cliproxy_host.default_root()
|
|
159
|
+
/ "state/secrets/vault-token",
|
|
160
|
+
)
|
|
161
|
+
args = parser.parse_args()
|
|
162
|
+
# Do not resolve the final component: an existing dangling symlink must be refused.
|
|
163
|
+
destination = args.destination.absolute()
|
|
164
|
+
recover(destination, args.vault_token_file)
|
|
165
|
+
print("Verified cloud recovery prepared at", destination)
|
|
166
|
+
print(
|
|
167
|
+
"No services or provider identities activated. RECOVERY.json records the source revision."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
try:
|
|
173
|
+
main()
|
|
174
|
+
except Exception:
|
|
175
|
+
raise SystemExit(
|
|
176
|
+
"Offline recovery failed; live deployment unchanged. Credentials and upstream responses suppressed."
|
|
177
|
+
) from None
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate restored data with trusted repository code, without activating credentials."""
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import pathlib
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
sys.dont_write_bytecode = True
|
|
12
|
+
root = pathlib.Path(sys.argv[1]).resolve()
|
|
13
|
+
source = pathlib.Path(__file__).resolve().parent
|
|
14
|
+
if not (root / "state/runtime/config.yaml").is_file():
|
|
15
|
+
import checkpoint
|
|
16
|
+
root = checkpoint.locate(root)
|
|
17
|
+
if (root / "CHECKPOINT.json").exists():
|
|
18
|
+
import checkpoint
|
|
19
|
+
checkpoint.validate(root)
|
|
20
|
+
if not (root / "state/runtime/config.yaml").is_file():
|
|
21
|
+
sys.exit("Pass the restored deployment root containing state/runtime/config.yaml.")
|
|
22
|
+
subprocess.run(
|
|
23
|
+
[
|
|
24
|
+
sys.executable,
|
|
25
|
+
"-B",
|
|
26
|
+
str(source / "validate-config"),
|
|
27
|
+
str(root / "state/runtime/config.yaml"),
|
|
28
|
+
],
|
|
29
|
+
check=True,
|
|
30
|
+
)
|
|
31
|
+
count = 0
|
|
32
|
+
for file in (root / "state/runtime/auths").glob("*.json"):
|
|
33
|
+
try:
|
|
34
|
+
json.loads(file.read_text())
|
|
35
|
+
count += 1
|
|
36
|
+
except Exception:
|
|
37
|
+
sys.exit("Restored credential JSON is invalid; contents suppressed.")
|
|
38
|
+
manifest = root / "INSTALLATION.json"
|
|
39
|
+
if manifest.exists():
|
|
40
|
+
metadata = json.loads(manifest.read_text())
|
|
41
|
+
spec = importlib.util.spec_from_file_location("install", source / "install.py")
|
|
42
|
+
installer = importlib.util.module_from_spec(spec)
|
|
43
|
+
spec.loader.exec_module(installer)
|
|
44
|
+
_, files = installer.source_files(source.parents[1], metadata["revision"])
|
|
45
|
+
expected = {
|
|
46
|
+
name: hashlib.sha256(data).hexdigest() for name, (_, data) in files.items()
|
|
47
|
+
}
|
|
48
|
+
if expected != metadata["files"]:
|
|
49
|
+
sys.exit("Restored source manifest differs from its Git commit.")
|
|
50
|
+
locks = json.loads(files["image.lock.json"][1])
|
|
51
|
+
else:
|
|
52
|
+
locks = json.loads((root / "image.lock.json").read_text())
|
|
53
|
+
assert all("@sha256:" in image for image in locks.values())
|
|
54
|
+
assert (root / "state/runtime/static/management.html").is_file()
|
|
55
|
+
print(
|
|
56
|
+
"Restored config, Git source metadata, dashboard and",
|
|
57
|
+
count,
|
|
58
|
+
"credential files validated offline.",
|
|
59
|
+
)
|
|
60
|
+
print("No restored credentials or code were activated.")
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Bound disposable history. Call only while holding state/status/operation.lock."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import pathlib
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
|
14
|
+
import cliproxy_host
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def forget(restic):
|
|
18
|
+
# Do not group by random staging paths or format-version tags: both grow forever.
|
|
19
|
+
restic(
|
|
20
|
+
"forget",
|
|
21
|
+
"--tag",
|
|
22
|
+
"cliproxy-fleet",
|
|
23
|
+
"--host",
|
|
24
|
+
"cliproxy-primary",
|
|
25
|
+
"--group-by",
|
|
26
|
+
"host",
|
|
27
|
+
"--keep-last",
|
|
28
|
+
"96",
|
|
29
|
+
"--keep-daily",
|
|
30
|
+
"7",
|
|
31
|
+
"--keep-weekly",
|
|
32
|
+
"5",
|
|
33
|
+
"--keep-monthly",
|
|
34
|
+
"12",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def prune(restic, now=None):
|
|
39
|
+
now = now or datetime.datetime.now(datetime.timezone.utc)
|
|
40
|
+
result = restic(
|
|
41
|
+
"snapshots",
|
|
42
|
+
"--json",
|
|
43
|
+
"--tag",
|
|
44
|
+
"cliproxy-checkpoint-v1",
|
|
45
|
+
"--host",
|
|
46
|
+
"cliproxy-primary",
|
|
47
|
+
stdout=subprocess.PIPE,
|
|
48
|
+
stderr=subprocess.PIPE,
|
|
49
|
+
text=True,
|
|
50
|
+
)
|
|
51
|
+
snapshots = json.loads(result.stdout)
|
|
52
|
+
times = [
|
|
53
|
+
datetime.datetime.fromisoformat(item["time"].replace("Z", "+00:00"))
|
|
54
|
+
for item in snapshots
|
|
55
|
+
]
|
|
56
|
+
if not times or not 0 <= (now - max(times)).total_seconds() <= 3600:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"A successful cloud checkpoint within one hour is required for GC"
|
|
59
|
+
)
|
|
60
|
+
restic(
|
|
61
|
+
"check"
|
|
62
|
+
) # Never delete history from a repository already failing integrity checks.
|
|
63
|
+
forget(restic)
|
|
64
|
+
# Physical collection applies only inside this dedicated Restic repository.
|
|
65
|
+
# No repack cap: a cap could let garbage arrive faster than it is reclaimed.
|
|
66
|
+
restic("prune", "--max-unused", "5%")
|
|
67
|
+
restic("check")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def staging(root, now=None):
|
|
71
|
+
now = time.time() if now is None else now
|
|
72
|
+
recovery = root / "recovery"
|
|
73
|
+
if not recovery.exists():
|
|
74
|
+
return 0
|
|
75
|
+
if recovery.is_symlink():
|
|
76
|
+
raise ValueError("Recovery directory must not be a link")
|
|
77
|
+
removed = 0
|
|
78
|
+
for path in recovery.iterdir():
|
|
79
|
+
if not re.fullmatch(r"(?:checkpoint|restore)-[a-z0-9_]{8}", path.name):
|
|
80
|
+
continue
|
|
81
|
+
metadata = path.lstat()
|
|
82
|
+
if stat.S_ISDIR(metadata.st_mode) and now - metadata.st_mtime > 86400:
|
|
83
|
+
# Python's fd-based rmtree does not traverse symlinks inside the tree.
|
|
84
|
+
if not shutil.rmtree.avoids_symlink_attacks:
|
|
85
|
+
raise ValueError("Safe directory deletion unavailable")
|
|
86
|
+
shutil.rmtree(path)
|
|
87
|
+
removed += 1
|
|
88
|
+
return removed
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def revisions(root, references=None):
|
|
92
|
+
"""Retain current and previous installed Git revisions; never force removal."""
|
|
93
|
+
current = json.loads((root / "INSTALLATION.json").read_text())
|
|
94
|
+
previous = root / "INSTALLATION.previous.json"
|
|
95
|
+
keep = {current["revision"]}
|
|
96
|
+
if previous.exists():
|
|
97
|
+
keep.add(json.loads(previous.read_text())["revision"])
|
|
98
|
+
parent = root / "revisions"
|
|
99
|
+
if references is None:
|
|
100
|
+
home = cliproxy_host.runtime_home()
|
|
101
|
+
units = cliproxy_host.backend().unit_directory
|
|
102
|
+
references = [
|
|
103
|
+
*(home / ".local/bin").glob("*-fleet"),
|
|
104
|
+
*units.glob("cliproxy-*"),
|
|
105
|
+
*units.glob("dev.devfleet.cliproxy-*"),
|
|
106
|
+
]
|
|
107
|
+
for reference in references:
|
|
108
|
+
if reference.is_symlink():
|
|
109
|
+
try:
|
|
110
|
+
keep.add(reference.resolve().relative_to(parent.resolve()).parts[0])
|
|
111
|
+
except ValueError:
|
|
112
|
+
pass
|
|
113
|
+
active = parent / current["revision"]
|
|
114
|
+
if parent.is_symlink() or active.is_symlink() or not active.is_dir():
|
|
115
|
+
raise ValueError("Invalid installed worktree directory")
|
|
116
|
+
|
|
117
|
+
def git(path, *args):
|
|
118
|
+
return subprocess.check_output(
|
|
119
|
+
[
|
|
120
|
+
"git",
|
|
121
|
+
"--no-optional-locks",
|
|
122
|
+
"-c",
|
|
123
|
+
"safe.directory=" + str(path),
|
|
124
|
+
"-C",
|
|
125
|
+
str(path),
|
|
126
|
+
*args,
|
|
127
|
+
],
|
|
128
|
+
stderr=subprocess.PIPE,
|
|
129
|
+
text=True,
|
|
130
|
+
).strip()
|
|
131
|
+
|
|
132
|
+
removed = 0
|
|
133
|
+
for path in parent.iterdir():
|
|
134
|
+
if path.name in keep or not re.fullmatch(r"[0-9a-f]{40}", path.name):
|
|
135
|
+
continue
|
|
136
|
+
if path.is_symlink() or not path.is_dir():
|
|
137
|
+
continue
|
|
138
|
+
if git(path, "rev-parse", "HEAD") != path.name:
|
|
139
|
+
raise ValueError("Unexpected worktree revision")
|
|
140
|
+
if git(path, "status", "--porcelain", "--untracked-files=all", "--ignored"):
|
|
141
|
+
raise ValueError("Retired worktree has changes; refusing deletion")
|
|
142
|
+
git(active, "worktree", "remove", str(path))
|
|
143
|
+
removed += 1
|
|
144
|
+
return removed
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
sys.dont_write_bytecode = True
|
|
5
|
+
|
|
6
|
+
import pathlib as _pathlib
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(_pathlib.Path(__file__).resolve().parent))
|
|
9
|
+
import cliproxy_host
|
|
10
|
+
|
|
11
|
+
import pathlib, sys, yaml, os
|
|
12
|
+
|
|
13
|
+
arguments = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
14
|
+
require_fill_first = "--require-fill-first" in sys.argv[1:]
|
|
15
|
+
p = (
|
|
16
|
+
pathlib.Path(arguments[0])
|
|
17
|
+
if arguments
|
|
18
|
+
else cliproxy_host.default_root()
|
|
19
|
+
/ "state/runtime/config.yaml"
|
|
20
|
+
)
|
|
21
|
+
try:
|
|
22
|
+
c = yaml.safe_load(p.read_text())
|
|
23
|
+
|
|
24
|
+
def require(condition):
|
|
25
|
+
if not condition:
|
|
26
|
+
raise ValueError("Unsafe configuration")
|
|
27
|
+
|
|
28
|
+
require(c["config-version"] == 8)
|
|
29
|
+
require(c["server"]["port"] == 8317)
|
|
30
|
+
require(c["oauth"]["auth-dir"] == "/state/auths")
|
|
31
|
+
management = c["management"]["secret-key"]
|
|
32
|
+
require(
|
|
33
|
+
isinstance(management, str)
|
|
34
|
+
and len(management) >= 32
|
|
35
|
+
and "REPLACE" not in management
|
|
36
|
+
)
|
|
37
|
+
keys = c["access"]["api-keys"]
|
|
38
|
+
require(
|
|
39
|
+
isinstance(keys, list)
|
|
40
|
+
and bool(keys)
|
|
41
|
+
and all(
|
|
42
|
+
isinstance(k, str) and len(k) >= 32 and "REPLACE" not in k for k in keys
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
require(management not in keys)
|
|
46
|
+
require(c["routing"]["strategy"] in ("round-robin", "weighted-round-robin", "fill-first"))
|
|
47
|
+
# Candidate configurations must keep the fill-first policy; the running config is only
|
|
48
|
+
# reported on drift (see scripts/monitor), so an older deployment still starts.
|
|
49
|
+
require(not require_fill_first or c["routing"]["strategy"] == "fill-first")
|
|
50
|
+
require(c.get("quota-exceeded", {}).get("switch-preview-model") is False)
|
|
51
|
+
require(c["observability"]["logs"]["debug"] is False)
|
|
52
|
+
require(c["observability"]["logs"]["request-log"] is False)
|
|
53
|
+
require(c["plugins"]["enabled"] is False)
|
|
54
|
+
except Exception:
|
|
55
|
+
sys.exit("Configuration failed YAML/security validation; values suppressed.")
|
|
56
|
+
print("YAML and deployment safety invariants passed (runtime staging still required).")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Vault API helpers. Never print response bodies, tokens, or credentials."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
sys.dont_write_bytecode = True
|
|
6
|
+
|
|
7
|
+
import pathlib as _pathlib
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(_pathlib.Path(__file__).resolve().parent))
|
|
10
|
+
import cliproxy_host
|
|
11
|
+
import fleet_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
import json, pathlib, urllib.request, os
|
|
15
|
+
|
|
16
|
+
ROOT = cliproxy_host.default_root().resolve()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def request(path, token=None, data=None):
|
|
20
|
+
headers = {"Content-Type": "application/json"}
|
|
21
|
+
if token:
|
|
22
|
+
headers["X-Vault-Token"] = token
|
|
23
|
+
req = urllib.request.Request(
|
|
24
|
+
fleet_config.vault()["address"] + "/v1/" + path,
|
|
25
|
+
headers=headers,
|
|
26
|
+
data=json.dumps(data).encode() if data is not None else None,
|
|
27
|
+
)
|
|
28
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
29
|
+
return json.load(r)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def token():
|
|
33
|
+
return (ROOT / "state/secrets/vault-token").read_text().strip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def credentials():
|
|
37
|
+
data = request(fleet_config.vault()["shared"], token())["data"]["data"]
|
|
38
|
+
fields = [
|
|
39
|
+
"S3_ENDPOINT",
|
|
40
|
+
"S3_REGION",
|
|
41
|
+
"S3_BUCKET",
|
|
42
|
+
"S3_ACCESS_KEY_ID",
|
|
43
|
+
"S3_SECRET_ACCESS_KEY",
|
|
44
|
+
]
|
|
45
|
+
if any(not data.get(k) for k in fields):
|
|
46
|
+
raise ValueError("Missing S3 field")
|
|
47
|
+
return {k: data[k] for k in fields}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def renew():
|
|
51
|
+
t = token()
|
|
52
|
+
meta = request("auth/token/lookup-self", t)["data"]
|
|
53
|
+
if meta.get("ttl", 0) < 3600:
|
|
54
|
+
if not meta.get("renewable"):
|
|
55
|
+
raise ValueError("Token expiry requires login")
|
|
56
|
+
request("auth/token/renew-self", t, {})
|
|
57
|
+
return meta.get("ttl", 0)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
sys.dont_write_bytecode = True
|
|
5
|
+
|
|
6
|
+
import pathlib as _pathlib
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(_pathlib.Path(__file__).resolve().parent))
|
|
9
|
+
import cliproxy_host
|
|
10
|
+
import fleet_config
|
|
11
|
+
|
|
12
|
+
import getpass, importlib.util, os, pathlib, sys
|
|
13
|
+
|
|
14
|
+
root = cliproxy_host.default_root().resolve()
|
|
15
|
+
spec = importlib.util.spec_from_file_location(
|
|
16
|
+
"vault_access", root / "scripts/vault-access.py"
|
|
17
|
+
)
|
|
18
|
+
v = importlib.util.module_from_spec(spec)
|
|
19
|
+
spec.loader.exec_module(v)
|
|
20
|
+
os.umask(0o077)
|
|
21
|
+
for directory in [root / "state", root / "state/secrets", root / "state/status"]:
|
|
22
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
23
|
+
if os.geteuid() == 0:
|
|
24
|
+
os.chown(directory, 1000, 1000)
|
|
25
|
+
try:
|
|
26
|
+
password = getpass.getpass("Vault password for " + fleet_config.vault()["user"] + " (hidden): ")
|
|
27
|
+
token = v.request("auth/userpass/login/" + fleet_config.vault()["user"], data={"password": password})[
|
|
28
|
+
"auth"
|
|
29
|
+
]["client_token"]
|
|
30
|
+
password = None
|
|
31
|
+
data = v.request(fleet_config.vault()["shared"], token)["data"]["data"]
|
|
32
|
+
fields = [
|
|
33
|
+
"S3_ENDPOINT",
|
|
34
|
+
"S3_REGION",
|
|
35
|
+
"S3_BUCKET",
|
|
36
|
+
"S3_ACCESS_KEY_ID",
|
|
37
|
+
"S3_SECRET_ACCESS_KEY",
|
|
38
|
+
]
|
|
39
|
+
if any(not data.get(k) for k in fields):
|
|
40
|
+
raise ValueError("Missing required S3 contract fields")
|
|
41
|
+
f = root / "state/secrets/vault-token"
|
|
42
|
+
new = f.with_suffix(".new")
|
|
43
|
+
new.write_text(token + "\n")
|
|
44
|
+
new.chmod(0o600)
|
|
45
|
+
os.replace(new, f)
|
|
46
|
+
if os.geteuid() == 0:
|
|
47
|
+
os.chown(f, 1000, 1000)
|
|
48
|
+
(root / "state/secrets/s3.json").unlink(missing_ok=True)
|
|
49
|
+
print(
|
|
50
|
+
"Saved Vault token securely. Each backup will fetch live S3 credentials; cached S3 credentials removed."
|
|
51
|
+
)
|
|
52
|
+
except Exception:
|
|
53
|
+
sys.exit(
|
|
54
|
+
"Vault login/read failed. Check credentials and access to the shared document; response suppressed."
|
|
55
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read existing recovery secrets; all Vault writes belong to the OpenTofu root."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
sys.dont_write_bytecode = True
|
|
7
|
+
|
|
8
|
+
import pathlib as _pathlib
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(_pathlib.Path(__file__).resolve().parent))
|
|
11
|
+
import cliproxy_host
|
|
12
|
+
import fleet_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
import hmac
|
|
16
|
+
import importlib.util
|
|
17
|
+
import os
|
|
18
|
+
import pathlib
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
root = cliproxy_host.default_root().resolve()
|
|
22
|
+
spec = importlib.util.spec_from_file_location(
|
|
23
|
+
"vault_access", root / "scripts/vault-access.py"
|
|
24
|
+
)
|
|
25
|
+
vault = importlib.util.module_from_spec(spec)
|
|
26
|
+
spec.loader.exec_module(vault)
|
|
27
|
+
os.umask(0o077)
|
|
28
|
+
try:
|
|
29
|
+
if sys.argv[1:] not in [["recover"], ["verify"]]:
|
|
30
|
+
sys.exit(
|
|
31
|
+
"Usage: vault-recovery recover|verify. Manage secret values through bun run vault:edit."
|
|
32
|
+
)
|
|
33
|
+
secret = vault.request(fleet_config.vault()["shared"], vault.token())["data"]["data"][
|
|
34
|
+
"CLIPROXY_FLEET_RESTIC_PASSWORD"
|
|
35
|
+
]
|
|
36
|
+
local = root / "state/secrets/restic-password"
|
|
37
|
+
if sys.argv[1] == "recover":
|
|
38
|
+
if local.exists() and not hmac.compare_digest(
|
|
39
|
+
local.read_text().strip(), secret
|
|
40
|
+
):
|
|
41
|
+
sys.exit("Local recovery password differs; refusing to overwrite it.")
|
|
42
|
+
local.write_text(secret + "\n")
|
|
43
|
+
local.chmod(0o600)
|
|
44
|
+
if os.geteuid() == 0:
|
|
45
|
+
os.chown(local, 1000, 1000)
|
|
46
|
+
if not hmac.compare_digest(local.read_text().strip(), secret):
|
|
47
|
+
sys.exit("Recovery password mismatch; values suppressed.")
|
|
48
|
+
marker = root / "state/secrets/recovery-confirmed"
|
|
49
|
+
marker.write_text("Verified existing independent Vault recovery copy.\n")
|
|
50
|
+
if os.geteuid() == 0:
|
|
51
|
+
os.chown(marker, 1000, 1000)
|
|
52
|
+
print("Independent Vault recovery copy verified; no secrets printed.")
|
|
53
|
+
except Exception:
|
|
54
|
+
sys.exit(
|
|
55
|
+
"Vault recovery failed. Check login, registered key and read access; response suppressed."
|
|
56
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
sys.dont_write_bytecode = True
|
|
5
|
+
|
|
6
|
+
import pathlib as _pathlib
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(_pathlib.Path(__file__).resolve().parent))
|
|
9
|
+
import cliproxy_host
|
|
10
|
+
|
|
11
|
+
import importlib.util, pathlib, sys, os
|
|
12
|
+
|
|
13
|
+
root = cliproxy_host.default_root().resolve()
|
|
14
|
+
spec = importlib.util.spec_from_file_location(
|
|
15
|
+
"vault_access", root / "scripts/vault-access.py"
|
|
16
|
+
)
|
|
17
|
+
v = importlib.util.module_from_spec(spec)
|
|
18
|
+
spec.loader.exec_module(v)
|
|
19
|
+
try:
|
|
20
|
+
v.renew()
|
|
21
|
+
(root / "state/status/vault-failed").unlink(missing_ok=True)
|
|
22
|
+
print("Vault token valid; renewal checked.")
|
|
23
|
+
except Exception:
|
|
24
|
+
f = root / "state/status/vault-failed"
|
|
25
|
+
f.write_text("Vault login/renewal required.\n")
|
|
26
|
+
if os.geteuid() == 0:
|
|
27
|
+
os.chown(f, 1000, 1000)
|
|
28
|
+
sys.exit(
|
|
29
|
+
"Vault token unavailable, expired, or unable to renew. Run vault-backup-login."
|
|
30
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Verify CLIProxyAPI encrypted backup integrity
|
|
3
|
+
After=network-online.target
|
|
4
|
+
[Service]
|
|
5
|
+
Environment=PYTHONDONTWRITEBYTECODE=1
|
|
6
|
+
Type=oneshot
|
|
7
|
+
ExecStart=/opt/cliproxy-fleet/scripts/backup check
|
|
8
|
+
UMask=0077
|
|
9
|
+
Nice=10
|
|
10
|
+
TimeoutStartSec=4h
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Consistent encrypted CLIProxyAPI backup to independent R2 storage
|
|
3
|
+
Requires=docker.service
|
|
4
|
+
After=docker.service network-online.target
|
|
5
|
+
[Service]
|
|
6
|
+
Environment=PYTHONDONTWRITEBYTECODE=1
|
|
7
|
+
Type=oneshot
|
|
8
|
+
WorkingDirectory=/opt/cliproxy-fleet
|
|
9
|
+
ExecStart=/opt/cliproxy-fleet/scripts/backup backup
|
|
10
|
+
UMask=0077
|
|
11
|
+
Nice=10
|
|
12
|
+
IOSchedulingClass=idle
|
|
13
|
+
TimeoutStartSec=1h
|