Build the alpha -> vlda-01 Gitea backup strategy

Implements the sketch from SYNC-PLAN.md item 2b. gitea dump (not raw
rsync, to avoid grabbing the live SQLite DB mid-write) runs nightly on
alpha via cron, into a dedicated read-only-chrooted SFTP account
(giteabackup) reusing the existing ftp_jens/ftp_alex sshd pattern.
vlda-01 pulls whatever's new via a User Scripts plugin entry (not a raw
crontab - Unraid's / and /usr are RAM-backed and don't survive reboot,
only /boot does), fetching only files it doesn't already have so a
run after days offline catches up on everything missed.

Found and fixed two real issues along the way: a default ACL on the
backup directory doesn't actually grant read access to new dump files,
since gitea dump creates them 600 and the resulting ACL mask neuters
the inherited grant - fixed by applying the ACL explicitly per file.
And the restricted account's ForceCommand internal-sftp blocks a real
rsync invocation, so the transport is SFTP get, not rsync.

Verified end-to-end through the actual scheduled-invocation path on
both ends, including a second run correctly skipping an already-pulled
file instead of re-fetching it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 16:29:32 +02:00
co-authored by Claude Sonnet 5
parent 94a367822f
commit f9e0f5221f
2 changed files with 84 additions and 54 deletions
+77
View File
@@ -352,6 +352,83 @@ Internet → Apache :80/:443 (git.jayfield.org vhost)
Gitea's automatic redirect) return `200`, and this repo's local `origin`
remote and stored git credentials were updated to match.
### Gitea backup to `vlda-01` (`SYNC-PLAN.md` item 2b)
Built 2026-07-26. A point-in-time backup, not a hot standby/live mirror —
now that alpha is the canonical live Gitea, this keeps `vlda-01`'s copy
from just going stale after the migration. Same network direction as
everything else in this project: alpha has no route into the home LAN,
so the pull is **initiated by `vlda-01`**, never alpha reaching in.
**Why `gitea dump`, not a raw `rsync` of `/srv/gitea/data`**: the DB is a
live SQLite file; copying it while Gitea is writing risks shipping a torn
copy. `gitea dump` produces one consistent archive (DB + repos + LFS +
config together) — also what Gitea's own restore procedure expects.
**On alpha** — `/usr/local/sbin/gitea-backup.sh`, `/etc/cron.d/gitea-backup`
(daily `02:30`, clear of logrotate's midnight timer):
- `docker exec -u git gitea gitea dump -f /data/backups/gitea-dump-<timestamp>.zip --skip-index`
into `/srv/gitea/data/backups/` (inside the existing bind mount — already
host-inspectable, no extra volume). Timestamped to the second, not just
the date: `gitea dump` refuses to overwrite an existing filename, which
a plain per-day name would hit on any manual re-run (found this the hard
way testing it).
- Prunes to the last 14 archives.
- Applies a POSIX ACL (`setfacl -m u:giteabackup:r`) to each new archive
so the pull account below can read it. **Has to happen explicitly per
file** — a default ACL on the directory alone isn't enough: `gitea dump`
creates each file `600`, and the resulting ACL mask neuters the
inherited grant (`getfacl` shows `#effective:---` if this step is
skipped).
**Transport — a dedicated, restricted account on alpha**, not a shared or
personal credential:
- System account `giteabackup` (`/usr/sbin/nologin`), member of the
existing `sftp` group — reuses the `Match Group sftp` /
`ForceCommand internal-sftp` block already in `sshd_config` for
`ftp_jens`/`ftp_alex`; no new sshd config needed.
- Chrooted to `/var/sftp/giteabackup/`, containing only a **read-only bind
mount** of `/srv/gitea/data/backups` (`/etc/fstab`, `bind,ro`) — even a
misused key can only ever read backup archives, never write, delete, or
reach anything else on the host. Verified: `touch` inside the mount
fails with "Read-only file system" even as root.
- Key-only auth: a dedicated ed25519 keypair generated on `vlda-01`
(private key never leaves it), `authorized_keys` further restricted
(`restrict,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty`)
on top of the chroot/`ForceCommand`.
- **Plain SFTP, not real `rsync`**: `ForceCommand internal-sftp` blocks a
genuine `rsync --server` invocation from the client, so the transport
is SFTP `get`, not rsync.
**On `vlda-01`** — via the **User Scripts** plugin, not a raw
crontab/`/usr/local/sbin` script: Unraid's `/` and `/usr` are RAM-backed
(`rootfs`/`overlay`) and don't survive a reboot — only `/boot` (the actual
USB stick) does. The pull script lives at
`/boot/config/plugins/user.scripts/scripts/gitea-backup-pull/script`,
scheduled daily `03:30` via `schedule.json`/`customSchedule.cron`
(`update_cron` folds it into the live `/etc/cron.d/root` alongside
Unraid's own generated jobs) — the same plugin already used for this
box's other scheduled maintenance, no new persistence mechanism invented.
The SSH keypair needed no special handling: Unraid already auto-mirrors
`/root/.ssh` to `/boot/config/ssh/root/` on its own.
- Lists what's actually on alpha via SFTP, fetches only filenames not
already present locally — a run after several days offline catches up
on all of them, not just the newest, without re-downloading anything
already pulled.
- Lands in `/mnt/user/appdata/gitea-backups-from-alpha/` (real array
storage, not the RAM-backed root), keeps the last 30 locally — deeper
history than alpha's own 14, given the Unraid array's ample headroom.
**Restore path, if ever needed**: manual and deliberate — stop the target
Gitea container, unpack the chosen dump's DB/repos/etc. into `/data` per
Gitea's documented restore steps, restart, verify. Not automated.
**Verified end-to-end**: a real archive produced on alpha, pulled to
`vlda-01` via the actual scheduled-invocation code path (`startCustom.php`,
not just the raw script) and confirmed as a genuinely new file; a second
run correctly reported "up to date, nothing new to pull" rather than
re-fetching.
### Password vault sync (KeePass, via Nextcloud)
Each user's KeePass database now syncs through Nextcloud's own WebDAV