add the emergency relocate/reset-mfa scripts the tests exercise

This commit is contained in:
Jarek Krochmalski committed 2026-08-27 07:41:02 +02:00
1 parent d34a7733f7
commit e7ee9b41c7
7 files changed
+621

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
#!/bin/sh
#
# Shared helpers for relocate-stack-paths (SQLite + PostgreSQL variants source this).
# Contains the pure re-anchor rule and the interactive/dry-run presentation, so the
# only difference between the two DB variants is how they read/write the database.
#
# The DB variant must define, before sourcing this file:
# db_list_stack_paths -> prints one row per (compose|env) path as:
# <id>\t<field>\t<label>\t<stored_path>
# (field = "compose" or "env"; label = human name)
# db_list_external_paths -> prints one line per external_stack_paths entry:
# <index>\t<stored_path>
# db_update_stack_path <id> <field> <new_path> -> UPDATE that one column
# db_update_external_path <index> <new_path> -> replace that array entry
#
# It reads: OLD_DIR, NEW_DIR, MODE (dry|apply), APPLY_ALL (0|1).
# --- pure re-anchor rule: rewrite a stored path from the old DATA_DIR prefix to the
# new one, matching only on a full path segment (so /app/data never matches
# /app/database), with a single trailing slash stripped from each dir. ---
# strip a single trailing slash
_strip_trailing_slash() { printf '%s' "$1" | sed 's:/*$::'; }
# rewrite_under_data_dir <stored> <oldDir> <newDir>
# Prints the rewritten path, or nothing when <stored> is not under <oldDir> (segment
# boundary, so /app/data never matches /app/database) or the rewrite is a no-op.
rewrite_under_data_dir() {
stored="$1"; old="$(_strip_trailing_slash "$2")"; new="$(_strip_trailing_slash "$3")"
[ -z "$stored" ] && return 0
[ -z "$old" ] && return 0
[ "$old" = "$new" ] && return 0
if [ "$stored" = "$old" ]; then printf '%s' "$new"; return 0; fi
case "$stored" in
"$old"/*) printf '%s%s' "$new" "${stored#"$old"}" ;;
*) return 0 ;; # not under old dir - leave it
esac
}
# classify_path <stored> : echoes "STATUS\tNEWPATH\tREASON"
# WILL_UPDATE <new> "" - old missing AND new exists
# SKIP "" outside - not under the old DATA_DIR (NEWPATH is empty)
# SKIP <new> oldexists - old file still present (adopted/not moved)
# SKIP <new> newmissing - rewritten target not on disk yet
classify_path() {
stored="$1"
new="$(rewrite_under_data_dir "$stored" "$OLD_DIR" "$NEW_DIR")"
if [ -z "$new" ]; then
printf 'SKIP\t\toutside'
return 0
fi
if [ -e "$stored" ]; then
printf 'SKIP\t%s\toldexists' "$new"
return 0
fi
if [ ! -e "$new" ]; then
printf 'SKIP\t%s\tnewmissing' "$new"
return 0
fi
printf 'WILL_UPDATE\t%s\t' "$new"
}
_reason_text() {
case "$1" in
outside) printf 'outside the old DATA_DIR - left as-is' ;;
oldexists) printf 'old file still exists - not stale, left as-is' ;;
newmissing) printf 'new file not found (copy the files across first)' ;;
*) printf '%s' "$1" ;;
esac
}
# --- header ---
print_header() {
echo "========================================================================"
echo " Dockhand - Relocate stack paths ($DB_LABEL)"
echo "========================================================================"
echo ""
echo " Updates where Dockhand LOOKS FOR each stack's compose/env files in its"
echo " database, after you changed DATA_DIR."
echo ""
echo " >> It does NOT move, copy, or delete any files. Your files are untouched."
echo " It only rewrites the stored path in the database. A pointer is changed"
echo " only when the old file is gone AND the new file already exists on disk."
echo ""
echo " Old DATA_DIR: $OLD_DIR"
echo " New DATA_DIR: $NEW_DIR"
[ -n "$DB_DISPLAY" ] && echo " Database: $DB_DISPLAY"
echo ""
if [ "$MODE" = "apply" ]; then
echo " >> Make a database backup before proceeding if you want a safety net."
echo ""
fi
}
# --- main pass: build a work list, present it, and (in apply mode) apply it ---
run_relocation() {
will=0; applied=0; declined=0
# Collect WILL_UPDATE items into a temp file: <kind>\t<id-or-index>\t<field>\t<label>\t<old>\t<new>
worklist="$(mktemp)"
print_header
echo "------------------------------------------------------------------------"
# stack_sources rows
db_list_stack_paths | while IFS=' ' read -r id field label stored; do
[ -z "$stored" ] && continue
res="$(classify_path "$stored")"
status="$(printf '%s' "$res" | cut -f1)"
new="$(printf '%s' "$res" | cut -f2)"
reason="$(printf '%s' "$res" | cut -f3)"
if [ "$status" = "WILL_UPDATE" ]; then
printf 'stack\t%s\t%s\t%s\t%s\t%s\n' "$id" "$field" "$label" "$stored" "$new" >> "$worklist"
printf ' %-22s %-8s WILL UPDATE\n' "$label" "$field"
printf ' %s\n -> %s\n' "$stored" "$new"
else
printf ' %-22s %-8s SKIP (%s)\n' "$label" "$field" "$(_reason_text "$reason")"
printf ' %s\n' "$stored"
fi
done
# external_stack_paths entries
db_list_external_paths | while IFS=' ' read -r idx stored; do
[ -z "$stored" ] && continue
res="$(classify_path "$stored")"
status="$(printf '%s' "$res" | cut -f1)"
new="$(printf '%s' "$res" | cut -f2)"
reason="$(printf '%s' "$res" | cut -f3)"
if [ "$status" = "WILL_UPDATE" ]; then
# field is '-' (a placeholder) because a tab-IFS `read` collapses an EMPTY
# field and would shift every later column.
printf 'external\t%s\t-\texternal_stack_paths[%s]\t%s\t%s\n' "$idx" "$idx" "$stored" "$new" >> "$worklist"
printf ' %-22s %-8s WILL UPDATE\n' "external_stack_paths[$idx]" ""
printf ' %s\n -> %s\n' "$stored" "$new"
else
printf ' %-22s %-8s SKIP (%s)\n' "external_stack_paths[$idx]" "" "$(_reason_text "$reason")"
printf ' %s\n' "$stored"
fi
done
echo "------------------------------------------------------------------------"
will="$(wc -l < "$worklist" | tr -d ' ')"
if [ "$MODE" = "dry" ]; then
echo " Summary: $will will update."
echo ""
echo " This was a DRY RUN. Nothing was changed."
echo " To apply, re-run with --apply (review each change), or --apply --all."
echo "========================================================================"
rm -f "$worklist"
return 0
fi
if [ "$will" -eq 0 ]; then
echo " Nothing to update."
echo "========================================================================"
rm -f "$worklist"
return 0
fi
# Interactive review needs a terminal. Without one (e.g. `docker exec` with no
# -it), tell the user how to proceed instead of erroring on each prompt.
if [ "$APPLY_ALL" -ne 1 ] && [ ! -r /dev/tty ]; then
echo " No terminal available for interactive review."
echo " Re-run with 'docker exec -it ...' to confirm each change, or add --all"
echo " to apply all $will change(s) without prompting."
echo "========================================================================"
rm -f "$worklist"
return 1
fi
# apply mode. Read the work list on FD 3, not stdin, so a db_update_* helper that
# happens to touch stdin (e.g. a client that reads it) can't swallow the list.
i=0
while IFS=' ' read -r kind key field label old new <&3; do
i=$((i + 1))
show_field="$field"; [ "$show_field" = "-" ] && show_field=""
printf '\n [%s/%s] %s %s\n' "$i" "$will" "$label" "$show_field"
printf ' %s\n -> %s\n' "$old" "$new"
if [ "$APPLY_ALL" -eq 1 ]; then
ans="y"
else
printf ' Apply this change? [y/n/q] '
read -r ans </dev/tty
fi
case "$ans" in
y|Y)
if [ "$kind" = "stack" ]; then
db_update_stack_path "$key" "$field" "$new"
else
db_update_external_path "$key" "$new"
fi
applied=$((applied + 1))
echo " ... updated."
;;
q|Q)
echo " ... quit."
break
;;
*)
declined=$((declined + 1))
echo " ... skipped."
;;
esac
done 3< "$worklist"
echo ""
echo " Done. $applied applied, $declined skipped by you."
echo " Restart Dockhand to pick up the changes."
echo "========================================================================"
rm -f "$worklist"
}
# --- argument parsing shared by both variants ---
parse_relocate_args() {
OLD_DIR=""; NEW_DIR=""; MODE="dry"; APPLY_ALL=0
for arg in "$@"; do
case "$arg" in
--apply) MODE="apply" ;;
--all) APPLY_ALL=1 ;;
--*) echo "Unknown option: $arg"; exit 2 ;;
*)
if [ -z "$OLD_DIR" ]; then OLD_DIR="$arg"
elif [ -z "$NEW_DIR" ]; then NEW_DIR="$arg"
else echo "Unexpected argument: $arg"; exit 2
fi
;;
esac
done
if [ -z "$OLD_DIR" ] || [ -z "$NEW_DIR" ]; then
echo "Usage: relocate-stack-paths.sh <old-data-dir> <new-data-dir> [--apply] [--all]"
exit 2
fi
case "$OLD_DIR" in /*) ;; *) echo "Error: old-data-dir must be an absolute path"; exit 2 ;; esac
case "$NEW_DIR" in /*) ;; *) echo "Error: new-data-dir must be an absolute path"; exit 2 ;; esac
}
+75
View File
@@ -0,0 +1,75 @@
#!/bin/sh
#
# PostgreSQL: relocate stored stack paths after a DATA_DIR change.
# See ../relocate-stack-paths.sh for usage. Does NOT move files - updates DB only.
# Requires DATABASE_URL.
DB_LABEL="PostgreSQL"
if [ -z "$DATABASE_URL" ]; then
echo "Error: DATABASE_URL environment variable not set"
echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand"
exit 1
fi
if ! command -v psql >/dev/null 2>&1; then
echo "Error: psql not found. Install the postgresql-client package."
exit 1
fi
DB_DISPLAY="$(printf '%s' "$DATABASE_URL" | sed 's#://[^@]*@#://***@#')" # hide credentials
# Fail loudly on a query error rather than silently returning an empty list (which
# would read as "nothing to do"). ON_ERROR_STOP + no 2>/dev/null on reads.
_psql() { psql "$DATABASE_URL" -v ON_ERROR_STOP=1 "$@"; }
# SQL-escape for single-quoted literals.
_sql_escape() { printf "%s" "$1" | sed "s/'/''/g"; }
db_list_stack_paths() {
_psql -t -A -F ' ' -c "
SELECT s.id, 'compose',
s.stack_name || CASE WHEN e.name IS NOT NULL THEN ' (' || e.name || ')' ELSE '' END,
s.compose_path
FROM stack_sources s LEFT JOIN environments e ON e.id = s.environment_id
WHERE s.compose_path IS NOT NULL AND s.compose_path <> ''
UNION ALL
SELECT s.id, 'env',
s.stack_name || CASE WHEN e.name IS NOT NULL THEN ' (' || e.name || ')' ELSE '' END,
s.env_path
FROM stack_sources s LEFT JOIN environments e ON e.id = s.environment_id
WHERE s.env_path IS NOT NULL AND s.env_path <> ''
ORDER BY 1;
"
}
db_list_external_paths() {
# settings.value holds a JSON array as text; parse it with jsonb so commas/quotes/
# escapes inside a path are handled correctly. WITH ORDINALITY gives a 0-based index.
_psql -t -A -F ' ' -c "
SELECT ord - 1, elem
FROM settings s,
jsonb_array_elements_text(s.value::jsonb) WITH ORDINALITY AS t(elem, ord)
WHERE s.key = 'external_stack_paths';
"
}
db_update_stack_path() {
id="$1"; field="$2"; new="$(_sql_escape "$3")"
col="compose_path"; [ "$field" = "env" ] && col="env_path"
_psql -q -c "UPDATE stack_sources SET $col = '$new', updated_at = NOW() WHERE id = $id;" >/dev/null
}
db_update_external_path() {
idx="$1"; new="$(_sql_escape "$2")"
# Replace ONLY entry <idx> via jsonb_set; other entries (and any commas/escapes in
# them) stay intact. value is text, so cast to jsonb and back.
_psql -q -c "
UPDATE settings
SET value = jsonb_set(value::jsonb, ARRAY['$idx'], to_jsonb('$new'::text))::text,
updated_at = NOW()
WHERE key = 'external_stack_paths';
" >/dev/null
}
. "$(dirname "$0")/../_relocate-common.sh"
parse_relocate_args "$@"
run_relocation
+92
View File
@@ -0,0 +1,92 @@
#!/bin/sh
#
# PostgreSQL: Emergency script to disable a user's MFA (TOTP)
# Use this if a user is locked out of MFA (lost their authenticator app)
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/postgres/reset-mfa.sh <username>
#
# Example:
# docker exec -it dockhand /app/scripts/emergency/postgres/reset-mfa.sh admin
#
set -e
echo "============================================"
echo " Dockhand - Reset User MFA (PostgreSQL)"
echo "============================================"
echo ""
# Check arguments
if [ -z "$1" ]; then
echo "Usage: $0 <username>"
echo ""
echo "Example:"
echo " $0 admin"
exit 1
fi
USERNAME="$1"
# Check DATABASE_URL
if [ -z "$DATABASE_URL" ]; then
echo "Error: DATABASE_URL environment variable not set"
echo ""
echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand"
exit 1
fi
cd /app 2>/dev/null || true
# Check if user exists using psql
USER_EXISTS=$(psql "$DATABASE_URL" -t -A -c "SELECT COUNT(*) FROM users WHERE username = '$USERNAME';" 2>/dev/null)
if [ "$USER_EXISTS" = "0" ]; then
echo "Error: User '$USERNAME' not found"
echo ""
echo "Available users:"
psql "$DATABASE_URL" -t -A -c "SELECT username FROM users;" 2>/dev/null | while read user; do
echo " - $user"
done
exit 1
fi
# Show current MFA state
MFA_ENABLED=$(psql "$DATABASE_URL" -t -A -c "SELECT mfa_enabled FROM users WHERE username = '$USERNAME';" 2>/dev/null)
if [ "$MFA_ENABLED" = "t" ] || [ "$MFA_ENABLED" = "true" ]; then
STATUS="enabled"
else
STATUS="already disabled"
fi
echo "This script will disable MFA (TOTP) for user '$USERNAME'."
echo "Backup codes will also be wiped — they cannot be reused."
echo ""
echo "Database: $DATABASE_URL"
echo "Username: $USERNAME"
echo "Current MFA: $STATUS"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
echo ""
echo "Disabling MFA for user '$USERNAME'..."
psql "$DATABASE_URL" -c "UPDATE users SET mfa_enabled = false, mfa_secret = NULL, updated_at = NOW() WHERE username = '$USERNAME';"
if [ $? -eq 0 ]; then
echo ""
echo "MFA disabled successfully for user '$USERNAME'."
echo "The user can now log in with just their password and can re-enable MFA from their profile."
else
echo "Error: Failed to disable MFA"
exit 1
fi
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
#
# Emergency script: relocate stored stack paths after a DATA_DIR change.
# Automatically detects database type (SQLite or PostgreSQL).
#
# When you change DATA_DIR (e.g. move the bind mount from /app/data to a host
# path), the stack files move on disk but Dockhand's database still points at the
# OLD absolute paths, so stacks show an empty compose. This script rewrites those
# stored pointers from the old DATA_DIR to the new one.
#
# It does NOT move, copy, or delete any files - it only updates where Dockhand
# looks in the database. A pointer is rewritten only when the old file is missing
# AND the new file already exists on disk.
#
# Usage:
# relocate-stack-paths.sh <old-data-dir> <new-data-dir> # dry run (default)
# relocate-stack-paths.sh <old-data-dir> <new-data-dir> --apply # interactive apply
# relocate-stack-paths.sh <old-data-dir> <new-data-dir> --apply --all # apply all
#
# Example:
# docker exec -it dockhand /app/scripts/emergency/relocate-stack-paths.sh \
# /app/data /mnt/pool/dockhand --apply
SCRIPT_DIR="$(dirname "$0")"
if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then
exec "$SCRIPT_DIR/postgres/relocate-stack-paths.sh" "$@"
else
exec "$SCRIPT_DIR/sqlite/relocate-stack-paths.sh" "$@"
fi
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
#
# Emergency script to disable a user's MFA (TOTP)
# Automatically detects database type (SQLite or PostgreSQL)
#
# Usage:
# docker exec -it dockhand /app/scripts/reset-mfa.sh <username>
#
# Example:
# docker exec -it dockhand /app/scripts/reset-mfa.sh admin
#
SCRIPT_DIR="$(dirname "$0")"
# Detect database type
if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then
exec "$SCRIPT_DIR/postgres/reset-mfa.sh" "$@"
else
exec "$SCRIPT_DIR/sqlite/reset-mfa.sh" "$@"
fi
+72
View File
@@ -0,0 +1,72 @@
#!/bin/sh
#
# SQLite: relocate stored stack paths after a DATA_DIR change.
# See ../relocate-stack-paths.sh for usage. Does NOT move files - updates DB only.
set -e
DB_LABEL="SQLite"
# Locate the database (env override, Docker default, then local dev).
DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}"
if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then
DB_PATH="./data/db/dockhand.db"
fi
if [ ! -f "$DB_PATH" ]; then
echo "Error: SQLite database not found at $DB_PATH"
echo "Set DOCKHAND_DB to the database path."
exit 1
fi
DB_DISPLAY="$DB_PATH"
# SQL-escape a value for single-quoted string literals.
_sql_escape() { printf "%s" "$1" | sed "s/'/''/g"; }
db_list_stack_paths() {
# One line per non-null compose/env path: <id>\t<field>\t<label>\t<path>
sqlite3 -separator ' ' "$DB_PATH" "
SELECT s.id, 'compose',
s.stack_name || CASE WHEN e.name IS NOT NULL THEN ' (' || e.name || ')' ELSE '' END,
s.compose_path
FROM stack_sources s LEFT JOIN environments e ON e.id = s.environment_id
WHERE s.compose_path IS NOT NULL AND s.compose_path <> ''
UNION ALL
SELECT s.id, 'env',
s.stack_name || CASE WHEN e.name IS NOT NULL THEN ' (' || e.name || ')' ELSE '' END,
s.env_path
FROM stack_sources s LEFT JOIN environments e ON e.id = s.environment_id
WHERE s.env_path IS NOT NULL AND s.env_path <> ''
ORDER BY 1;
"
}
db_list_external_paths() {
# settings.external_stack_paths is a JSON array; emit <index>\t<path> per entry.
# Use json_each so commas/quotes/escapes inside a path are handled correctly.
sqlite3 -separator ' ' "$DB_PATH" "
SELECT je.key, je.value
FROM settings s, json_each(s.value) je
WHERE s.key = 'external_stack_paths';
" 2>/dev/null
}
db_update_stack_path() {
id="$1"; field="$2"; new="$(_sql_escape "$3")"
col="compose_path"; [ "$field" = "env" ] && col="env_path"
sqlite3 "$DB_PATH" "UPDATE stack_sources SET $col = '$new', updated_at = datetime('now') WHERE id = $id;"
}
db_update_external_path() {
idx="$1"; new="$(_sql_escape "$2")"
# Replace ONLY entry <idx> via json_set; other entries (and any commas/escapes in
# them) are left byte-for-byte intact.
sqlite3 "$DB_PATH" "
UPDATE settings
SET value = json_set(value, '\$[$idx]', '$new'), updated_at = datetime('now')
WHERE key = 'external_stack_paths';
"
}
. "$(dirname "$0")/../_relocate-common.sh"
parse_relocate_args "$@"
run_relocation
+96
View File
@@ -0,0 +1,96 @@
#!/bin/sh
#
# SQLite: Emergency script to disable a user's MFA (TOTP)
# Use this if a user is locked out of MFA (lost their authenticator app)
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/sqlite/reset-mfa.sh <username>
#
# Example:
# docker exec -it dockhand /app/scripts/emergency/sqlite/reset-mfa.sh admin
#
set -e
echo "========================================"
echo " Dockhand - Reset User MFA (SQLite)"
echo "========================================"
echo ""
# Check arguments
if [ -z "$1" ]; then
echo "Usage: $0 <username>"
echo ""
echo "Example:"
echo " $0 admin"
exit 1
fi
USERNAME="$1"
# Default database path
DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}"
# Check if running locally (not in Docker)
if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then
DB_PATH="./data/db/dockhand.db"
fi
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database not found at $DB_PATH"
echo "Set DOCKHAND_DB environment variable to specify the database path"
exit 1
fi
# Check if user exists
EXISTING=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM users WHERE username='$USERNAME';")
if [ "$EXISTING" -eq "0" ]; then
echo "Error: User '$USERNAME' not found"
echo ""
echo "Available users:"
sqlite3 "$DB_PATH" "SELECT username FROM users;" | while read user; do
echo " - $user"
done
exit 1
fi
# Show current MFA state
MFA_ENABLED=$(sqlite3 "$DB_PATH" "SELECT mfa_enabled FROM users WHERE username='$USERNAME';")
if [ "$MFA_ENABLED" = "1" ] || [ "$MFA_ENABLED" = "true" ]; then
STATUS="enabled"
else
STATUS="already disabled"
fi
echo "This script will disable MFA (TOTP) for user '$USERNAME'."
echo "Backup codes will also be wiped — they cannot be reused."
echo ""
echo "Database: $DB_PATH"
echo "Username: $USERNAME"
echo "Current MFA: $STATUS"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
echo ""
echo "Disabling MFA for user '$USERNAME'..."
sqlite3 "$DB_PATH" "UPDATE users SET mfa_enabled=0, mfa_secret=NULL, updated_at=datetime('now') WHERE username='$USERNAME';"
if [ $? -eq 0 ]; then
echo ""
echo "MFA disabled successfully for user '$USERNAME'."
echo "The user can now log in with just their password and can re-enable MFA from their profile."
else
echo "Error: Failed to disable MFA"
exit 1
fi