Earlier draft archived cloud-svc entirely. Better shape: keep it as a control plane for the restic backend. Two listeners in one process: - provisioning :9091 on automc-net (called by discord-bot) - operator :9092 on 127.0.0.1 (called by automc-setup wizard) Players still hit restic-rest-server (data plane) directly with their per-user password. cloud-svc never sits in the player data path — limits its public exposure to zero.
11 KiB
cloud-sync — design
Per-Discord-user state sync for Minecraft. Pulls on launch, pushes on exit. Single JAR drops into Prism / MMC / ATLauncher / frazclient as a pre-launch + post-exit hook.
Data plane: restic-rest-server with --private-repos --append-only. Clients hit this directly with their per-user password.
Control plane: cloud-svc Go service with two listeners — a provisioning port reachable from automc-net (called by discord-bot) and a loopback admin port (called by automc-setup wizard). Players never touch cloud-svc.
Client: cloud-sync.jar subprocesses restic. ~200 LOC.
Why this shape
| Concern | How restic solves it |
|---|---|
| Snapshot semantics | Native — every restic backup is a snapshot |
| Deduplication | Chunk-level (not just file-level), built in |
| 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 |
| 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.
Topology
flowchart LR
pl["player PC"]:::external
op["operator
(via SSH)"]:::external
jar["cloud-sync.jar
(in launcher's
pre/post hooks)"]:::deploy
restic["restic binary
(auto-downloaded
on first run)"]:::deploy
subgraph john["john (192.168.65.33)"]
rp{{"reverse proxy
:443"}}:::deploy
subgraph net["automc-net"]
ao{{"restic-rest-server
--private-repos
--append-only
:8002"}}:::deploy
bot{{"discord-bot"}}:::deploy
cs_int{{"cloud-svc
provisioning :9091
(automc-net only)"}}:::deploy
end
cs_admin{{"cloud-svc
admin :9092
(127.0.0.1 only)"}}:::deploy
store[/"/srv/cloud-data
/<discord_id>/..."/]:::pvc
htp[/"/etc/restic-users
htpasswd"/]:::pvc
end
pl --> jar --> restic
restic ==>|"rest:https
<discord_id>:<password>"| rp
rp -->|"loopback"| ao
ao -->|"reads"| htp
ao -->|"writes"| store
bot -.->|"on /register:
POST /admin/users"| cs_int
cs_int -.->|"htpasswd add
restic init
key add"| 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 -.->|"prune via
operator master key"| store
classDef deploy fill:#d5e8d4,stroke:#82b366,color:#000
classDef pvc fill:#f5f5f5,stroke:#666,color:#000
classDef external fill:#f5f5f5,stroke:#666,color:#000,stroke-dasharray:5 5
cloud-svc runs as one process with two listeners:
| Listener | Bind | Reachable from | Endpoints |
|---|---|---|---|
| Provisioning | automc-net:9091 (no PublishPort) |
discord-bot via service-net DNS | POST /admin/users only |
| Operator | 127.0.0.1:9092 |
john's loopback (SSH session) | GET/DELETE /admin/users, POST /admin/users/{id}/prune, GET /admin/users/{id}/quota, etc. |
The split means a compromised discord-bot can mint new accounts but cannot enumerate, prune, or revoke existing ones. Operator-only ops require shell access on john.
Auth model:
- Provisioning listener: shared service token (env
CLOUD_PROVISIONING_KEY), discord-bot uses same value from its own env - Operator listener: no auth — loopback bind is the boundary, same pattern as
server-manager:127.0.0.1:8080
Auth & identity
| 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 |
| 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.
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.
Client flow
cloud-sync.jar pull
1. Load creds from <pack-folder>/.cloud-token (format: discord_id:password on one line)
2. Locate or auto-download restic binary into <jar dir>/restic-<version>/
3. restic -r rest:https://<url>/<discord_id>/ snapshots --latest 1 --json
4. If no snapshots → exit 0 (first run on this machine, nothing to restore)
5. restic restore latest --target <pack-folder> --include-from cloud-scope.txt
cloud-sync.jar push
1. Same creds + restic locator as pull
2. restic backup <pack-folder> --files-from cloud-scope.txt --exclude-from cloud-exclude.txt
3. restic forget --keep-last 20 --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
The forget --prune step is allowed by restic-rest-server --append-only only if the client supplies Force-Allow-Forget: true. We DON'T enable this in --append-only mode — the server refuses forget. Pruning happens server-side via a nightly cron running restic forget with the operator's full-access password against the repo. Clients can only add, never remove.
cloud-scope.json → restic args
| Input | Becomes |
|---|---|
include: ["options.txt", "config/", "journeymap/data/"] |
Listed in cloud-scope.txt, passed as --files-from cloud-scope.txt |
exclude: ["config/simple-mod-sync*", "**/*.log"] |
Listed in cloud-exclude.txt, passed as --exclude-from cloud-exclude.txt |
max_size_mb_per_file: 50 |
restic doesn't have a per-file size cap; we filter during scope generation |
Retention policy
Server-side cron (e.g., daily at 04:00 UTC) walks all per-user repos:
for repo in /srv/cloud-data/*/; do
user=$(basename "$repo")
restic -r "$repo" --password-file /etc/restic-master-pass \
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:
- Init each user's repo with TWO keys — one for the user, one for the operator-side pruner. restic supports multi-key per repo.
- Run the cron with each user's own password — requires storing all user passwords server-side; defeats the encryption.
- Don't auto-prune — let users push forever, trust quota at the rest-server level.
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.
What's in v1
- restic-rest-server with
--private-repos --append-only --htpasswd-file - discord-bot
/registerextension: mint password, htpasswd add,restic initrepo,restic key addplayer key - cloud-sync.jar that subprocesses restic for pull/push
- Auto-download restic binary on first run from upstream GitHub release
- Server-side nightly prune cron with operator-side master password key
What's deferred
- restic version pinning / auto-update of the binary (treat like packwiz-installer self-update)
- Server-side
restic checkcron for repo integrity - Per-user quota at the rest-server level (rest-server supports
--max-sizeper-user via.maxsizefile in each repo) - Operator UI for "this player has 25 GB of cloud data, what's in it?"
- Cross-machine sync UX (you can play on PC A then PC B; latest snapshot wins. No conflict UI because restic doesn't merge — restore-latest is destructive by design.)
cloud-svc — reshape, not delete
cloud-svc gets a new purpose: control plane for the restic backend. Throw away:
- Manifest types + validation (
manifest.go) - Blob storage + tarball extraction (
storage.gobody) - Player-facing
/v1/*endpoints (server.gobody) - Snapshot ID generation, content hash cross-check
Keep:
- Project skeleton (go.mod, Dockerfile, Makefile, CI)
- Auth-cache pattern from
auth.go(reused for provisioning token verification) - Per-user mutex pattern from
storage.go(still needed to serialize concurrent provisioning calls) - Config loader from
config.go(adds new vars)
New code:
- Two
http.Serverinstances, one per listener - htpasswd writer that respects bcrypt + file locking
- restic CLI subprocesser (init repo, add key, prune)
time.Tickerfor nightly prune job
Estimate: ~300 LOC kept, ~600 LOC new. Net smaller than current cloud-svc.
Also delete cloud_pull / cloud_push from frazclient/client.py (these get obsoleted by cloud-sync.jar calls).
Topology consequences for automc/docs/network-exposure.md
| Layer | Bind | Public? |
|---|---|---|
restic-ao (data plane) |
127.0.0.1:8002 |
Via reverse proxy at cloud.tm.center:443 |
cloud-svc provisioning listener |
automc-net:9091 (no PublishPort) |
No |
cloud-svc admin listener |
127.0.0.1:9092 |
No |
Only one public HTTPS endpoint changes from the original plan: it now fronts restic-ao instead of cloud-svc. Same reverse-proxy hardening checklist applies. Threat surface differences:
| Old (cloud-svc as data path) | New (restic-ao as data path) |
|---|---|
Bearer token via auth-service /auth/verify-key |
HTTP Basic via htpasswd in restic-rest-server |
| Custom Go service, 33 tests | Upstream restic-rest-server, well-audited |
| Player-facing endpoints | None — cloud-svc not public |
Operator endpoints are loopback-only and require SSH access to john to reach. No new public surface from the control plane.
Repo layout post-pivot
| Repo | Purpose |
|---|---|
Timemachine/cloud-sync (this) |
Kotlin/Gradle JAR that subprocesses restic |
Timemachine/cloud-svc |
Reshaped — control plane only. Two-port Go service for provisioning + operator ops. NOT archived. |
Timemachine/discord-bot |
Extended /register flow calls cloud-svc to provision; DMs returned password |
Timemachine/automc |
setup wizard adds automc-setup cloud {list,prune,revoke,quota} subcommands hitting cloud-svc's loopback admin port. Quadlet templates for both restic-ao (new flags) and cloud-svc (two listeners). database/schema.sql unchanged. |
Pre-implementation checklist
- User reviews this design doc
- Confirmed (2026-06-02): cloud-svc reshapes to control plane, not archived
- Confirmed (2026-06-02): two-port split — automc-net for provisioning, loopback for operator
- Confirm: server-side prune via operator master password key on each repo
- Confirm: cloud-sync.jar auto-downloads restic binary vs requires it pre-installed
- Confirm: nightly prune cadence (default proposal: daily 04:00 UTC)
- Confirm: shared service token between discord-bot and cloud-svc provisioning port (env var on both)