drop restic repo encryption; rely on TLS + append-only + LUKS
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:
@@ -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` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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.
|
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
|
POST /admin/users"| cs_int
|
||||||
cs_int -.->|"htpasswd add
|
cs_int -.->|"htpasswd add
|
||||||
restic init
|
restic init
|
||||||
key add"| htp
|
--insecure-no-password"| htp
|
||||||
cs_int -.->|"mints repo"| store
|
cs_int -.->|"mints repo"| store
|
||||||
bot -.->|"DM password"| pl
|
bot -.->|"DM password"| pl
|
||||||
|
|
||||||
op -.->|"SSH then
|
op -.->|"SSH then
|
||||||
automc-setup cloud ..."| cs_admin
|
automc-setup cloud ..."| cs_admin
|
||||||
cs_admin -.->|"list / prune / revoke"| htp
|
cs_admin -.->|"list / revoke"| htp
|
||||||
cs_admin -.->|"prune via
|
cs_admin -.->|"prune via
|
||||||
operator master key"| store
|
filesystem path"| store
|
||||||
|
|
||||||
classDef deploy fill:#d5e8d4,stroke:#82b366,color:#000
|
classDef deploy fill:#d5e8d4,stroke:#82b366,color:#000
|
||||||
classDef pvc fill:#f5f5f5,stroke:#666,color:#000
|
classDef pvc fill:#f5f5f5,stroke:#666,color:#000
|
||||||
@@ -105,11 +105,11 @@ Auth model:
|
|||||||
| Element | Value |
|
| Element | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| User identity | Discord ID (immutable, from discord-bot's existing account-card flow) |
|
| 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>/` |
|
| URL pattern | `rest:https://cloud.tm.center/<discord_id>/` |
|
||||||
| Server isolation | `--private-repos` enforces URL path matches authenticated user |
|
| 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.
|
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
|
```bash
|
||||||
for repo in /srv/cloud-data/*/; do
|
for repo in /srv/cloud-data/*/; do
|
||||||
user=$(basename "$repo")
|
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
|
forget --keep-last=20 --keep-daily=7 --keep-weekly=4 --keep-monthly=6 --prune
|
||||||
done
|
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.
|
- Repos use `--insecure-no-password` (no encryption key to coordinate)
|
||||||
2. **Run the cron with each user's own password** — requires storing all user passwords server-side; defeats the encryption.
|
- The operator owns the on-disk files anyway
|
||||||
3. **Don't auto-prune** — let users push forever, trust quota at the rest-server level.
|
- `--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
|
## What's in v1
|
||||||
|
|
||||||
- restic-rest-server with `--private-repos --append-only --htpasswd-file`
|
- 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
|
- discord-bot `/register` extension: mint password, htpasswd add, `restic init --insecure-no-password`
|
||||||
- cloud-sync.pyz that subprocesses restic for pull/push
|
- 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
|
- 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
|
## What's deferred
|
||||||
|
|
||||||
@@ -271,7 +271,8 @@ All locked 2026-06-02:
|
|||||||
|
|
||||||
- [x] cloud-svc reshapes to control plane, not archived
|
- [x] cloud-svc reshapes to control plane, not archived
|
||||||
- [x] Two-port split — automc-net for provisioning, loopback for operator
|
- [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] 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.
|
- [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
@@ -1,8 +1,17 @@
|
|||||||
"""pull + push entry points.
|
"""pull + push entry points.
|
||||||
|
|
||||||
Both subprocess restic against ``rest:<scheme>://<id>:<password>@<host>/<id>/``
|
Both subprocess restic against ``rest:<scheme>://<id>:<password>@<host>/<id>/``
|
||||||
where the same password is the HTTP basic credential and the repo
|
where the password is HTTP basic auth ONLY. Restic repos are initialised with
|
||||||
encryption key. cloud-svc provisions one password covering both.
|
``--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
|
from __future__ import annotations
|
||||||
@@ -25,12 +34,12 @@ def pull(args: Args) -> int:
|
|||||||
discord_id, password = read_credentials(args.token_file)
|
discord_id, password = read_credentials(args.token_file)
|
||||||
binary = restic.resolve_binary(args)
|
binary = restic.resolve_binary(args)
|
||||||
repo = _restic_repo(args.url, discord_id, password)
|
repo = _restic_repo(args.url, discord_id, password)
|
||||||
env = _restic_env(password)
|
env = _restic_env()
|
||||||
|
|
||||||
# Check whether any snapshots exist
|
# Check whether any snapshots exist
|
||||||
code, out = restic.run(
|
code, out = restic.run(
|
||||||
binary,
|
binary,
|
||||||
["-r", repo, "snapshots", "--json", "--latest", "1"],
|
["-r", repo, "--insecure-no-password", "snapshots", "--json", "--latest", "1"],
|
||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
@@ -53,7 +62,8 @@ def pull(args: Args) -> int:
|
|||||||
code, _ = restic.run(
|
code, _ = restic.run(
|
||||||
binary,
|
binary,
|
||||||
[
|
[
|
||||||
"-r", repo, "restore", "latest",
|
"-r", repo, "--insecure-no-password",
|
||||||
|
"restore", "latest",
|
||||||
"--target", str(args.pack_folder),
|
"--target", str(args.pack_folder),
|
||||||
"--exclude-file", str(exclude_from),
|
"--exclude-file", str(exclude_from),
|
||||||
],
|
],
|
||||||
@@ -71,7 +81,7 @@ def push(args: Args) -> int:
|
|||||||
discord_id, password = read_credentials(args.token_file)
|
discord_id, password = read_credentials(args.token_file)
|
||||||
binary = restic.resolve_binary(args)
|
binary = restic.resolve_binary(args)
|
||||||
repo = _restic_repo(args.url, discord_id, password)
|
repo = _restic_repo(args.url, discord_id, password)
|
||||||
env = _restic_env(password)
|
env = _restic_env()
|
||||||
|
|
||||||
scope = scopemod.load(args.pack_folder)
|
scope = scopemod.load(args.pack_folder)
|
||||||
files_from, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope)
|
files_from, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope)
|
||||||
@@ -79,7 +89,8 @@ def push(args: Args) -> int:
|
|||||||
code, _ = restic.run(
|
code, _ = restic.run(
|
||||||
binary,
|
binary,
|
||||||
[
|
[
|
||||||
"-r", repo, "backup",
|
"-r", repo, "--insecure-no-password",
|
||||||
|
"backup",
|
||||||
"--files-from", str(files_from),
|
"--files-from", str(files_from),
|
||||||
"--exclude-file", str(exclude_from),
|
"--exclude-file", str(exclude_from),
|
||||||
"--host", "cloud-sync",
|
"--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}/"
|
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 {
|
return {
|
||||||
"RESTIC_PASSWORD": password,
|
# No RESTIC_PASSWORD — repos use --insecure-no-password.
|
||||||
"RESTIC_PROGRESS_FPS": "0",
|
"RESTIC_PROGRESS_FPS": "0",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ def test_missing_scheme_rejected():
|
|||||||
_restic_repo("cloud.tm.center", "u", "p")
|
_restic_repo("cloud.tm.center", "u", "p")
|
||||||
|
|
||||||
|
|
||||||
def test_env_contains_password():
|
def test_env_does_not_contain_password():
|
||||||
env = _restic_env("hunter2")
|
"""Repos use --insecure-no-password; RESTIC_PASSWORD must NOT appear in env
|
||||||
assert env["RESTIC_PASSWORD"] == "hunter2"
|
or it would silently switch repos into encrypted mode."""
|
||||||
assert "RESTIC_PROGRESS_FPS" in env
|
env = _restic_env()
|
||||||
|
assert "RESTIC_PASSWORD" not in env
|
||||||
|
assert env["RESTIC_PROGRESS_FPS"] == "0"
|
||||||
|
|||||||
Reference in New Issue
Block a user