#!/usr/bin/env bash # /usr/local/bin/restic-backup-playerdata.sh # # Class A backup per docs/BACKUP-STRATEGY.md — every 5 minutes, snapshot # playerdata + stats + advancements + plugin DBs + LuckPerms config. # Skips the heavy region/ files (those are Class B, hourly). # # Driven by mc-backup-playerdata.timer (5 min cadence). # # Pre-req: restic installed; one-time bootstrap performed by # scripts/restic-init.sh which creates the local repo and writes # /etc/mc-backup.env + /etc/mc-backup.pw. # # Status (2026-05-07): scripts shipped to repo; deployment to nullstone # is BLOCKED on operator running `apt install restic` + scripts/restic-init.sh # under sudo. See docs/RUNBOOK-BACKUP-RESTORE.md "Phase 2 deployment". set -euo pipefail umask 077 ENV_FILE="${MC_BACKUP_ENV_FILE:-/etc/mc-backup.env}" if [ ! -r "$ENV_FILE" ]; then echo "FATAL: env file $ENV_FILE not readable — run scripts/restic-init.sh first" >&2 exit 2 fi # shellcheck disable=SC1090 . "$ENV_FILE" : "${RESTIC_REPOSITORY_FREQUENT:?RESTIC_REPOSITORY_FREQUENT not set in $ENV_FILE}" : "${RESTIC_PASSWORD_FILE:?RESTIC_PASSWORD_FILE not set in $ENV_FILE}" : "${MC_DATA:?MC_DATA not set in $ENV_FILE}" export RESTIC_REPOSITORY="$RESTIC_REPOSITORY_FREQUENT" export RESTIC_PASSWORD_FILE LOG="${MC_BACKUP_LOG:-/var/log/mc-backup.log}" SENTINEL="${MC_BACKUP_FREQUENT_SENTINEL:-/var/lib/mc-backup/last-success-frequent}" RCON_HOST="${MC_RCON_HOST:-127.0.0.1}" RCON_PORT="${MC_RCON_PORT:-25575}" RCON_PASS="${MC_RCON_PASSWORD:-}" mkdir -p "$(dirname "$SENTINEL")" log() { printf '[%s] [frequent] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" \ | tee -a "$LOG" } on_err() { local rc=$? log "ERROR rc=${rc} at line ${BASH_LINENO[0]}" if [ -n "${ALERT_URL:-}" ]; then curl -fsS -m 5 -d "mc-backup-frequent FAILED rc=${rc}" "$ALERT_URL" \ >/dev/null 2>&1 || true fi exit "$rc" } trap on_err ERR log "=== run start (host=$(hostname)) ===" # 1. Best-effort: ask the server to flush before snapshotting. # Don't fail the backup if rcon is down or unreachable — we'd rather # have a slightly-stale snapshot than no snapshot. if [ -n "$RCON_PASS" ] && command -v mcrcon >/dev/null 2>&1; then if mcrcon -H "$RCON_HOST" -P "$RCON_PORT" -p "$RCON_PASS" -w 1 \ "save-all flush" >/dev/null 2>&1; then log "rcon save-all flush: ok" else log "rcon save-all flush: failed (continuing)" fi else log "rcon: skipped (no mcrcon or no password)" fi # 2. Build the include list. Anything that's missing on disk is silently # skipped by restic, so we can list optional paths freely. INCLUDES=( "${MC_DATA}/world/playerdata" "${MC_DATA}/world/stats" "${MC_DATA}/world/advancements" "${MC_DATA}/world/level.dat" "${MC_DATA}/world_nether/level.dat" "${MC_DATA}/world_the_end/level.dat" "${MC_DATA}/homestead_data.db" "${MC_DATA}/plugins/AuthMe" "${MC_DATA}/plugins/CoreProtect/database.db" "${MC_DATA}/plugins/LuckPerms" ) EXISTING=() for p in "${INCLUDES[@]}"; do if [ -e "$p" ]; then EXISTING+=("$p") fi done if [ ${#EXISTING[@]} -eq 0 ]; then log "no source paths exist — aborting" exit 3 fi # 3. Snapshot. Tagged so retention policy can target this class only. log "snapshotting ${#EXISTING[@]} path(s)" restic backup \ --tag playerdata \ --tag auto-5min \ --host "$(hostname)" \ --exclude='*.lock' \ --exclude='*.tmp' \ "${EXISTING[@]}" \ >> "$LOG" 2>&1 # 4. Light retention — only on this repo, only on this tag. restic forget \ --tag auto-5min \ --keep-last 24 \ --keep-hourly 24 \ --keep-daily 7 \ --prune \ --quiet \ >> "$LOG" 2>&1 || log "forget+prune returned non-zero (continuing)" # 5. Sentinel for external monitor. { printf 'last_success=%s\n' "$(date -Iseconds)" printf 'class=A\n' printf 'repo=%s\n' "$RESTIC_REPOSITORY" } > "$SENTINEL" # 6. Heartbeat (no-op if HEARTBEAT_URL unset). if [ -n "${HEARTBEAT_URL:-}" ]; then curl -fsS -m 5 "$HEARTBEAT_URL" >/dev/null 2>&1 || true fi log "=== run ok ==="