···11+#!/usr/bin/env bash
22+# =============================================================================
33+# cleanup-service-data.sh
44+#
55+# Removes residual on-disk data from services that have been uninstalled
66+# (i.e., disabled in NixOS config and no longer running).
77+#
88+# For each known service the script checks:
99+# 1. Whether the service unit is currently active or enabled — if so,
1010+# the service is live and its data is NEVER touched.
1111+# 2. Whether the data path(s) exist on disk.
1212+# 3. For PostgreSQL-backed services, whether the DB still exists.
1313+#
1414+# Nothing is deleted without explicit per-service confirmation.
1515+#
1616+# Run as root on the NixOS server.
1717+# =============================================================================
1818+set -euo pipefail
1919+2020+RED='\033[0;31m'
2121+GREEN='\033[0;32m'
2222+YELLOW='\033[1;33m'
2323+CYAN='\033[0;36m'
2424+BOLD='\033[1m'
2525+NC='\033[0m'
2626+2727+info() { echo -e "${GREEN}[INFO]${NC} $*"; }
2828+warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
2929+skip() { echo -e "${CYAN}[SKIP]${NC} $*"; }
3030+removed() { echo -e "${RED}[DEL]${NC} $*"; }
3131+section() { echo -e "\n${BOLD}── $* ${NC}"; }
3232+confirm() {
3333+ read -r -p "$(echo -e "${YELLOW} ${1} [y/N] ${NC}")" r
3434+ [[ "${r,,}" == "y" ]]
3535+}
3636+3737+[[ "$(id -u)" -eq 0 ]] || {
3838+ echo -e "${RED}Run as root.${NC}" >&2
3939+ exit 1
4040+}
4141+4242+DRY_RUN=false
4343+[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true && warn "Dry-run mode — nothing will be deleted."
4444+4545+# ── Helpers ───────────────────────────────────────────────────────────────────
4646+4747+# Returns 0 if the systemd service is active or enabled (i.e., still live).
4848+service_live() {
4949+ local unit="$1"
5050+ systemctl is-active --quiet "$unit" 2>/dev/null ||
5151+ systemctl is-enabled --quiet "$unit" 2>/dev/null
5252+}
5353+5454+# Removes a directory, respecting dry-run.
5555+remove_dir() {
5656+ local path="$1"
5757+ if [[ ! -d "$path" ]]; then return; fi
5858+ local size
5959+ size=$(du -sh "$path" 2>/dev/null | cut -f1 || echo "?")
6060+ if $DRY_RUN; then
6161+ warn " DRY-RUN: would remove $path ($size)"
6262+ else
6363+ rm -rf "$path"
6464+ removed "$path ($size freed)"
6565+ fi
6666+}
6767+6868+# Drops a PostgreSQL database if it exists.
6969+drop_pg_db() {
7070+ local db="$1"
7171+ local exists
7272+ exists=$(sudo -u postgres psql -tA -c \
7373+ "SELECT 1 FROM pg_database WHERE datname='${db}';" 2>/dev/null || echo "")
7474+ if [[ "$exists" != "1" ]]; then
7575+ skip " PostgreSQL DB '${db}' does not exist — nothing to drop."
7676+ return
7777+ fi
7878+ if $DRY_RUN; then
7979+ warn " DRY-RUN: would drop PostgreSQL DB '${db}'"
8080+ else
8181+ sudo -u postgres dropdb "$db"
8282+ removed " PostgreSQL DB '${db}' dropped."
8383+ fi
8484+}
8585+8686+# Drops a PostgreSQL role if it exists and has no remaining owned objects.
8787+drop_pg_role() {
8888+ local role="$1"
8989+ local exists
9090+ exists=$(sudo -u postgres psql -tA -c \
9191+ "SELECT 1 FROM pg_roles WHERE rolname='${role}';" 2>/dev/null || echo "")
9292+ if [[ "$exists" != "1" ]]; then return; fi
9393+ if $DRY_RUN; then
9494+ warn " DRY-RUN: would drop PostgreSQL role '${role}'"
9595+ else
9696+ if sudo -u postgres dropuser "$role" 2>/dev/null; then
9797+ removed " PostgreSQL role '${role}' dropped."
9898+ else
9999+ warn " Could not drop role '${role}' (may still own objects)."
100100+ fi
101101+ fi
102102+}
103103+104104+# ── Service definitions ───────────────────────────────────────────────────────
105105+# Each entry: check_unit display_name pg_db(or-) dirs...
106106+107107+declare -A SVC_LABELS=(
108108+ [gotosocial]="GoToSocial (ActivityPub)"
109109+ [sharkey]="Sharkey (ActivityPub)"
110110+ [forgejo]="Forgejo (Git forge)"
111111+ [nextcloud]="Nextcloud"
112112+ [immich - server]="Immich (photos)"
113113+ [jellyfin]="Jellyfin"
114114+ [vaultwarden]="Vaultwarden"
115115+ [bluesky - pds]="Bluesky PDS (ATProto)"
116116+ [samba - smbd]="Time Machine (Samba)"
117117+ [grafana]="Grafana"
118118+ [prometheus]="Prometheus"
119119+)
120120+121121+# unit → "pg_db:pg_role dir1 dir2 ..." (- means no PostgreSQL)
122122+declare -A SVC_DATA=(
123123+ [gotosocial]="-:/srv/gotosocial"
124124+ [sharkey]="sharkey:sharkey /srv/sharkey"
125125+ [forgejo]="-:/srv/forgejo"
126126+ [nextcloud]="nextcloud:nextcloud /srv/nextcloud"
127127+ [immich - server]="immich:immich /srv/immich"
128128+ # Jellyfin: /var/lib/jellyfin is config/metadata — the media dir
129129+ # (/srv/nextcloud/data/.../Media) is shared with Nextcloud and is NOT cleaned.
130130+ [jellyfin]="-:/var/lib/jellyfin"
131131+ [vaultwarden]="-:/srv/vaultwarden"
132132+ [bluesky - pds]="-:/srv/bluesky-pds"
133133+ [samba - smbd]="-:/srv/timemachine"
134134+ [grafana]="-:/var/lib/grafana"
135135+ [prometheus]="-:/var/lib/prometheus2"
136136+)
137137+138138+# ── Main loop ─────────────────────────────────────────────────────────────────
139139+FOUND=0
140140+141141+for unit in "${!SVC_DATA[@]}"; do
142142+ label="${SVC_LABELS[$unit]}"
143143+ entry="${SVC_DATA[$unit]}"
144144+145145+ # Parse "pg_db:pg_role dir1 dir2 ..."
146146+ pg_part="${entry%%:*}"
147147+ dirs_raw="${entry#*:}"
148148+ IFS=' ' read -r -a dirs <<<"$dirs_raw"
149149+150150+ # Determine whether any data actually exists on disk
151151+ data_present=false
152152+ for d in "${dirs[@]}"; do
153153+ [[ -d "$d" ]] && data_present=true && break
154154+ done
155155+156156+ # Also check for PostgreSQL DB if applicable
157157+ pg_present=false
158158+ pg_db=""
159159+ pg_role=""
160160+ if [[ "$pg_part" != "-" ]]; then
161161+ pg_db="${pg_part%%/*}"
162162+ pg_role="${pg_part##*/}"
163163+ # Only check if psql is available
164164+ if command -v psql &>/dev/null; then
165165+ exists=$(sudo -u postgres psql -tA -c \
166166+ "SELECT 1 FROM pg_database WHERE datname='${pg_db}';" 2>/dev/null || echo "")
167167+ [[ "$exists" == "1" ]] && pg_present=true
168168+ fi
169169+ fi
170170+171171+ $data_present || $pg_present || continue
172172+173173+ FOUND=$((FOUND + 1))
174174+ section "$label"
175175+176176+ # ── Guard: skip if service is still live ──────────────────────────────
177177+ if service_live "$unit"; then
178178+ skip "$unit is still active/enabled — skipping."
179179+ continue
180180+ fi
181181+182182+ # Show what was found
183183+ for d in "${dirs[@]}"; do
184184+ if [[ -d "$d" ]]; then
185185+ size=$(du -sh "$d" 2>/dev/null | cut -f1 || echo "?")
186186+ echo -e " ${CYAN}dir${NC} $d ($size)"
187187+ fi
188188+ done
189189+ if $pg_present; then
190190+ echo -e " ${CYAN}pg${NC} database: $pg_db role: $pg_role"
191191+ fi
192192+193193+ # ── Confirm and remove ────────────────────────────────────────────────
194194+ confirm "Remove all data for $label?" || {
195195+ skip "Skipped."
196196+ continue
197197+ }
198198+199199+ for d in "${dirs[@]}"; do
200200+ remove_dir "$d"
201201+ done
202202+203203+ if $pg_present; then
204204+ drop_pg_db "$pg_db"
205205+ drop_pg_role "$pg_role"
206206+ fi
207207+208208+ info "Cleaned up $label."
209209+done
210210+211211+# ── /root key backups from migration ──────────────────────────────────────────
212212+KEYPAIR_BACKUPS=(/root/gts-keypair-*.env)
213213+if [[ -f "${KEYPAIR_BACKUPS[0]}" ]]; then
214214+ section "GTS keypair migration backups"
215215+ for f in "${KEYPAIR_BACKUPS[@]}"; do
216216+ [[ -f "$f" ]] || continue
217217+ echo -e " ${CYAN}file${NC} $f"
218218+ done
219219+ if confirm "Remove GTS keypair backup(s) from /root?"; then
220220+ for f in "${KEYPAIR_BACKUPS[@]}"; do
221221+ [[ -f "$f" ]] || continue
222222+ if $DRY_RUN; then
223223+ warn " DRY-RUN: would remove $f"
224224+ else
225225+ rm -f "$f"
226226+ removed "$f"
227227+ fi
228228+ done
229229+ else
230230+ skip "Skipped."
231231+ fi
232232+fi
233233+234234+# ── GTS .nix.bak ──────────────────────────────────────────────────────────────
235235+BAK_DIR="$(dirname "$(realpath "$0")")"
236236+GTS_BAK="${BAK_DIR}/../modules/server/gotosocial.nix.bak"
237237+GTS_BAK="$(realpath --canonicalize-missing "$GTS_BAK")"
238238+if [[ -f "$GTS_BAK" ]]; then
239239+ section "GTS module backup"
240240+ echo -e " ${CYAN}file${NC} $GTS_BAK"
241241+ if confirm "Remove gotosocial.nix.bak?"; then
242242+ if $DRY_RUN; then
243243+ warn " DRY-RUN: would remove $GTS_BAK"
244244+ else
245245+ rm -f "$GTS_BAK"
246246+ removed "$GTS_BAK"
247247+ fi
248248+ else
249249+ skip "Skipped."
250250+ fi
251251+fi
252252+253253+# ── Summary ───────────────────────────────────────────────────────────────────
254254+echo ""
255255+if [[ "$FOUND" -eq 0 ]]; then
256256+ info "No residual service data found."
257257+else
258258+ info "Done."
259259+fi
+57-41
scripts/migrate-gts-to-sharkey.sh
···77# 2. Stopping GTS, switching to Sharkey via nixos-rebuild
88# 3. Injecting the old RSA keypair into Sharkey's PostgreSQL
99#
1010+# GTS schema (SQLite):
1111+# table: accounts
1212+# columns: private_key, public_key (PEM strings, local account has domain IS NULL)
1313+#
1414+# Sharkey schema (PostgreSQL, 2025.4.6):
1515+# table: user_keypair
1616+# columns: "userId" (PK, FK → user.id), "publicKey", "privateKey" (varchar 4096)
1717+#
1018# Run as root on the NixOS server.
1111-# Prereq: sharkey.nix written + secrets/sharkey.env encrypted + options updated.
1919+# Prereq: sharkey.nix written + secrets/sharkey.env sops-encrypted + nixos-rebuild pending.
1220# =============================================================================
1321set -euo pipefail
1422···4250 command -v "$cmd" &>/dev/null || error "Missing required command: $cmd"
4351done
44524545-# ── Step 1: Extract RSA keys ──────────────────────────────────────────────────
5353+# ── Step 1: Extract RSA keys from GTS SQLite ─────────────────────────────────
5454+# GTS bun ORM maps PrivateKey/PublicKey (*rsa.PrivateKey/*rsa.PublicKey) to
5555+# snake_case columns private_key/public_key, stored as PEM strings.
4656info "Extracting RSA keypair for @${GTS_USERNAME} from SQLite..."
47574858PRIVATE_KEY=$(sqlite3 "$GTS_DB" \
···5060PUBLIC_KEY=$(sqlite3 "$GTS_DB" \
5161 "SELECT public_key FROM accounts WHERE username='${GTS_USERNAME}' AND domain IS NULL LIMIT 1;")
52625353-[[ -n "$PRIVATE_KEY" && -n "$PUBLIC_KEY" ]] || error "Could not extract keys — check username."
6363+[[ -n "$PRIVATE_KEY" ]] || error "private_key is empty — check GTS_USERNAME and that the account is local."
6464+[[ -n "$PUBLIC_KEY" ]] || error "public_key is empty."
54655555-# Persist to a backup file in case we need to re-run step 3
6666+# Sanity-check PEM headers
6767+[[ "$PRIVATE_KEY" == *"BEGIN RSA PRIVATE KEY"* || "$PRIVATE_KEY" == *"BEGIN PRIVATE KEY"* ]] ||
6868+ error "private_key does not look like a PEM block."
6969+[[ "$PUBLIC_KEY" == *"BEGIN PUBLIC KEY"* ]] ||
7070+ error "public_key does not look like a PEM block."
7171+5672{
5773 echo "PRIVATE_KEY<<EOF"
5874 echo "$PRIVATE_KEY"
···6581info "Keys backed up to: $KEY_BACKUP"
66826783# ── Step 2: Stop GTS, rebuild with Sharkey ────────────────────────────────────
6868-warn "About to stop GoToSocial. ap.ewancroft.uk will be down until Sharkey starts."
8484+warn "About to stop GoToSocial. ap.ewancroft.uk will be offline until Sharkey starts."
6985confirm "Continue?" || {
7086 info "Aborted."
7187 exit 0
···7591systemctl stop "$GTS_SERVICE"
76927793warn "Now run: nixos-rebuild switch --flake .#server"
7878-warn "(services.gotosocial.enable = false, services.sharkey.enable = true)"
9494+warn "(myConfig.services.sharkey.enable = true in your host config)"
7995read -r -p "$(echo -e "${YELLOW}Press Enter once nixos-rebuild switch completes...${NC}")"
80968197systemctl is-active --quiet sharkey || error "Sharkey is not running. Check: journalctl -u sharkey -n 50"
8298systemctl is-active --quiet postgresql || error "PostgreSQL is not running."
8399info "Sharkey + PostgreSQL are up."
841008585-# ── Step 3: Create account in Sharkey ─────────────────────────────────────────
8686-warn "Create the @${GTS_USERNAME} account in the Sharkey admin panel now."
8787-warn " https://${AP_HOSTNAME} -> Admin -> Users -> Create"
8888-warn "Username must be: ${GTS_USERNAME}"
101101+# ── Step 3: Create the local account in Sharkey ───────────────────────────────
102102+warn "Create @${GTS_USERNAME} in the Sharkey setup wizard or admin panel:"
103103+warn " https://${AP_HOSTNAME} (first run triggers the setup wizard)"
104104+warn " Username must be exactly: ${GTS_USERNAME}"
89105read -r -p "$(echo -e "${YELLOW}Press Enter once the account exists...${NC}")"
9010691107SHARKEY_USER_ID=$(sudo -u postgres psql -d "$SHARKEY_DB" -tA \
92108 -c "SELECT id FROM \"user\" WHERE username='${GTS_USERNAME}' AND host IS NULL LIMIT 1;" 2>/dev/null || true)
109109+SHARKEY_USER_ID="${SHARKEY_USER_ID// /}" # trim whitespace psql may add
931109494-[[ -n "$SHARKEY_USER_ID" ]] || error "User @${GTS_USERNAME} not found in Sharkey DB. Create the account first."
111111+[[ -n "$SHARKEY_USER_ID" ]] || error "@${GTS_USERNAME} not found in Sharkey DB — create the account first."
95112info "Sharkey user ID: ${SHARKEY_USER_ID}"
961139797-# ── Step 4: Inject old RSA keypair ────────────────────────────────────────────
9898-info "Injecting GTS RSA keypair into Sharkey..."
114114+# ── Step 4: Inject old RSA keypair into user_keypair ─────────────────────────
115115+# Sharkey 2025.4.6 always has the user_keypair table (UserKeypair.ts entity).
116116+# Columns: "userId" (PK), "publicKey" varchar(4096), "privateKey" varchar(4096).
117117+# Dollar-quoting ($pem$...$pem$) handles PEM newlines safely without escaping.
118118+info "Injecting GTS RSA keypair into Sharkey's user_keypair table..."
99119100100-HAS_KEYPAIR_TABLE=$(sudo -u postgres psql -d "$SHARKEY_DB" -tA \
101101- -c "SELECT to_regclass('public.user_keypair');" 2>/dev/null || echo "")
120120+sudo -u postgres psql -d "$SHARKEY_DB" -c \
121121+ "INSERT INTO user_keypair (\"userId\", \"publicKey\", \"privateKey\")
122122+ VALUES (
123123+ '${SHARKEY_USER_ID}',
124124+ \$pem\$${PUBLIC_KEY}\$pem\$,
125125+ \$pem\$${PRIVATE_KEY}\$pem\$
126126+ )
127127+ ON CONFLICT (\"userId\") DO UPDATE
128128+ SET \"publicKey\" = EXCLUDED.\"publicKey\",
129129+ \"privateKey\" = EXCLUDED.\"privateKey\";"
102130103103-if [[ "$HAS_KEYPAIR_TABLE" == "user_keypair" ]]; then
104104- sudo -u postgres psql -d "$SHARKEY_DB" -c \
105105- "INSERT INTO user_keypair (\"userId\", \"publicKey\", \"privateKey\")
106106- VALUES ('${SHARKEY_USER_ID}', \$pem\$${PUBLIC_KEY}\$pem\$, \$pem\$${PRIVATE_KEY}\$pem\$)
107107- ON CONFLICT (\"userId\") DO UPDATE
108108- SET \"publicKey\" = EXCLUDED.\"publicKey\",
109109- \"privateKey\" = EXCLUDED.\"privateKey\";"
110110- info "Updated user_keypair table."
111111-else
112112- # Older schema — keys inline on user table
113113- sudo -u postgres psql -d "$SHARKEY_DB" -c \
114114- "UPDATE \"user\"
115115- SET \"publicKey\" = \$pem\$${PUBLIC_KEY}\$pem\$,
116116- \"privateKey\" = \$pem\$${PRIVATE_KEY}\$pem\$
117117- WHERE id = '${SHARKEY_USER_ID}';"
118118- info "Updated user table (inline key columns)."
119119-fi
131131+info "user_keypair updated."
120132121121-info "Restarting Sharkey..."
133133+info "Restarting Sharkey to pick up the new keypair..."
122134systemctl restart sharkey
123135sleep 5
124124-systemctl is-active --quiet sharkey || error "Sharkey failed to restart."
136136+systemctl is-active --quiet sharkey || error "Sharkey failed to restart. Check: journalctl -u sharkey -n 50"
125137126138# ── Step 5: Verify ────────────────────────────────────────────────────────────
127139info "Verifying WebFinger..."
128128-WF=$(curl -fsSL "https://${ACCOUNT_DOMAIN}/.well-known/webfinger?resource=acct:${GTS_USERNAME}@${ACCOUNT_DOMAIN}" 2>/dev/null || true)
140140+WF=$(curl -fsSL \
141141+ "https://${ACCOUNT_DOMAIN}/.well-known/webfinger?resource=acct:${GTS_USERNAME}@${ACCOUNT_DOMAIN}" \
142142+ 2>/dev/null || true)
129143if echo "$WF" | jq -e '.subject' &>/dev/null; then
130144 info "WebFinger OK: $(echo "$WF" | jq -r '.subject')"
131145else
132132- warn "WebFinger returned unexpected result — check your Vercel redirect."
146146+ warn "WebFinger probe failed — check the Vercel redirect at ewancroft.uk."
133147fi
134148135149info "Verifying actor public key..."
136136-ACTOR=$(curl -fsSL -H 'Accept: application/activity+json' "https://${AP_HOSTNAME}/users/${GTS_USERNAME}" 2>/dev/null || true)
150150+ACTOR=$(curl -fsSL -H 'Accept: application/activity+json' \
151151+ "https://${AP_HOSTNAME}/users/${GTS_USERNAME}" 2>/dev/null || true)
137152if echo "$ACTOR" | jq -e '.publicKey.publicKeyPem' &>/dev/null; then
138153 ACTOR_KEY=$(echo "$ACTOR" | jq -r '.publicKey.publicKeyPem')
139154 if [[ "$ACTOR_KEY" == "$PUBLIC_KEY" ]]; then
140140- info "Actor public key matches GTS original. Identity preserved."
155155+ info "Actor public key matches GTS original. ✓ Identity preserved."
141156 else
142142- warn "Public key mismatch — Sharkey may not have reloaded yet. Try: systemctl restart sharkey"
157157+ warn "Public key mismatch — Sharkey may be caching its generated key."
158158+ warn "Try: systemctl restart sharkey and re-run the verify block."
143159 fi
144160else
145145- warn "Could not retrieve actor JSON — Sharkey may still be starting up."
161161+ warn "Could not fetch actor JSON — Sharkey may still be starting up."
146162fi
147163148164echo ""
149149-info "Done. Key backup retained at: ${KEY_BACKUP}"
165165+info "Done. Key backup at: ${KEY_BACKUP}"