#!/usr/bin/env bash # ============================================================================= # ARTEX admin password reset script # # The login username is fixed as ARTEX; the password is stored as a bcrypt hash in # the auth.password_hash key of the settings table. This script uses pgcrypto to # generate the bcrypt hash in-database — compatible with the backend (x/crypto/bcrypt). # # Two deployments: # local (default) — host connects to the database directly with psql. Connection # info priority: args > --dsn/$ARTEX_PG_DSN > config.json database.* # docker — runs psql inside the postgres container via `docker compose # exec` (or `docker exec`) (compose does not expose 5432 by default). # # Usage examples: # ./reset-password.sh # local, auto-reads config.json/env, prompts for the new password # ./reset-password.sh -p 'New-Passphrase!' # local, new password given directly (at least 12 characters) # ./reset-password.sh --dsn postgres://u:p@h:5432/artex # ./reset-password.sh -H 127.0.0.1 -P 5433 -U autopentest -W pass -d artex # ./reset-password.sh -m docker # docker deployment (reads POSTGRES_* from .env) # ./reset-password.sh -m docker -c pg-container-name --exec docker # # Security: new password via env var + psql \getenv (never in argv), auto-escaped # with :'var' (anti-SQL-injection); DB password via PGPASSWORD, also not in argv. # ============================================================================= set -euo pipefail PASS_KEY="auth.password_hash" BCRYPT_COST=10 MODE="" # local | docker (empty = auto-detect) DSN="" HOST="" PORT="" USER="" DBPASS="" DBNAME="" SSLMODE="" CONFIG="" CONTAINER="" # postgres service/container name in docker mode (default postgres) EXEC_KIND="" # compose | docker (which exec to use in docker mode; empty = auto) NEWPASS="" ASSUME_YES=0 die() { echo "error: $*" >&2; exit 1; } info() { echo "· $*" >&2; } usage() { sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0; } # ---- Argument parsing ------------------------------------------------------- while [[ $# -gt 0 ]]; do case "$1" in -m|--mode) MODE="${2:-}"; shift 2 ;; --dsn) DSN="${2:-}"; shift 2 ;; -H|--host) HOST="${2:-}"; shift 2 ;; -P|--port) PORT="${2:-}"; shift 2 ;; -U|--user) USER="${2:-}"; shift 2 ;; -W|--db-password) DBPASS="${2:-}"; shift 2 ;; -d|--dbname) DBNAME="${2:-}"; shift 2 ;; --sslmode) SSLMODE="${2:-}"; shift 2 ;; --config) CONFIG="${2:-}"; shift 2 ;; -c|--container) CONTAINER="${2:-}"; shift 2 ;; --exec) EXEC_KIND="${2:-}"; shift 2 ;; -p|--new-password) NEWPASS="${2:-}"; shift 2 ;; -y|--yes) ASSUME_YES=1; shift ;; -h|--help) usage ;; *) die "unknown argument: $1 (see -h for usage)" ;; esac done # ---- Read database.* from config.json (local mode only, and only when no explicit connection is given) ----- # Prefer python3 for parsing (robust); fall back to grep when python3 is missing (config.json is a flat per-key file). read_config_json() { local path="$1" [[ -f "$path" ]] || return 1 if command -v python3 >/dev/null 2>&1; then python3 - "$path" <<'PY' import json, sys try: d = json.load(open(sys.argv[1])).get("database", {}) except Exception: sys.exit(1) # Supports a direct dsn, or individual fields if d.get("dsn"): print("DSN\t" + d["dsn"]); sys.exit(0) for k in ("host","port","user","password","dbname","sslmode"): if d.get(k) is not None: print(k.upper() + "\t" + str(d[k])) PY else # Minimal fallback: grep key by key (values are strings or numbers) local k for k in host port user password dbname sslmode; do local v v=$(grep -oE "\"$k\"[[:space:]]*:[[:space:]]*(\"[^\"]*\"|[0-9]+)" "$path" 2>/dev/null \ | head -1 | sed -E "s/.*:[[:space:]]*//; s/^\"//; s/\"$//") || true [[ -n "$v" ]] && echo -e "${k^^}\t$v" done fi } apply_config_fields() { local key val while IFS=$'\t' read -r key val; do [[ -z "$key" ]] && continue case "$key" in DSN) [[ -z "$DSN" ]] && DSN="$val" ;; HOST) [[ -z "$HOST" ]] && HOST="$val" ;; PORT) [[ -z "$PORT" ]] && PORT="$val" ;; USER) [[ -z "$USER" ]] && USER="$val" ;; PASSWORD) [[ -z "$DBPASS" ]] && DBPASS="$val" ;; DBNAME) [[ -z "$DBNAME" ]] && DBNAME="$val" ;; SSLMODE) [[ -z "$SSLMODE" ]] && SSLMODE="$val" ;; esac done } # ---- Auto-detect mode --------------------------------------------------------- if [[ -z "$MODE" ]]; then if [[ -n "$DSN$HOST$USER$DBNAME" || -n "${ARTEX_PG_DSN:-}" || -f "${CONFIG:-config.json}" ]]; then MODE="local" elif command -v docker >/dev/null 2>&1 && [[ -f docker-compose.yml ]]; then MODE="docker" else MODE="local" fi fi info "deployment mode: $MODE" # ---- Collect the new password ------------------------------------------------- if [[ -z "$NEWPASS" ]]; then read -r -s -p "Enter new password (username is fixed as ARTEX): " NEWPASS; echo >&2 [[ -n "$NEWPASS" ]] || die "password cannot be empty" read -r -s -p "Enter again to confirm: " NEWPASS2; echo >&2 [[ "$NEWPASS" == "$NEWPASS2" ]] || die "the two entries do not match" fi [[ -n "$NEWPASS" ]] || die "password cannot be empty" # Same minimum as the server (minPasswordRunes in server/auth.go), counted in # characters like the server: UTF-8 bytes minus continuation bytes, whatever the locale. NEWPASS_CHARS=$(printf '%s' "$NEWPASS" | LC_ALL=C tr -d '\200-\277' | wc -c) (( NEWPASS_CHARS >= 12 )) || die "password must be at least 12 characters" # Hand the password to psql via environment variable (read with \getenv, never in argv/ps) export ARTEX_RESET_NEWPASS="$NEWPASS" # Generate bcrypt in-database and upsert; the password is auto-escaped with :'newpw'. CREATE EXTENSION is idempotent; # if the database role lacks permission to create extensions it errors here (see the hint in the run failure branch below). SQL=$(cat < --dsn/$ARTEX_PG_DSN > config.json if [[ -z "$DSN" && -z "$HOST$USER$DBNAME" ]]; then [[ -n "${ARTEX_PG_DSN:-}" ]] && DSN="$ARTEX_PG_DSN" fi if [[ -z "$DSN" && -z "$HOST$USER$DBNAME" ]]; then cfg="${CONFIG:-config.json}" if [[ -f "$cfg" ]]; then info "reading database config from $cfg" apply_config_fields < <(read_config_json "$cfg") fi fi command -v psql >/dev/null 2>&1 || die "psql not found on this machine (install postgresql-client, or use -m docker instead)" declare -a PSQL_ARGS=() if [[ -n "$DSN" ]]; then PSQL_ARGS=("$DSN") target="$DSN" else [[ -n "$USER" ]] || die "missing database user (-U) or a valid config.json/DSN" [[ -n "$DBNAME" ]] || die "missing database name (-d) or a valid config.json/DSN" HOST="${HOST:-127.0.0.1}"; PORT="${PORT:-5432}"; SSLMODE="${SSLMODE:-disable}" PSQL_ARGS=(-h "$HOST" -p "$PORT" -U "$USER" -d "$DBNAME") [[ -n "$SSLMODE" ]] && export PGSSLMODE="$SSLMODE" [[ -n "$DBPASS" ]] && export PGPASSWORD="$DBPASS" target="$USER@$HOST:$PORT/$DBNAME" fi info "target database: $target" if [[ "$ASSUME_YES" -ne 1 ]]; then read -r -p "Reset the ARTEX password in this database? [y/N] " ans [[ "$ans" == "y" || "$ans" == "Y" ]] || die "cancelled" fi if ! printf '%s\n' "$SQL" | psql "${PSQL_ARGS[@]}" -v ON_ERROR_STOP=1 -q >/dev/null; then die "write failed. If it reports a pgcrypto permission/missing error, use a role allowed to create extensions, or run CREATE EXTENSION pgcrypto manually first." fi else # ---- docker ---- command -v docker >/dev/null 2>&1 || die "docker not found" CONTAINER="${CONTAINER:-postgres}" # Choose the exec method: prefer docker compose exec (service name), otherwise docker exec (container name) if [[ -z "$EXEC_KIND" ]]; then if docker compose version >/dev/null 2>&1 && [[ -f docker-compose.yml ]]; then EXEC_KIND="compose" else EXEC_KIND="docker" fi fi # psql credentials inside the container: command line first, then POSTGRES_* from .env, then compose defaults (artex) if [[ -f .env ]]; then # shellcheck disable=SC1091 set -a; . ./.env; set +a fi DUSER="${USER:-${POSTGRES_USER:-artex}}" DNAME="${DBNAME:-${POSTGRES_DB:-artex}}" [[ -n "$DBPASS" ]] && export PGPASSWORD="$DBPASS" [[ -z "${PGPASSWORD:-}" && -n "${POSTGRES_PASSWORD:-}" ]] && export PGPASSWORD="$POSTGRES_PASSWORD" info "target: psql -U $DUSER -d $DNAME inside container $CONTAINER (exec=$EXEC_KIND)" if [[ "$ASSUME_YES" -ne 1 ]]; then read -r -p "Reset the ARTEX password in this container database? [y/N] " ans [[ "$ans" == "y" || "$ans" == "Y" ]] || die "cancelled" fi # -e with a name only (no value) → inherited from the current environment, so the password never appears in the docker command argv. declare -a EXEC_CMD if [[ "$EXEC_KIND" == "compose" ]]; then EXEC_CMD=(docker compose exec -T -e ARTEX_RESET_NEWPASS -e PGPASSWORD "$CONTAINER" psql -U "$DUSER" -d "$DNAME" -v ON_ERROR_STOP=1 -q) else EXEC_CMD=(docker exec -i -e ARTEX_RESET_NEWPASS -e PGPASSWORD "$CONTAINER" psql -U "$DUSER" -d "$DNAME" -v ON_ERROR_STOP=1 -q) fi if ! printf '%s\n' "$SQL" | "${EXEC_CMD[@]}" >/dev/null; then die "write failed. Check the container name (-c), the database account (POSTGRES_* in .env), and that the role has pgcrypto permission." fi fi unset ARTEX_RESET_NEWPASS echo "✓ ARTEX admin password has been reset. Log in with username ARTEX + the new password (no service restart needed)."