drop restic repo encryption; rely on TLS + append-only + LUKS
CI / test (3.10) (push) Successful in 8s
CI / test (3.11) (push) Successful in 8s
CI / test (3.12) (push) Successful in 7s
CI / build-pyz (push) Successful in 4s
CI / release (push) Has been skipped

User credentials now serve HTTP basic auth only. Repos init with
--insecure-no-password. Removes:
  - RESTIC_PASSWORD env in client subprocess
  - Per-repo password coordination story
  - Multi-key restic setup (user key + operator-master key)
  - Two-password recovery edge cases

Operator-side prune now runs over the filesystem path (-r /srv/.../<user>/)
which bypasses rest-server's HTTP-layer append-only enforcement. No
password needed at all.

Protection model stays:
  - TLS in transit (reverse proxy)
  - HTTP basic per-user (htpasswd) for read/write authorization
  - --private-repos for per-user URL isolation
  - --append-only for client-side delete protection
  - LUKS / disk-level for at-rest encryption (operator's responsibility)

Verified end-to-end on john: pull → push → restore round-trip works,
DELETE on bogus snapshot still returns 403 (append-only intact),
operator can read repo via filesystem path (prune-mode access works).

33 pytest still green.
This commit is contained in:
2026-06-04 22:23:40 +02:00
parent ffdfb1f9b6
commit 49d1cb3280
3 changed files with 44 additions and 30 deletions
+18 -17
View File
@@ -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/<discord_id>/` |
| 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 <discord_id>` 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/<user>/`), 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 <repo> --password-file <operator> 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/<user>/`). 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 `<pack-folder>/.cloud-sync/restic-<version>`. 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_<OTHER>` 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.
+20 -9
View File
@@ -1,8 +1,17 @@
"""pull + push entry points.
Both subprocess restic against ``rest:<scheme>://<id>:<password>@<host>/<id>/``
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",
}
+6 -4
View File
@@ -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"