feat: add interactive Kopia restore script, Authelia guidance in CLAUDE.md
- extras/restore_kopia_backup.sh: interactive restore tool — lists all snapshot sources, pick source + snapshot, choose inspect (restore to /tmp) or restore-in-place (moves current data aside as .pre-restore-DATE for easy rollback, auto-stops/starts the associated Docker service) - services/backup.sh: copy restore script to ~/docker/backup/ during install; update summary to show restore command - CLAUDE.md: add note to update README services table when adding a service; add Authelia section documenting which services have built-in auth, the import-authelia Caddy pattern for no-auth services, and remember_me_duration config for kiosk/persistent sessions https://claude.ai/code/session_019XgsQ13XKm4Zj3cNsDNwHj
This commit is contained in:
@@ -21,6 +21,9 @@ checklist per group, and calls `install_<name>()` for each selected item.
|
||||
|
||||
That's it. The menu picks it up on the next run.
|
||||
|
||||
Also update the **Services table in `README.md`** — add the service name to the
|
||||
appropriate group row so the README stays current.
|
||||
|
||||
## Minimal Docker service template
|
||||
|
||||
```bash
|
||||
@@ -179,6 +182,51 @@ self-documenting on the deployed box.
|
||||
| `extras` | Non-Docker tools and scripts |
|
||||
| `backup` | Backup solutions |
|
||||
|
||||
## Authelia SSO — which services need it
|
||||
|
||||
Some services have their own login screens; others have none and need Caddy to
|
||||
gate them via Authelia.
|
||||
|
||||
**Has built-in auth — no Authelia needed:**
|
||||
`emby`, `jellyfin`, `audiobookshelf`, `immich`, `mealie`, `actualbudget`,
|
||||
`homeassistant`, `portainer`, `meshcentral`, `traccar`, `uptimekuma`,
|
||||
`filebrowser`, `wg-easy`, `ntfy` (configurable)
|
||||
|
||||
**No built-in auth — should be protected:**
|
||||
`magicmirror`, `wolf-pair`, `js99er`, `sky-cam`
|
||||
|
||||
For services without built-in auth, prompt the user before calling
|
||||
`configure_caddy_for_service` and pass `import authelia` as the extra block
|
||||
if Authelia is installed and the user wants SSO protection:
|
||||
|
||||
```bash
|
||||
local EXTRA_BLOCK=""
|
||||
if [ -d "$DOCKER_DIR/authelia" ]; then
|
||||
local _use_auth=""
|
||||
prompt_yn "Protect MagicMirror with Authelia SSO? (y/n):" "y" _use_auth
|
||||
[[ "$_use_auth" =~ ^[Yy]$ ]] && EXTRA_BLOCK=" import authelia"
|
||||
fi
|
||||
configure_caddy_for_service "MagicMirror" "8081" "mirror" "$EXTRA_BLOCK"
|
||||
```
|
||||
|
||||
**Authelia "stay logged in" / kiosk mode:**
|
||||
Edit `~/docker/authelia/config/configuration.yml` and set a long
|
||||
`remember_me_duration`. Users then check "Remember me" once on login and
|
||||
the session persists through reboots (Redis stores the session in a volume):
|
||||
|
||||
```yaml
|
||||
session:
|
||||
secret: 'your-existing-secret'
|
||||
remember_me_duration: 1y # add or update this line
|
||||
expiration: 1h
|
||||
inactivity: 5m
|
||||
cookies:
|
||||
- domain: 'example.com'
|
||||
authelia_url: 'https://auth.example.com'
|
||||
```
|
||||
|
||||
After editing: `docker compose -f ~/docker/authelia/docker-compose.yml restart`
|
||||
|
||||
## Non-Docker services
|
||||
|
||||
Not everything is a container. For apt-based or git-clone–based services,
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/bin/bash
|
||||
# extras/restore_kopia_backup.sh — interactive restore from a Kopia snapshot.
|
||||
# Installed to ~/docker/backup/ by the backup service installer.
|
||||
#
|
||||
# Run as root:
|
||||
# sudo ./restore_kopia_backup.sh interactive
|
||||
# sudo ./restore_kopia_backup.sh --list list all snapshot sources and exit
|
||||
#
|
||||
# Reads backup.conf from the same directory. The repository password is stored
|
||||
# there (chmod 600, root-only) — no password prompt needed.
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONF="${BACKUP_CONF:-$HERE/backup.conf}"
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
die() { err "$*"; exit 1; }
|
||||
|
||||
# ── Preflight ─────────────────────────────────────────────────────────────────
|
||||
[ "${EUID:-$(id -u)}" -eq 0 ] || die "Run as root: sudo $0"
|
||||
[ -f "$CONF" ] || die "backup.conf not found: $CONF (run: sudo setup.sh backup)"
|
||||
command -v jq >/dev/null 2>&1 || die "jq is required — install it: sudo apt install jq"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$CONF"
|
||||
export KOPIA_PASSWORD
|
||||
|
||||
command -v "$KOPIA" >/dev/null 2>&1 || die "Kopia not found: $KOPIA"
|
||||
k() { "$KOPIA" --config-file="$KOPIA_CONFIG" "$@"; }
|
||||
k repository status >/dev/null 2>&1 || die "Cannot connect to Kopia repository. Check backup.conf."
|
||||
|
||||
# ── Derive Docker dir (mirrors lib/common.sh logic) ───────────────────────────
|
||||
ACTUAL_USER="${SUDO_USER:-${USER:-$(id -un)}}"
|
||||
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "/home/$ACTUAL_USER")"
|
||||
DOCKER_BASE="$ACTUAL_HOME/docker"
|
||||
|
||||
# ── Snapshot helpers ──────────────────────────────────────────────────────────
|
||||
all_snapshots_json() {
|
||||
k snapshot list --all --json 2>/dev/null
|
||||
}
|
||||
|
||||
# Given a source path like ~/docker/minecraft/data, return "service_name /path/to/compose.yml"
|
||||
# or empty string if not a Docker service.
|
||||
docker_info_for_path() {
|
||||
local path="$1"
|
||||
[[ "$path" == "$DOCKER_BASE"/* ]] || return 0
|
||||
local relative="${path#$DOCKER_BASE/}"
|
||||
local service="${relative%%/*}"
|
||||
local compose="$DOCKER_BASE/$service/docker-compose.yml"
|
||||
[ -f "$compose" ] && echo "$service $compose"
|
||||
}
|
||||
|
||||
# ── --list ────────────────────────────────────────────────────────────────────
|
||||
if [ "${1:-}" = "--list" ]; then
|
||||
echo ""
|
||||
info "Loading snapshots..."
|
||||
JSON=$(all_snapshots_json)
|
||||
echo ""
|
||||
echo "Backup sources:"
|
||||
echo ""
|
||||
echo "$JSON" | jq -r '
|
||||
group_by(.source.path)[] |
|
||||
(.[0].source.path) as $p |
|
||||
(.[0].description // "-") as $d |
|
||||
(.[0].startTime | split("T") | "\(.[0]) \(.[1][:8]) UTC") as $t |
|
||||
(length | tostring) as $n |
|
||||
"\($p)\n \($d) | latest: \($t) | \($n) snapshot(s)\n"
|
||||
'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Interactive restore ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════╗"
|
||||
echo "║ Kopia Backup Restore ║"
|
||||
echo "╚═══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
info "Loading snapshot index..."
|
||||
SNAP_JSON=$(all_snapshots_json)
|
||||
|
||||
[ -z "$SNAP_JSON" ] || [ "$SNAP_JSON" = "[]" ] || [ "$SNAP_JSON" = "null" ] \
|
||||
&& die "No snapshots found. Run a backup first: sudo $HERE/backup.sh"
|
||||
|
||||
# Build source arrays (newest-first per source)
|
||||
mapfile -t SRC_PATHS < <(echo "$SNAP_JSON" | jq -r 'group_by(.source.path)[] | .[0].source.path')
|
||||
mapfile -t SRC_DESCS < <(echo "$SNAP_JSON" | jq -r 'group_by(.source.path)[] | .[0].description // "-"')
|
||||
mapfile -t SRC_LATEST < <(echo "$SNAP_JSON" | jq -r 'group_by(.source.path)[] | .[0].startTime | split("T") | "\(.[0]) \(.[1][:8])"')
|
||||
mapfile -t SRC_COUNTS < <(echo "$SNAP_JSON" | jq -r 'group_by(.source.path)[] | length')
|
||||
|
||||
[ "${#SRC_PATHS[@]}" -eq 0 ] && die "No snapshot sources found."
|
||||
|
||||
echo "What do you want to restore?"
|
||||
echo ""
|
||||
for i in "${!SRC_PATHS[@]}"; do
|
||||
printf " %2d) %s\n %s | latest: %s | %s snapshots\n\n" \
|
||||
"$((i+1))" "${SRC_PATHS[$i]}" "${SRC_DESCS[$i]}" \
|
||||
"${SRC_LATEST[$i]}" "${SRC_COUNTS[$i]}"
|
||||
done
|
||||
|
||||
read -rp "Select source [1-${#SRC_PATHS[@]}] or q to quit: " SEL
|
||||
[[ "$SEL" =~ ^[qQ]$ ]] && echo "Cancelled." && exit 0
|
||||
[[ "$SEL" =~ ^[0-9]+$ ]] && [ "$SEL" -ge 1 ] && [ "$SEL" -le "${#SRC_PATHS[@]}" ] \
|
||||
|| die "Invalid selection: $SEL"
|
||||
|
||||
SOURCE_PATH="${SRC_PATHS[$((SEL-1))]}"
|
||||
echo ""
|
||||
ok "Source: $SOURCE_PATH"
|
||||
|
||||
# ── Pick a snapshot ───────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Available snapshots (most recent first):"
|
||||
echo ""
|
||||
|
||||
mapfile -t SNAP_IDS < <(echo "$SNAP_JSON" | jq -r --arg p "$SOURCE_PATH" '
|
||||
[.[] | select(.source.path == $p)] | sort_by(.startTime) | reverse | .[0:15] | .[].id')
|
||||
mapfile -t SNAP_TIMES < <(echo "$SNAP_JSON" | jq -r --arg p "$SOURCE_PATH" '
|
||||
[.[] | select(.source.path == $p)] | sort_by(.startTime) | reverse | .[0:15] |
|
||||
.[].startTime | split("T") | "\(.[0]) \(.[1][:8]) UTC"')
|
||||
|
||||
[ "${#SNAP_IDS[@]}" -eq 0 ] && die "No snapshots found for that source."
|
||||
|
||||
for i in "${!SNAP_IDS[@]}"; do
|
||||
note=""; [ "$i" -eq 0 ] && note=" ← latest"
|
||||
printf " %2d) %s (id: %s...)%s\n" \
|
||||
"$((i+1))" "${SNAP_TIMES[$i]}" "${SNAP_IDS[$i]:0:12}" "$note"
|
||||
done
|
||||
|
||||
echo ""
|
||||
read -rp "Select snapshot [1-${#SNAP_IDS[@]}, Enter = latest]: " SNAP_SEL
|
||||
SNAP_SEL="${SNAP_SEL:-1}"
|
||||
[[ "$SNAP_SEL" =~ ^[0-9]+$ ]] && [ "$SNAP_SEL" -ge 1 ] && [ "$SNAP_SEL" -le "${#SNAP_IDS[@]}" ] \
|
||||
|| die "Invalid selection: $SNAP_SEL"
|
||||
|
||||
SNAPSHOT_ID="${SNAP_IDS[$((SNAP_SEL-1))]}"
|
||||
SNAPSHOT_TIME="${SNAP_TIMES[$((SNAP_SEL-1))]}"
|
||||
echo ""
|
||||
ok "Snapshot: $SNAPSHOT_TIME (id: ${SNAPSHOT_ID:0:16}...)"
|
||||
|
||||
# ── Choose restore mode ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Restore mode:"
|
||||
echo ""
|
||||
echo " 1) Inspect — restore to /tmp so you can browse files without touching anything live"
|
||||
echo " 2) Restore — move current data aside, restore snapshot in its place"
|
||||
echo " old data kept as .pre-restore-DATE (easy rollback)"
|
||||
echo ""
|
||||
read -rp "Select [1/2] or q to quit: " MODE
|
||||
[[ "$MODE" =~ ^[qQ]$ ]] && echo "Cancelled." && exit 0
|
||||
|
||||
case "$MODE" in
|
||||
|
||||
# ── Inspect: restore to /tmp, nothing touched ─────────────────────────────────
|
||||
1)
|
||||
TEMP_DIR="/tmp/kopia-inspect-$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$TEMP_DIR"
|
||||
echo ""
|
||||
info "Restoring snapshot to $TEMP_DIR (nothing live is changed)..."
|
||||
if k restore "$SNAPSHOT_ID" "$TEMP_DIR"; then
|
||||
echo ""
|
||||
ok "Done. Browse the restored files:"
|
||||
echo ""
|
||||
echo " ls -la $TEMP_DIR"
|
||||
echo ""
|
||||
echo " When finished:"
|
||||
echo " rm -rf $TEMP_DIR"
|
||||
else
|
||||
rm -rf "$TEMP_DIR" 2>/dev/null || true
|
||||
die "Restore failed — no changes were made."
|
||||
fi
|
||||
;;
|
||||
|
||||
# ── Restore in place: move aside, restore, offer rollback instructions ─────────
|
||||
2)
|
||||
SINFO=$(docker_info_for_path "$SOURCE_PATH")
|
||||
SVC_NAME="${SINFO%% *}"
|
||||
COMPOSE_FILE="${SINFO##* }"
|
||||
# If no match, both vars will be empty or equal
|
||||
[ "$SVC_NAME" = "$COMPOSE_FILE" ] && SVC_NAME="" && COMPOSE_FILE=""
|
||||
|
||||
# Stop associated Docker service if running
|
||||
STOPPED=false
|
||||
if [ -n "$SVC_NAME" ] && [ -n "$COMPOSE_FILE" ] \
|
||||
&& docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$SVC_NAME"; then
|
||||
echo ""
|
||||
warn "Container '$SVC_NAME' is currently running."
|
||||
read -rp " Stop it before restoring? Recommended to avoid data corruption. (Y/n): " STOP_YN
|
||||
if [[ ! "$STOP_YN" =~ ^[Nn]$ ]]; then
|
||||
info "Stopping $SVC_NAME..."
|
||||
docker compose -f "$COMPOSE_FILE" down 2>/dev/null \
|
||||
|| docker stop "$SVC_NAME" 2>/dev/null \
|
||||
|| warn "Could not stop $SVC_NAME — continuing anyway."
|
||||
STOPPED=true
|
||||
ok "$SVC_NAME stopped."
|
||||
else
|
||||
warn "Restoring with $SVC_NAME running — consistency not guaranteed."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Move current data aside
|
||||
ASIDE="${SOURCE_PATH}.pre-restore-$(date +%Y%m%d-%H%M%S)"
|
||||
echo ""
|
||||
if [ -e "$SOURCE_PATH" ]; then
|
||||
info "Moving current data aside → $(basename "$ASIDE")"
|
||||
mv "$SOURCE_PATH" "$ASIDE"
|
||||
ok "Current data saved at: $ASIDE"
|
||||
else
|
||||
warn "Source path doesn't exist yet: $SOURCE_PATH — restoring fresh."
|
||||
fi
|
||||
|
||||
# Restore snapshot
|
||||
mkdir -p "$SOURCE_PATH"
|
||||
info "Restoring $SNAPSHOT_TIME → $SOURCE_PATH ..."
|
||||
if k restore "$SNAPSHOT_ID" "$SOURCE_PATH"; then
|
||||
ok "Restore complete."
|
||||
else
|
||||
err "Restore failed — rolling back to original data."
|
||||
rm -rf "$SOURCE_PATH" 2>/dev/null || true
|
||||
if [ -e "$ASIDE" ]; then
|
||||
mv "$ASIDE" "$SOURCE_PATH"
|
||||
ok "Original data recovered from aside copy."
|
||||
fi
|
||||
if [ "$STOPPED" = true ] && [ -n "$COMPOSE_FILE" ]; then
|
||||
docker compose -f "$COMPOSE_FILE" up -d 2>/dev/null || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Restart container
|
||||
if [ "$STOPPED" = true ] && [ -n "$COMPOSE_FILE" ]; then
|
||||
echo ""
|
||||
read -rp " Start '$SVC_NAME' now? (Y/n): " START_YN
|
||||
if [[ ! "$START_YN" =~ ^[Nn]$ ]]; then
|
||||
info "Starting $SVC_NAME..."
|
||||
docker compose -f "$COMPOSE_FILE" up -d 2>/dev/null \
|
||||
&& ok "$SVC_NAME started." \
|
||||
|| warn "Start failed — check: docker compose -f $COMPOSE_FILE logs"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " RESTORE COMPLETE"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Restored from : $SNAPSHOT_TIME"
|
||||
echo " Restored to : $SOURCE_PATH"
|
||||
[ -e "$ASIDE" ] && echo " Previous data : $ASIDE"
|
||||
echo ""
|
||||
echo " Verify your data, then:"
|
||||
echo ""
|
||||
if [ -e "$ASIDE" ]; then
|
||||
echo " Keep the restore (delete aside copy when satisfied):"
|
||||
echo " rm -rf \"$ASIDE\""
|
||||
echo ""
|
||||
echo " Roll back to previous data:"
|
||||
[ -n "$COMPOSE_FILE" ] && echo " docker compose -f $COMPOSE_FILE down"
|
||||
echo " rm -rf \"$SOURCE_PATH\""
|
||||
echo " mv \"$ASIDE\" \"$SOURCE_PATH\""
|
||||
[ -n "$COMPOSE_FILE" ] && echo " docker compose -f $COMPOSE_FILE up -d"
|
||||
fi
|
||||
echo ""
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Cancelled."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
+16
-3
@@ -460,6 +460,18 @@ WORKEREOF
|
||||
chown root:root "$WORKER" 2>/dev/null || true
|
||||
log_success "backup.sh written"
|
||||
|
||||
# ── Copy the interactive restore script from the repo ────────────────────
|
||||
local RESTORE_SRC="${HERE:-}/extras/restore_kopia_backup.sh"
|
||||
local RESTORE_DEST="$BACKUP_DIR/restore_kopia_backup.sh"
|
||||
if [ -f "$RESTORE_SRC" ]; then
|
||||
cp "$RESTORE_SRC" "$RESTORE_DEST"
|
||||
chmod +x "$RESTORE_DEST"
|
||||
chown root:root "$RESTORE_DEST" 2>/dev/null || true
|
||||
log_success "restore_kopia_backup.sh installed"
|
||||
else
|
||||
log_warning "extras/restore_kopia_backup.sh not found — restore script not installed"
|
||||
fi
|
||||
|
||||
# ── 8. Install systemd timer (fallback: cron) ────────────────────────────
|
||||
local AUTORUN=""
|
||||
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
||||
@@ -533,9 +545,10 @@ UNITEOF
|
||||
echo " NOT backed up: ROMs, Steam game installs (re-downloadable)."
|
||||
echo ""
|
||||
echo " Commands:"
|
||||
echo " sudo $WORKER back up now"
|
||||
echo " sudo $WORKER snapshots list snapshots"
|
||||
echo " sudo $WORKER restore restore / browse"
|
||||
echo " sudo $WORKER back up now"
|
||||
echo " sudo $WORKER snapshots list snapshots"
|
||||
echo " sudo $RESTORE_DEST interactive restore"
|
||||
echo " sudo $RESTORE_DEST --list list all snapshot sources"
|
||||
echo " $AUTORUN"
|
||||
echo ""
|
||||
echo " Offsite mirror (another computer / cloud): set REMOTE_TYPE + REMOTE_ARGS"
|
||||
|
||||
Reference in New Issue
Block a user