#!/bin/bash # ============================================================================ # Provision the Endurain self-hosted fitness tracker. # # Idempotent: re-running keeps the existing .env (and therefore the existing # database credentials — regenerating them would orphan the Postgres volume). # # ORDERING IS LOAD-BEARING. Endurain seeds a hardcoded admin/admin account via # an Alembic migration with no env override, and endurain.kuhy.duckdns.org # already resolves publicly. So this script brings the stack up on loopback # ONLY and refuses to install the Caddy site; publishing is a separate, # deliberate step taken after the admin password has been changed. # ============================================================================ set -euo pipefail SCRIPT_NAME="$(basename "$0")" REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" readonly SCRIPT_NAME REPO_DIR readonly ENV_FILE="${REPO_DIR}/.env" readonly LOCAL_PATH="${ENDURAIN_LOCAL_PATH:-/var/opt/endurain}" readonly APP_URL="http://127.0.0.1:8085" readonly PUBLIC_HOST="endurain.kuhy.duckdns.org" log() { printf '%s\n' "$*"; } log_ok() { printf ' ok: %s\n' "$*"; } die() { printf 'Error: %s\n' "$*" >&2 exit 1 } usage() { echo "Usage: ${SCRIPT_NAME} [--recreate-env]" echo " --recreate-env Regenerate secrets. DESTRUCTIVE: orphans the existing DB." exit 0 } RECREATE_ENV=0 validate_requirements() { command -v docker >/dev/null || die "docker not installed" docker compose version >/dev/null 2>&1 || die "docker compose plugin missing" command -v openssl >/dev/null || die "openssl not installed" # Fail early rather than after the volumes are created. docker info >/dev/null 2>&1 || die "cannot talk to the docker daemon" } # Secrets are generated once and never printed to a file outside .env. write_env() { if [[ -f "${ENV_FILE}" && "${RECREATE_ENV}" -eq 0 ]]; then log_ok ".env already exists — keeping existing credentials" return fi local db_password secret_key fernet_key db_password="$(openssl rand -hex 32)" secret_key="$(openssl rand -hex 32)" fernet_key="$(openssl rand -base64 32)" umask 077 cat >"${ENV_FILE}" < 1000 (v0.19.0 removed the UID/GID env vars) # postgres -> 999 (image drops privileges internally after entrypoint) # redis -> 999 # Getting this wrong surfaces as "dependency failed to start: container # endurain-postgres is unhealthy", with the real cause (mkdir: Permission # denied) visible only in `docker logs endurain-postgres`. readonly POSTGRES_UID=999 readonly REDIS_UID=999 readonly ENDURAIN_UID=1000 prepare_data_dirs() { local d for d in "${LOCAL_PATH}/backend/data" "${LOCAL_PATH}/backend/logs" \ "${LOCAL_PATH}/postgres" "${LOCAL_PATH}/redis"; do sudo mkdir -p "${d}" done sudo chown -R "${ENDURAIN_UID}:${ENDURAIN_UID}" "${LOCAL_PATH}/backend" sudo chown -R "${POSTGRES_UID}:${POSTGRES_UID}" "${LOCAL_PATH}/postgres" sudo chown -R "${REDIS_UID}:${REDIS_UID}" "${LOCAL_PATH}/redis" log_ok "Data directories ready under ${LOCAL_PATH}" } start_stack() { docker compose -f "${REPO_DIR}/docker-compose.yml" up -d log_ok "Stack started" } wait_for_health() { for _ in $(seq 1 60); do if curl -fsS --max-time 5 "${APP_URL}/api/v1/about" >/dev/null 2>&1; then log_ok "Endurain healthy at ${APP_URL}" return 0 fi sleep 2 done die "Endurain did not become healthy within 120s. Check: docker logs endurain-app" } # Fail closed: if the seeded admin/admin still works, the service must not be # published. This is a gate, not a warning. # # The endpoint is /api/v1/auth/login with X-Client-Type: mobile. Two traps here, # both of which produce a FALSE PASS (gate says "safe" while admin/admin works): # - /api/v1/token does not exist; it falls through to the SPA index. # - Without X-Client-Type: mobile the API answers 403 "CSRF token required" # before ever checking the credentials. # So only an explicit 200 means "still vulnerable", and any unexpected status is # treated as vulnerable rather than assumed safe. check_admin_password_changed() { local code code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 \ -X POST "${APP_URL}/api/v1/auth/login" \ -H 'Content-Type: application/x-www-form-urlencoded' \ -H 'X-Client-Type: mobile' \ --data 'username=admin&password=admin' 2>/dev/null || echo 000)" case "${code}" in 401 | 403) return 0 ;; # credentials rejected — password was changed 200) return 1 ;; # seeded admin/admin still works *) log " (unexpected status ${code} from the login probe)" return 1 ;; # fail closed on anything unexpected esac } main() { validate_requirements log "=== Endurain setup ===" write_env prepare_data_dirs start_stack wait_for_health log "" if check_admin_password_changed; then log_ok "Seeded admin/admin no longer accepted" log "" log "Next: install the Caddy site to publish it, then configure the phone." log " ${REPO_DIR}/scripts/publish_endurain.sh" else log "!! ACTION REQUIRED — the service is NOT safe to publish yet." log " Endurain still accepts the seeded admin/admin login." log " 1. Open ${APP_URL} and log in as admin/admin" log " 2. Change the admin password immediately (store it in Keepass)" log " 3. Settings -> Security -> API Keys: create a key scoped" log " 'activities:upload' and save it for the importer" log " 4. Re-run this script to confirm, then publish." log "" log " The Caddy site is deliberately NOT installed while this is true:" log " https://${PUBLIC_HOST} already resolves to this host." fi } while [[ $# -gt 0 ]]; do case $1 in --recreate-env) RECREATE_ENV=1 shift ;; -h | --help) usage ;; *) die "Unknown option: $1" ;; esac done main "$@"