diff --git a/scripts/cleanup-service-data.sh b/scripts/cleanup-service-data.sh new file mode 100644 index 0000000..179c9dc --- /dev/null +++ b/scripts/cleanup-service-data.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# ============================================================================= +# cleanup-service-data.sh +# +# Removes residual on-disk data from services that have been uninstalled +# (i.e., disabled in NixOS config and no longer running). +# +# For each known service the script checks: +# 1. Whether the service unit is currently active or enabled — if so, +# the service is live and its data is NEVER touched. +# 2. Whether the data path(s) exist on disk. +# 3. For PostgreSQL-backed services, whether the DB still exists. +# +# Nothing is deleted without explicit per-service confirmation. +# +# Run as root on the NixOS server. +# ============================================================================= +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +skip() { echo -e "${CYAN}[SKIP]${NC} $*"; } +removed() { echo -e "${RED}[DEL]${NC} $*"; } +section() { echo -e "\n${BOLD}── $* ${NC}"; } +confirm() { + read -r -p "$(echo -e "${YELLOW} ${1} [y/N] ${NC}")" r + [[ "${r,,}" == "y" ]] +} + +[[ "$(id -u)" -eq 0 ]] || { + echo -e "${RED}Run as root.${NC}" >&2 + exit 1 +} + +DRY_RUN=false +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true && warn "Dry-run mode — nothing will be deleted." + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +# Returns 0 if the systemd service is active or enabled (i.e., still live). +service_live() { + local unit="$1" + systemctl is-active --quiet "$unit" 2>/dev/null || + systemctl is-enabled --quiet "$unit" 2>/dev/null +} + +# Removes a directory, respecting dry-run. +remove_dir() { + local path="$1" + if [[ ! -d "$path" ]]; then return; fi + local size + size=$(du -sh "$path" 2>/dev/null | cut -f1 || echo "?") + if $DRY_RUN; then + warn " DRY-RUN: would remove $path ($size)" + else + rm -rf "$path" + removed "$path ($size freed)" + fi +} + +# Drops a PostgreSQL database if it exists. +drop_pg_db() { + local db="$1" + local exists + exists=$(sudo -u postgres psql -tA -c \ + "SELECT 1 FROM pg_database WHERE datname='${db}';" 2>/dev/null || echo "") + if [[ "$exists" != "1" ]]; then + skip " PostgreSQL DB '${db}' does not exist — nothing to drop." + return + fi + if $DRY_RUN; then + warn " DRY-RUN: would drop PostgreSQL DB '${db}'" + else + sudo -u postgres dropdb "$db" + removed " PostgreSQL DB '${db}' dropped." + fi +} + +# Drops a PostgreSQL role if it exists and has no remaining owned objects. +drop_pg_role() { + local role="$1" + local exists + exists=$(sudo -u postgres psql -tA -c \ + "SELECT 1 FROM pg_roles WHERE rolname='${role}';" 2>/dev/null || echo "") + if [[ "$exists" != "1" ]]; then return; fi + if $DRY_RUN; then + warn " DRY-RUN: would drop PostgreSQL role '${role}'" + else + if sudo -u postgres dropuser "$role" 2>/dev/null; then + removed " PostgreSQL role '${role}' dropped." + else + warn " Could not drop role '${role}' (may still own objects)." + fi + fi +} + +# ── Service definitions ─────────────────────────────────────────────────────── +# Each entry: check_unit display_name pg_db(or-) dirs... + +declare -A SVC_LABELS=( + [gotosocial]="GoToSocial (ActivityPub)" + [sharkey]="Sharkey (ActivityPub)" + [forgejo]="Forgejo (Git forge)" + [nextcloud]="Nextcloud" + [immich - server]="Immich (photos)" + [jellyfin]="Jellyfin" + [vaultwarden]="Vaultwarden" + [bluesky - pds]="Bluesky PDS (ATProto)" + [samba - smbd]="Time Machine (Samba)" + [grafana]="Grafana" + [prometheus]="Prometheus" +) + +# unit → "pg_db:pg_role dir1 dir2 ..." (- means no PostgreSQL) +declare -A SVC_DATA=( + [gotosocial]="-:/srv/gotosocial" + [sharkey]="sharkey:sharkey /srv/sharkey" + [forgejo]="-:/srv/forgejo" + [nextcloud]="nextcloud:nextcloud /srv/nextcloud" + [immich - server]="immich:immich /srv/immich" + # Jellyfin: /var/lib/jellyfin is config/metadata — the media dir + # (/srv/nextcloud/data/.../Media) is shared with Nextcloud and is NOT cleaned. + [jellyfin]="-:/var/lib/jellyfin" + [vaultwarden]="-:/srv/vaultwarden" + [bluesky - pds]="-:/srv/bluesky-pds" + [samba - smbd]="-:/srv/timemachine" + [grafana]="-:/var/lib/grafana" + [prometheus]="-:/var/lib/prometheus2" +) + +# ── Main loop ───────────────────────────────────────────────────────────────── +FOUND=0 + +for unit in "${!SVC_DATA[@]}"; do + label="${SVC_LABELS[$unit]}" + entry="${SVC_DATA[$unit]}" + + # Parse "pg_db:pg_role dir1 dir2 ..." + pg_part="${entry%%:*}" + dirs_raw="${entry#*:}" + IFS=' ' read -r -a dirs <<<"$dirs_raw" + + # Determine whether any data actually exists on disk + data_present=false + for d in "${dirs[@]}"; do + [[ -d "$d" ]] && data_present=true && break + done + + # Also check for PostgreSQL DB if applicable + pg_present=false + pg_db="" + pg_role="" + if [[ "$pg_part" != "-" ]]; then + pg_db="${pg_part%%/*}" + pg_role="${pg_part##*/}" + # Only check if psql is available + if command -v psql &>/dev/null; then + exists=$(sudo -u postgres psql -tA -c \ + "SELECT 1 FROM pg_database WHERE datname='${pg_db}';" 2>/dev/null || echo "") + [[ "$exists" == "1" ]] && pg_present=true + fi + fi + + $data_present || $pg_present || continue + + FOUND=$((FOUND + 1)) + section "$label" + + # ── Guard: skip if service is still live ────────────────────────────── + if service_live "$unit"; then + skip "$unit is still active/enabled — skipping." + continue + fi + + # Show what was found + for d in "${dirs[@]}"; do + if [[ -d "$d" ]]; then + size=$(du -sh "$d" 2>/dev/null | cut -f1 || echo "?") + echo -e " ${CYAN}dir${NC} $d ($size)" + fi + done + if $pg_present; then + echo -e " ${CYAN}pg${NC} database: $pg_db role: $pg_role" + fi + + # ── Confirm and remove ──────────────────────────────────────────────── + confirm "Remove all data for $label?" || { + skip "Skipped." + continue + } + + for d in "${dirs[@]}"; do + remove_dir "$d" + done + + if $pg_present; then + drop_pg_db "$pg_db" + drop_pg_role "$pg_role" + fi + + info "Cleaned up $label." +done + +# ── /root key backups from migration ────────────────────────────────────────── +KEYPAIR_BACKUPS=(/root/gts-keypair-*.env) +if [[ -f "${KEYPAIR_BACKUPS[0]}" ]]; then + section "GTS keypair migration backups" + for f in "${KEYPAIR_BACKUPS[@]}"; do + [[ -f "$f" ]] || continue + echo -e " ${CYAN}file${NC} $f" + done + if confirm "Remove GTS keypair backup(s) from /root?"; then + for f in "${KEYPAIR_BACKUPS[@]}"; do + [[ -f "$f" ]] || continue + if $DRY_RUN; then + warn " DRY-RUN: would remove $f" + else + rm -f "$f" + removed "$f" + fi + done + else + skip "Skipped." + fi +fi + +# ── GTS .nix.bak ────────────────────────────────────────────────────────────── +BAK_DIR="$(dirname "$(realpath "$0")")" +GTS_BAK="${BAK_DIR}/../modules/server/gotosocial.nix.bak" +GTS_BAK="$(realpath --canonicalize-missing "$GTS_BAK")" +if [[ -f "$GTS_BAK" ]]; then + section "GTS module backup" + echo -e " ${CYAN}file${NC} $GTS_BAK" + if confirm "Remove gotosocial.nix.bak?"; then + if $DRY_RUN; then + warn " DRY-RUN: would remove $GTS_BAK" + else + rm -f "$GTS_BAK" + removed "$GTS_BAK" + fi + else + skip "Skipped." + fi +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo "" +if [[ "$FOUND" -eq 0 ]]; then + info "No residual service data found." +else + info "Done." +fi diff --git a/scripts/migrate-gts-to-sharkey.sh b/scripts/migrate-gts-to-sharkey.sh index ae6eba5..64e3cfc 100644 --- a/scripts/migrate-gts-to-sharkey.sh +++ b/scripts/migrate-gts-to-sharkey.sh @@ -7,8 +7,16 @@ # 2. Stopping GTS, switching to Sharkey via nixos-rebuild # 3. Injecting the old RSA keypair into Sharkey's PostgreSQL # +# GTS schema (SQLite): +# table: accounts +# columns: private_key, public_key (PEM strings, local account has domain IS NULL) +# +# Sharkey schema (PostgreSQL, 2025.4.6): +# table: user_keypair +# columns: "userId" (PK, FK → user.id), "publicKey", "privateKey" (varchar 4096) +# # Run as root on the NixOS server. -# Prereq: sharkey.nix written + secrets/sharkey.env encrypted + options updated. +# Prereq: sharkey.nix written + secrets/sharkey.env sops-encrypted + nixos-rebuild pending. # ============================================================================= set -euo pipefail @@ -42,7 +50,9 @@ for cmd in sqlite3 psql systemctl curl jq; do command -v "$cmd" &>/dev/null || error "Missing required command: $cmd" done -# ── Step 1: Extract RSA keys ────────────────────────────────────────────────── +# ── Step 1: Extract RSA keys from GTS SQLite ───────────────────────────────── +# GTS bun ORM maps PrivateKey/PublicKey (*rsa.PrivateKey/*rsa.PublicKey) to +# snake_case columns private_key/public_key, stored as PEM strings. info "Extracting RSA keypair for @${GTS_USERNAME} from SQLite..." PRIVATE_KEY=$(sqlite3 "$GTS_DB" \ @@ -50,9 +60,15 @@ PRIVATE_KEY=$(sqlite3 "$GTS_DB" \ PUBLIC_KEY=$(sqlite3 "$GTS_DB" \ "SELECT public_key FROM accounts WHERE username='${GTS_USERNAME}' AND domain IS NULL LIMIT 1;") -[[ -n "$PRIVATE_KEY" && -n "$PUBLIC_KEY" ]] || error "Could not extract keys — check username." +[[ -n "$PRIVATE_KEY" ]] || error "private_key is empty — check GTS_USERNAME and that the account is local." +[[ -n "$PUBLIC_KEY" ]] || error "public_key is empty." + +# Sanity-check PEM headers +[[ "$PRIVATE_KEY" == *"BEGIN RSA PRIVATE KEY"* || "$PRIVATE_KEY" == *"BEGIN PRIVATE KEY"* ]] || + error "private_key does not look like a PEM block." +[[ "$PUBLIC_KEY" == *"BEGIN PUBLIC KEY"* ]] || + error "public_key does not look like a PEM block." -# Persist to a backup file in case we need to re-run step 3 { echo "PRIVATE_KEY< Admin -> Users -> Create" -warn "Username must be: ${GTS_USERNAME}" +# ── Step 3: Create the local account in Sharkey ─────────────────────────────── +warn "Create @${GTS_USERNAME} in the Sharkey setup wizard or admin panel:" +warn " https://${AP_HOSTNAME} (first run triggers the setup wizard)" +warn " Username must be exactly: ${GTS_USERNAME}" read -r -p "$(echo -e "${YELLOW}Press Enter once the account exists...${NC}")" SHARKEY_USER_ID=$(sudo -u postgres psql -d "$SHARKEY_DB" -tA \ -c "SELECT id FROM \"user\" WHERE username='${GTS_USERNAME}' AND host IS NULL LIMIT 1;" 2>/dev/null || true) +SHARKEY_USER_ID="${SHARKEY_USER_ID// /}" # trim whitespace psql may add -[[ -n "$SHARKEY_USER_ID" ]] || error "User @${GTS_USERNAME} not found in Sharkey DB. Create the account first." +[[ -n "$SHARKEY_USER_ID" ]] || error "@${GTS_USERNAME} not found in Sharkey DB — create the account first." info "Sharkey user ID: ${SHARKEY_USER_ID}" -# ── Step 4: Inject old RSA keypair ──────────────────────────────────────────── -info "Injecting GTS RSA keypair into Sharkey..." - -HAS_KEYPAIR_TABLE=$(sudo -u postgres psql -d "$SHARKEY_DB" -tA \ - -c "SELECT to_regclass('public.user_keypair');" 2>/dev/null || echo "") - -if [[ "$HAS_KEYPAIR_TABLE" == "user_keypair" ]]; then - sudo -u postgres psql -d "$SHARKEY_DB" -c \ - "INSERT INTO user_keypair (\"userId\", \"publicKey\", \"privateKey\") - VALUES ('${SHARKEY_USER_ID}', \$pem\$${PUBLIC_KEY}\$pem\$, \$pem\$${PRIVATE_KEY}\$pem\$) - ON CONFLICT (\"userId\") DO UPDATE - SET \"publicKey\" = EXCLUDED.\"publicKey\", - \"privateKey\" = EXCLUDED.\"privateKey\";" - info "Updated user_keypair table." -else - # Older schema — keys inline on user table - sudo -u postgres psql -d "$SHARKEY_DB" -c \ - "UPDATE \"user\" - SET \"publicKey\" = \$pem\$${PUBLIC_KEY}\$pem\$, - \"privateKey\" = \$pem\$${PRIVATE_KEY}\$pem\$ - WHERE id = '${SHARKEY_USER_ID}';" - info "Updated user table (inline key columns)." -fi - -info "Restarting Sharkey..." +# ── Step 4: Inject old RSA keypair into user_keypair ───────────────────────── +# Sharkey 2025.4.6 always has the user_keypair table (UserKeypair.ts entity). +# Columns: "userId" (PK), "publicKey" varchar(4096), "privateKey" varchar(4096). +# Dollar-quoting ($pem$...$pem$) handles PEM newlines safely without escaping. +info "Injecting GTS RSA keypair into Sharkey's user_keypair table..." + +sudo -u postgres psql -d "$SHARKEY_DB" -c \ + "INSERT INTO user_keypair (\"userId\", \"publicKey\", \"privateKey\") + VALUES ( + '${SHARKEY_USER_ID}', + \$pem\$${PUBLIC_KEY}\$pem\$, + \$pem\$${PRIVATE_KEY}\$pem\$ + ) + ON CONFLICT (\"userId\") DO UPDATE + SET \"publicKey\" = EXCLUDED.\"publicKey\", + \"privateKey\" = EXCLUDED.\"privateKey\";" + +info "user_keypair updated." + +info "Restarting Sharkey to pick up the new keypair..." systemctl restart sharkey sleep 5 -systemctl is-active --quiet sharkey || error "Sharkey failed to restart." +systemctl is-active --quiet sharkey || error "Sharkey failed to restart. Check: journalctl -u sharkey -n 50" # ── Step 5: Verify ──────────────────────────────────────────────────────────── info "Verifying WebFinger..." -WF=$(curl -fsSL "https://${ACCOUNT_DOMAIN}/.well-known/webfinger?resource=acct:${GTS_USERNAME}@${ACCOUNT_DOMAIN}" 2>/dev/null || true) +WF=$(curl -fsSL \ + "https://${ACCOUNT_DOMAIN}/.well-known/webfinger?resource=acct:${GTS_USERNAME}@${ACCOUNT_DOMAIN}" \ + 2>/dev/null || true) if echo "$WF" | jq -e '.subject' &>/dev/null; then info "WebFinger OK: $(echo "$WF" | jq -r '.subject')" else - warn "WebFinger returned unexpected result — check your Vercel redirect." + warn "WebFinger probe failed — check the Vercel redirect at ewancroft.uk." fi info "Verifying actor public key..." -ACTOR=$(curl -fsSL -H 'Accept: application/activity+json' "https://${AP_HOSTNAME}/users/${GTS_USERNAME}" 2>/dev/null || true) +ACTOR=$(curl -fsSL -H 'Accept: application/activity+json' \ + "https://${AP_HOSTNAME}/users/${GTS_USERNAME}" 2>/dev/null || true) if echo "$ACTOR" | jq -e '.publicKey.publicKeyPem' &>/dev/null; then ACTOR_KEY=$(echo "$ACTOR" | jq -r '.publicKey.publicKeyPem') if [[ "$ACTOR_KEY" == "$PUBLIC_KEY" ]]; then - info "Actor public key matches GTS original. Identity preserved." + info "Actor public key matches GTS original. ✓ Identity preserved." else - warn "Public key mismatch — Sharkey may not have reloaded yet. Try: systemctl restart sharkey" + warn "Public key mismatch — Sharkey may be caching its generated key." + warn "Try: systemctl restart sharkey and re-run the verify block." fi else - warn "Could not retrieve actor JSON — Sharkey may still be starting up." + warn "Could not fetch actor JSON — Sharkey may still be starting up." fi echo "" -info "Done. Key backup retained at: ${KEY_BACKUP}" +info "Done. Key backup at: ${KEY_BACKUP}"