diff --git a/DESIGN.md b/DESIGN.md index 1914eb6..c1de14e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -17,7 +17,7 @@ Per-Discord-user state sync for Minecraft. Pulls on launch, pushes on exit. Sing | Retention policy | `restic forget --keep-last/daily/weekly/monthly` | | Append-only enforcement | `restic-rest-server --append-only`: even with a valid password, clients can't delete | | Per-user isolation | `--private-repos`: URL path must contain the authenticated username | -| Encryption at rest | Per-repo password, built in | +| Encryption at rest | Disabled (`--insecure-no-password`); delegated to LUKS on host disk | | Multi-machine support | Restic tags + hostname; if we ever want it, free | cloud-svc as originally designed was a worse re-implementation of all the above. Pivoting before it ships; cloud-svc gets reshaped into the control plane described below. @@ -72,15 +72,15 @@ htpasswd"/]:::pvc POST /admin/users"| cs_int cs_int -.->|"htpasswd add restic init -key add"| htp +--insecure-no-password"| htp cs_int -.->|"mints repo"| store bot -.->|"DM password"| pl op -.->|"SSH then automc-setup cloud ..."| cs_admin - cs_admin -.->|"list / prune / revoke"| htp + cs_admin -.->|"list / revoke"| htp cs_admin -.->|"prune via -operator master key"| store +filesystem path"| store classDef deploy fill:#d5e8d4,stroke:#82b366,color:#000 classDef pvc fill:#f5f5f5,stroke:#666,color:#000 @@ -105,11 +105,11 @@ Auth model: | Element | Value | |---|---| | User identity | Discord ID (immutable, from discord-bot's existing account-card flow) | -| User credential | restic repo password = bcrypt'd in `/etc/restic-users` htpasswd file | +| User credential | One password per user. HTTP basic auth ONLY — bcrypt'd in `/etc/restic-users` htpasswd file. Restic repos use `--insecure-no-password`, so this password does NOT also encrypt blobs. | | URL pattern | `rest:https://cloud.tm.center//` | | Server isolation | `--private-repos` enforces URL path matches authenticated user | -discord-bot's `/register` flow extends to call `POST cloud-svc:9091/admin/users` with the player's Discord ID. cloud-svc mints a random password, `htpasswd -B`-adds it to the file, runs `restic init` + `restic key add operator-master`, and returns the password. discord-bot DMs it to the player. discord-bot itself never touches restic or htpasswd directly. +discord-bot's `/register` flow extends to call `POST cloud-svc:9091/admin/users` with the player's Discord ID. cloud-svc mints a random password, `htpasswd -B`-adds it to the file, runs `restic init --insecure-no-password`, and returns the password. discord-bot DMs it to the player. discord-bot itself never touches restic or htpasswd directly. Revocation = operator runs `automc-setup cloud revoke ` which hits the loopback admin port. No token store, no scope checks, no auth-service involvement. @@ -184,26 +184,26 @@ Server-side cron (e.g., daily at 04:00 UTC) walks all per-user repos: ```bash for repo in /srv/cloud-data/*/; do user=$(basename "$repo") - restic -r "$repo" --password-file /etc/restic-master-pass \ + restic -r "$repo" --insecure-no-password \ forget --keep-last=20 --keep-daily=7 --keep-weekly=4 --keep-monthly=6 --prune done ``` -This requires the operator to have a "master password" that opens any user's repo — restic doesn't have that natively. **Options:** +Operator-side prune talks to restic **directly on the filesystem** (`-r /srv/cloud-data//`), bypassing the rest-server's `--append-only` enforcement. No HTTP, no password. Works because: -1. **Init each user's repo with TWO keys** — one for the user, one for the operator-side pruner. restic supports multi-key per repo. -2. **Run the cron with each user's own password** — requires storing all user passwords server-side; defeats the encryption. -3. **Don't auto-prune** — let users push forever, trust quota at the rest-server level. +- Repos use `--insecure-no-password` (no encryption key to coordinate) +- The operator owns the on-disk files anyway +- `--append-only` is a rest-server HTTP-layer policy; the filesystem doesn't care -Recommendation: **option 1** (multi-key per repo). On `/register`, the bot calls `restic -r --password-file key add` to add the player's password as a SECOND key. The pruner cron uses the operator master password. +Previous drafts of this doc proposed a multi-key restic setup (one key per user + one operator-master key) to enable HTTP-mode prune. That's no longer needed. ## What's in v1 - restic-rest-server with `--private-repos --append-only --htpasswd-file` -- discord-bot `/register` extension: mint password, htpasswd add, `restic init` repo, `restic key add` player key -- cloud-sync.pyz that subprocesses restic for pull/push +- discord-bot `/register` extension: mint password, htpasswd add, `restic init --insecure-no-password` +- cloud-sync.pyz that subprocesses restic for pull/push (`--insecure-no-password` on every call) - Auto-download restic binary on first run from upstream GitHub release -- Server-side nightly prune cron with operator-side master password key +- Server-side nightly prune via separate systemd timer on john, running `restic forget --prune` directly on filesystem paths (bypassing the rest-server's HTTP-layer append-only enforcement) ## What's deferred @@ -271,7 +271,8 @@ All locked 2026-06-02: - [x] cloud-svc reshapes to control plane, not archived - [x] Two-port split — automc-net for provisioning, loopback for operator -- [x] Server-side prune via operator master password key on each repo. On `provision`, cloud-svc runs `restic init` then `restic key add` with the operator-master password as a SECOND key. The nightly pruner uses the operator key to open any repo. +- [x] **Repo encryption disabled** (`--insecure-no-password`). Per-user password covers HTTP basic auth ONLY. Defense-in-depth via repo encryption was dropped for the homelab scope; protection delegated to LUKS on disk + TLS at proxy + append-only at rest-server. Cuts provisioning from 3 restic ops to 1, removes the two-password coordination problem. +- [x] Server-side prune over filesystem path (`-r /srv/cloud-data//`). Bypasses rest-server's HTTP-layer `--append-only`. No multi-key dance needed. - [x] cloud-sync.pyz auto-downloads restic binary. Matches `packwiz-installer-bootstrap` pattern. First run hits `https://github.com/restic/restic/releases` for the matching platform binary, caches under `/.cloud-sync/restic-`. SHA256 verified against the release's `SHA256SUMS` file. `--no-download` flag for air-gapped operators. -- [x] Nightly prune at 04:00 UTC. `time.Ticker` inside cloud-svc; no external cron. `--prune-time HH:MM` flag in case operators want a different window. +- [x] Nightly prune at 04:00 UTC via **separate systemd timer on john** (not embedded in cloud-svc), for fault isolation — prune crash doesn't take down provisioning. The timer also runs `restic copy` to the homelab primary before pruning (per-user repos, mirroring john's layout). - [x] Per-caller tokens, NOT shared. cloud-svc reads `CLOUD_PROVISIONING_TOKENS_BOT`, `CLOUD_PROVISIONING_TOKENS_` env vars — one per known caller. Logs include the matched caller name so audit trails show which service made each call. Adding a future caller (e.g., a portal) means a new env var, not a token rotation. diff --git a/cloud_sync/sync.py b/cloud_sync/sync.py index f9f0bfe..102c6c6 100644 --- a/cloud_sync/sync.py +++ b/cloud_sync/sync.py @@ -1,8 +1,17 @@ """pull + push entry points. Both subprocess restic against ``rest:://:@//`` -where the same password is the HTTP basic credential and the repo -encryption key. cloud-svc provisions one password covering both. +where the password is HTTP basic auth ONLY. Restic repos are initialised with +``--insecure-no-password`` so no encryption-at-rest password exists; protection +relies on: + + 1. TLS in transit at the reverse proxy + 2. ``--private-repos`` + htpasswd per user at restic-rest-server + 3. ``--append-only`` to prevent client-side deletion + 4. Disk-level encryption (LUKS) on the host + +Defense-in-depth via repo encryption was dropped because the threat model +(homelab, operator-trusted) doesn't justify the password-coordination cost. """ from __future__ import annotations @@ -25,12 +34,12 @@ def pull(args: Args) -> int: discord_id, password = read_credentials(args.token_file) binary = restic.resolve_binary(args) repo = _restic_repo(args.url, discord_id, password) - env = _restic_env(password) + env = _restic_env() # Check whether any snapshots exist code, out = restic.run( binary, - ["-r", repo, "snapshots", "--json", "--latest", "1"], + ["-r", repo, "--insecure-no-password", "snapshots", "--json", "--latest", "1"], env=env, ) if code != 0: @@ -53,7 +62,8 @@ def pull(args: Args) -> int: code, _ = restic.run( binary, [ - "-r", repo, "restore", "latest", + "-r", repo, "--insecure-no-password", + "restore", "latest", "--target", str(args.pack_folder), "--exclude-file", str(exclude_from), ], @@ -71,7 +81,7 @@ def push(args: Args) -> int: discord_id, password = read_credentials(args.token_file) binary = restic.resolve_binary(args) repo = _restic_repo(args.url, discord_id, password) - env = _restic_env(password) + env = _restic_env() scope = scopemod.load(args.pack_folder) files_from, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope) @@ -79,7 +89,8 @@ def push(args: Args) -> int: code, _ = restic.run( binary, [ - "-r", repo, "backup", + "-r", repo, "--insecure-no-password", + "backup", "--files-from", str(files_from), "--exclude-file", str(exclude_from), "--host", "cloud-sync", @@ -122,8 +133,8 @@ def _restic_repo(base_url: str, discord_id: str, password: str) -> str: return f"rest:{scheme}{u}:{p}@{host_and_path}/{discord_id}/" -def _restic_env(password: str) -> dict[str, str]: +def _restic_env() -> dict[str, str]: return { - "RESTIC_PASSWORD": password, + # No RESTIC_PASSWORD — repos use --insecure-no-password. "RESTIC_PROGRESS_FPS": "0", } diff --git a/tests/test_repo_url.py b/tests/test_repo_url.py index e038849..dc41810 100644 --- a/tests/test_repo_url.py +++ b/tests/test_repo_url.py @@ -48,7 +48,9 @@ def test_missing_scheme_rejected(): _restic_repo("cloud.tm.center", "u", "p") -def test_env_contains_password(): - env = _restic_env("hunter2") - assert env["RESTIC_PASSWORD"] == "hunter2" - assert "RESTIC_PROGRESS_FPS" in env +def test_env_does_not_contain_password(): + """Repos use --insecure-no-password; RESTIC_PASSWORD must NOT appear in env + or it would silently switch repos into encrypted mode.""" + env = _restic_env() + assert "RESTIC_PASSWORD" not in env + assert env["RESTIC_PROGRESS_FPS"] == "0"