Ctrl K
ComunidadGuías…

install.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
#
# ProxMate installer.
#
# Brings up a ProxMate stack with Docker Compose: preflight checks, a generated
# ENCRYPTION_KEY, a correct .env for either an HTTP trial or a real HTTPS domain,
# and the build. It stops at the browser setup wizard on purpose — the Proxmox
# API token is entered there, never on a command line where it would land in
# shell history or a process list.
#
# This script is meant to be READ before it is run. It is deliberately not a
# `curl | sudo bash` one-liner: ProxMate ends up holding a Proxmox token that is
# effectively root on your cluster, so piping it into a root shell is the wrong
# habit for exactly this software.
#
#   curl -fsSLO https://raw.githubusercontent.com/r0073d-l053r/ProxMate/main/install.sh
#   less install.sh
#   bash install.sh
#
# Missing dependencies (Docker, Compose v2, git, curl, openssl) are detected and
# — with your explicit consent — installed for you. They are not optional:
# ProxMate is a containerised application and will not run without them, so
# declining simply stops the install without changing anything on the machine.
#
# Usage:
#   bash install.sh                            # interactive
#   bash install.sh --local                    # HTTP on this machine's LAN address
#   bash install.sh --local --host 10.0.0.5    # HTTP on an address you choose
#   bash install.sh --domain proxmate.example.com
#   bash install.sh --domain x --dir /opt/proxmate --ref v0.8.6 --yes
#
# Flags:
#   --local            HTTP, no reverse proxy. For a trial on a trusted network.
#   --host <addr>      With --local: the address you will type in the browser.
#                      Defaults to this machine's primary LAN IP. Use "localhost"
#                      only if you browse from this same machine.
#   --domain <host>    One HTTPS origin you will front with a reverse proxy.
#   --dir <path>       Where to install (default: ./proxmate, or the checkout you
#                      are already standing in).
#   --ref <git-ref>    Tag/branch to check out (default: newest vX.Y.Z tag).
#   --no-start         Write everything but do not build or start.
#   --yes, -y          Never prompt; requires --local or --domain. Also counts as
#                      consent to install any missing dependencies listed above.
#   --help, -h         This message.
#
# ── end of help text (usage() prints the block above) ────────────────────────
set -euo pipefail

REPO_URL="${PROXMATE_REPO_URL:-https://github.com/r0073d-l053r/ProxMate.git}"
HELP_LAST_LINE=44            # keep in sync with the marker line above
INSTALL_DIR=""
MODE=""
DOMAIN=""
HTTP_HOST=""
GIT_REF=""
ASSUME_YES=0
DO_START=1
ADOPTED_CWD=0
TMP_ENV=""

# ── output ────────────────────────────────────────────────────────────────────
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
  C_RESET=$'\033[0m'; C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'
  C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[34m'
else
  C_RESET=""; C_DIM=""; C_BOLD=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""
fi

step() { printf '%s==>%s %s\n' "$C_BLUE$C_BOLD" "$C_RESET$C_BOLD" "$*$C_RESET"; }
ok()   { printf '  %s+%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
warn() { printf '  %s!%s %s\n' "$C_YELLOW" "$C_RESET" "$*"; }
info() { printf '  %s%s%s\n' "$C_DIM" "$*" "$C_RESET"; }
die()  { printf '\n%serror:%s %s\n' "$C_RED$C_BOLD" "$C_RESET" "$*" >&2; exit 1; }

# A stray temp .env must never be left holding a freshly generated key.
cleanup() { [ -n "$TMP_ENV" ] && [ -f "$TMP_ENV" ] && rm -f "$TMP_ENV"; return 0; }
trap cleanup EXIT INT TERM

usage() { sed -n "2,${HELP_LAST_LINE}p" "$0" | sed 's/^# \{0,1\}//; s/^#$//'; exit 0; }

# ── args ──────────────────────────────────────────────────────────────────────
# Keep a pristine copy: the loop below shifts every argument away, and the
# docker-group re-exec much further down has to hand the ORIGINAL arguments to
# the new shell. Reading "$@" down there yields nothing and the re-invoked script
# then dies asking for the --local/--domain you already gave it.
ORIG_ARGV=("$@")

while [ $# -gt 0 ]; do
  case "$1" in
    --local)    MODE="local"; shift ;;
    --host)     [ $# -ge 2 ] || die "--host needs an address"
                HTTP_HOST="$2"; shift 2 ;;
    --domain)   [ $# -ge 2 ] || die "--domain needs a hostname"
                MODE="domain"; DOMAIN="$2"; shift 2 ;;
    --dir)      [ $# -ge 2 ] || die "--dir needs a path"
                INSTALL_DIR="$2"; shift 2 ;;
    --ref)      [ $# -ge 2 ] || die "--ref needs a git ref"
                GIT_REF="$2"; shift 2 ;;
    --no-start) DO_START=0; shift ;;
    -y|--yes)   ASSUME_YES=1; shift ;;
    -h|--help)  usage ;;
    *)          die "unknown option: $1  (try --help)" ;;
  esac
done

# A registrable hostname — not a URL, not a host:port, not a bare IP. It is baked
# into the frontend bundle AND used as the WebAuthn RP ID, where a port or an IP
# literal is invalid and silently breaks passkey registration.
validate_domain() {
  case "$1" in
    "")                 die "--domain needs a hostname, e.g. proxmate.example.com" ;;
    *[!a-zA-Z0-9.-]*)   die "domain may only contain letters, digits, dots and hyphens (got: $1)
  Drop any scheme, port or path — pass just the hostname." ;;
    *.*)                : ;;
    *)                  die "that doesn't look like a domain: $1" ;;
  esac
  case "$1" in
    *[0-9].[0-9]*)
      # 1.2.3.4 style. Passkeys require a real domain, so flag it rather than
      # letting registration fail later with an opaque browser error.
      if printf '%s' "$1" | grep -qE '^[0-9]+(\.[0-9]+){3}$'; then
        die "--domain needs a DNS name, not an IP address ($1).
  HTTPS certificates and passkeys both require a real hostname.
  For an IP-based trial use:  --local --host $1"
      fi ;;
  esac
}

# ── 1. preflight ──────────────────────────────────────────────────────────────
step "Checking prerequisites"

have() { command -v "$1" >/dev/null 2>&1; }

# ── dependency resolution ────────────────────────────────────────────────────
# ProxMate cannot run without these. Rather than telling you to go and read three
# other install guides, we offer to install them — but only ever after you say
# yes, and never by piping a downloaded script straight into a root shell.

SUDO=""
if [ "$(id -u)" -ne 0 ]; then
  if have sudo; then SUDO="sudo"; fi
fi

# Who is really doing this. Bash does NOT set $USER — login/PAM/sshd/su do — so
# under `env -i`, cron, or a non-PAM context it is unset, and an unbound-variable
# error under `set -u` is NOT caught by `|| die`; the script would abort with a raw
# bash error as its entire output. Process credentials always exist.
RUN_USER="$(id -un)"
# Under `sudo bash install.sh` the invoking human is SUDO_USER, not root. Without
# this the checkout, the mode-600 .env and ./backups all end up root-owned and the
# human is never added to the docker group — see chown_to_invoker below.
REAL_USER="${SUDO_USER:-$RUN_USER}"
REAL_UID="${SUDO_UID:-$(id -u)}"
REAL_GID="${SUDO_GID:-$(id -g)}"

# usermod/addgroup live in /usr/sbin, which is NOT on a normal user's PATH on
# several distros (and is stripped entirely by cron and `env -i`). Plain
# `command -v usermod` therefore reports "missing" on a machine that has it, and
# the caller then prints Alpine advice to a Fedora user. Resolve the real path.
find_admin_tool() {
  command -v "$1" 2>/dev/null && return 0
  for _d in /usr/sbin /sbin /usr/local/sbin; do
    [ -x "$_d/$1" ] && { printf '%s' "$_d/$1"; return 0; }
  done
  return 1
}

# Hand anything we created back to the human when we are root via sudo. Silent
# no-op in every other case.
chown_to_invoker() {
  [ "$(id -u)" -eq 0 ] || return 0
  [ -n "${SUDO_USER:-}" ] || return 0
  [ -e "$1" ] || return 0
  chown -R "$REAL_UID:$REAL_GID" "$1" 2>/dev/null || true
}

DISTRO_ID=""
[ -r /etc/os-release ] && DISTRO_ID="$( . /etc/os-release 2>/dev/null && printf '%s' "${ID:-}" )"

PKG_MGR=""
detect_pkg_mgr() {
  if   have apt-get; then PKG_MGR="apt"
  elif have dnf;     then PKG_MGR="dnf"
  elif have yum;     then PKG_MGR="yum"
  elif have pacman;  then PKG_MGR="pacman"
  elif have zypper;  then PKG_MGR="zypper"
  elif have apk;     then PKG_MGR="apk"
  fi
}
detect_pkg_mgr

pkg_install() {
  # $@ = package names, already translated for this distro
  case "$PKG_MGR" in
    # `VAR=x sudo cmd` sets the variable for SUDO, and sudo's default env_reset
    # throws it away before cmd ever sees it. It has to go through `sudo env`.
    apt)    $SUDO apt-get update -qq \
              && $SUDO env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
                   apt-get install -y -qq "$@" ;;
    dnf)    $SUDO dnf install -y -q "$@" ;;
    yum)    $SUDO yum install -y -q "$@" ;;
    # Deliberately NOT `pacman -Sy <pkg>`: refreshing the sync DB without
    # upgrading resolves dependencies against versions the host doesn't have —
    # the partial-upgrade state Arch declares unsupported. Adding -u is no better:
    # it would turn this into an unattended full system upgrade of someone else's
    # machine. So on Arch we ask rather than act (see the caller).
    zypper) $SUDO zypper --non-interactive install "$@" ;;
    apk)    $SUDO apk add --no-cache "$@" ;;
    *)      return 1 ;;
  esac
}

# get.docker.com is Docker's own installer and the method Docker documents, but it
# only dispatches on a handful of distro IDs — arch, opensuse-*, alpine and
# almalinux all hit its "Unsupported distribution" exit. Those distros package
# Docker and the compose plugin themselves, so use that instead of sending the
# user into an installer that cannot work.
#
# Where get.docker.com IS used it is downloaded to a file and the path printed
# first, never piped into a root shell — the habit this project refuses to teach,
# and it matters more here than usual because ProxMate ends up holding a Proxmox
# token that is effectively root on your cluster.
install_docker() {
  case "$PKG_MGR" in
    pacman)
      warn "on Arch, installing packages without a full system upgrade is unsupported."
      die "Please run:  sudo pacman -Syu docker docker-compose
  then start it with:  sudo systemctl enable --now docker
  and run this script again." ;;
    zypper)
      $SUDO zypper --non-interactive install docker docker-compose || \
        die "could not install Docker from the openSUSE repositories."
      $SUDO systemctl enable --now docker || warn "could not start the docker service (see above)"
      return 0 ;;
    apk)
      $SUDO apk add --no-cache docker docker-cli-compose || \
        die "could not install Docker from the Alpine repositories."
      # rc-update, not just rc-service: without it Docker does not survive a reboot
      # and the stack silently fails to come back.
      $SUDO rc-update add docker default 2>/dev/null || true
      $SUDO rc-service docker start || warn "could not start the docker service (see above)"
      return 0 ;;
  esac

  case "$DISTRO_ID" in
    ubuntu|debian|raspbian|centos|fedora|rhel|rocky) : ;;
    *)
      die "Docker's official installer does not support '${DISTRO_ID:-this system}'.
  Install Docker Engine and the Compose v2 plugin from your distribution's
  repositories, then run this script again:
      https://docs.docker.com/engine/install/" ;;
  esac

  local script
  # The six X's MUST be the final characters: busybox/musl mktemp rejects a
  # trailing suffix with EINVAL, which under `set -e` would abort the script with
  # no message at all, right after packages were installed.
  script="$(mktemp "${TMPDIR:-/tmp}/get-docker.XXXXXX")" \
    || die "could not create a temporary file in ${TMPDIR:-/tmp}"
  info "downloading Docker's official installer to $script"
  if ! curl -fsSL https://get.docker.com -o "$script"; then
    rm -f "$script"
    die "could not download https://get.docker.com — check network/DNS, or install
  Docker yourself: https://docs.docker.com/engine/install/"
  fi
  info "running it (you can read it at $script first if you'd rather)"
  if ! $SUDO sh "$script"; then
    rm -f "$script"
    die "Docker's installer failed — its own output is above.
  Install Docker manually and re-run this script:
  https://docs.docker.com/engine/install/"
  fi
  rm -f "$script"

  # get.docker.com starts and enables the service on systemd distros; on others
  # (WSL, systemd-less containers, some VMs) it may not be running yet. Keep the
  # `|| warn` — letting `set -e` abort here would give zero output — but do NOT
  # discard stderr, or the reason is lost and the user gets a generic message.
  if have systemctl; then
    $SUDO systemctl enable --now docker || warn "could not enable/start the docker service (see above) — continuing"
  elif have rc-service; then
    $SUDO rc-service docker start || warn "could not start the docker service (see above) — continuing"
  fi
}

# Work out what's actually missing before asking for anything.
MISSING_LABELS=""
NEED_DOCKER=0
NEED_COMPOSE=0
NEED_PKGS=""

if ! have docker; then
  # Both are listed even though one install brings both, so that anyone who
  # declines and goes off to do it by hand knows the compose plugin is required
  # too — installing only the engine is a common and confusing way to get stuck.
  NEED_DOCKER=1
  MISSING_LABELS="$MISSING_LABELS
    Docker engine        runs ProxMate's two containers
    Docker Compose v2    builds and orchestrates them (ships with current Docker)"
elif ! docker compose version >/dev/null 2>&1; then
  NEED_COMPOSE=1
  MISSING_LABELS="$MISSING_LABELS
    Docker Compose v2    builds and orchestrates them ('docker compose', not 'docker-compose')"
fi

have git || { NEED_PKGS="$NEED_PKGS git"; MISSING_LABELS="$MISSING_LABELS
    git                  fetches the ProxMate source"; }

have curl || { NEED_PKGS="$NEED_PKGS curl"; MISSING_LABELS="$MISSING_LABELS
    curl                 downloads what's needed"; }

# Either of these can generate the key; only ask for openssl if neither exists.
if ! have openssl && ! { [ -r /dev/urandom ] && have od; }; then
  NEED_PKGS="$NEED_PKGS openssl"
  MISSING_LABELS="$MISSING_LABELS
    openssl              generates your ENCRYPTION_KEY"
fi

if [ -n "$MISSING_LABELS" ]; then
  echo
  warn "ProxMate needs some things this machine doesn't have yet:"
  printf '%s\n' "$MISSING_LABELS"
  echo
  info "These are required — ProxMate is a containerised application and cannot"
  info "run without them. Nothing here is optional or cosmetic."
  echo

  if [ -z "$SUDO" ] && [ "$(id -u)" -ne 0 ]; then
    die "installing these needs root, but 'sudo' isn't available and you aren't root.
  Install them yourself, or re-run this script as root:
      Docker  → https://docs.docker.com/engine/install/"
  fi
  if [ "$NEED_DOCKER" -eq 0 ] && [ -n "$NEED_PKGS" ] && [ -z "$PKG_MGR" ]; then
    die "couldn't recognise this system's package manager (tried apt, dnf, yum,
  pacman, zypper, apk). Install these yourself and re-run:$NEED_PKGS"
  fi

  _agreed=0
  if [ "$ASSUME_YES" -eq 1 ]; then
    _agreed=1
    info "--yes given; installing them."
  elif [ ! -t 0 ]; then
    die "these need to be installed, but this isn't an interactive terminal so I
  can't ask. Re-run with --yes to install them without prompting, or install
  them yourself first."
  else
    while :; do
      printf '  Install them now? [y/N]: '
      read -r _ans || _ans=""
      case "$_ans" in
        y|Y|yes|YES) _agreed=1; break ;;
        n|N|no|NO|"") _agreed=0; break ;;
        *) warn "please answer y or n." ;;
      esac
    done
  fi

  if [ "$_agreed" -ne 1 ]; then
    echo
    step "Stopping here"
    info "ProxMate can't be installed without those. Nothing has been changed on"
    info "this machine — no files written, no packages installed."
    echo
    info "Install them yourself and run this script again:"
    info "    Docker + Compose v2  → https://docs.docker.com/engine/install/"
    [ -n "$NEED_PKGS" ] && info "   $NEED_PKGS  → your system's package manager"
    echo
    exit 1
  fi

  # `have sudo` only proves the binary exists, not that this user may use it.
  # Without this check someone outside sudoers consents, watches a package manager
  # start, and only then discovers they were never allowed — after we have already
  # said "installing". Ask sudo to authenticate first so the refusal is immediate
  # and unambiguous. This is also where a password prompt legitimately appears.
  if [ -n "$SUDO" ]; then
    if ! sudo -n true 2>/dev/null; then
      info "these need administrator rights — sudo will ask for your password"
    fi
    $SUDO -v || die "sudo is installed, but $RUN_USER isn't authorised to use it on this
  machine (its reason is above). Ask an administrator to install these, or run
  this script as root."
  fi

  echo
  if [ -n "$NEED_PKGS" ]; then
    step "Installing:$NEED_PKGS"
    # Check the OUTCOME, not the exit status. zypper reserves 100-106 for
    # informational results — 106 means "a repo failed to refresh but the
    # transaction succeeded" — so trusting the code tells someone their package
    # failed while they watch it install fine.
    # shellcheck disable=SC2086
    pkg_install $NEED_PKGS || true
    for _p in $NEED_PKGS; do
      have "$_p" || die "could not install: $_p
  Its package manager's output is above. Install it yourself and re-run this script."
    done
    ok "installed:$NEED_PKGS"
  fi
  if [ "$NEED_DOCKER" -eq 1 ] || [ "$NEED_COMPOSE" -eq 1 ]; then
    step "Installing Docker"
    install_docker
    have docker || die "Docker still isn't on PATH after installing. Open a new
  shell and re-run this script."
    docker compose version >/dev/null 2>&1 || die \
      "Docker installed, but Compose v2 is missing. Install the compose plugin:
  https://docs.docker.com/compose/install/linux/"
    ok "Docker installed"
  fi
fi

# Versions alone don't prove access: a user outside the docker group gets
# "permission denied" here, which is the most common first-run failure — and it
# is guaranteed immediately after a fresh install, because group membership only
# applies to new sessions.
if ! docker ps >/dev/null 2>&1; then
  if [ "$(id -u)" -ne 0 ] && [ -n "$SUDO" ] && ! id -nG 2>/dev/null | tr ' ' '\n' | grep -qx docker; then
    warn "your user isn't in the 'docker' group yet — adding it"
    # busybox (Alpine) ships adduser/addgroup, not the shadow suite, so usermod
    # may not exist. Without this the script dies advising the very command that
    # just failed with "not found".
    if _um="$(find_admin_tool usermod)"; then
      $SUDO "$_um" -aG docker "$RUN_USER" || die "could not add $RUN_USER to the 'docker' group — the reason is above.
  Do it yourself, start a new session, and re-run this script:
      sudo usermod -aG docker $RUN_USER"
    elif _ag="$(find_admin_tool addgroup)"; then
      $SUDO "$_ag" "$RUN_USER" docker || die "could not add $RUN_USER to the 'docker' group — the reason is above.
  Do it yourself, start a new session, and re-run this script:
      sudo addgroup $RUN_USER docker"
    else
      die "neither 'usermod' nor 'addgroup' was found, so $RUN_USER cannot be added
  to the 'docker' group automatically. On Alpine:  sudo apk add shadow
  Then:  sudo usermod -aG docker $RUN_USER  — and re-run this script."
    fi
    # Group membership only takes effect in a new session. Re-exec through `sg`
    # so this run can continue without making you log out and back in.
    if have sg && [ -z "${PROXMATE_REEXEC:-}" ]; then
      ok "added — continuing in a shell that has the new group"
      export PROXMATE_REEXEC=1
      # Re-invoke through bash explicitly: $0 is usually a bare relative path
      # like "install.sh", which is neither executable nor on PATH, so handing
      # it to `sg -c` directly would just fail with "command not found".
      # ORIG_ARGV, not "$@" — the parser above already consumed the arguments.
      exec sg docker -c "bash $(printf '%q ' "$0" ${ORIG_ARGV[@]+"${ORIG_ARGV[@]}"})"
    fi
    if have newgrp; then
      die "added $RUN_USER to the 'docker' group. Log out and back in (or run
  'newgrp docker'), then run this script again — it will pick up where it left off."
    fi
    die "added $RUN_USER to the 'docker' group. Log out and back in, then run this
  script again — it will pick up where it left off."
  fi
  die "cannot talk to the Docker daemon.
  Is it running?   ${SUDO:+sudo }systemctl start docker"
fi
ok "docker daemon reachable"
ok "docker compose $(docker compose version --short 2>/dev/null || echo v2)"

# Running as root (directly, or via `sudo bash install.sh`) means `docker ps`
# above succeeded and the whole group-repair branch was skipped — so the human
# behind the sudo never gets docker access, and afterwards has to prefix every
# `docker compose logs` with sudo in a directory they may not even be able to
# read. Add them here instead.
if [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ] && [ "$REAL_USER" != "root" ]; then
  if ! id -nG "$REAL_USER" 2>/dev/null | tr ' ' '\n' | grep -qx docker; then
    if _rum="$(find_admin_tool usermod)" && "$_rum" -aG docker "$REAL_USER" 2>/dev/null; then
      ok "added $REAL_USER to the 'docker' group (takes effect in a new session)"
    elif _rag="$(find_admin_tool addgroup)" && "$_rag" "$REAL_USER" docker 2>/dev/null; then
      ok "added $REAL_USER to the 'docker' group (takes effect in a new session)"
    else
      warn "couldn't add $REAL_USER to the 'docker' group — they'll need sudo for docker commands"
    fi
  fi
fi

if have openssl; then
  gen_key() { openssl rand -hex 32; }
elif [ -r /dev/urandom ] && have od; then
  gen_key() { od -vAn -N32 -tx1 < /dev/urandom | tr -d ' \n'; }
else
  die "need either openssl or /dev/urandom to generate an encryption key."
fi
ok "can generate an encryption key"

# The Next.js production build is the memory-hungry step; on a 1 GB box it gets
# OOM-killed and surfaces as an opaque compose failure.
if [ -r /proc/meminfo ]; then
  mem_kb=$(awk '/^MemTotal:/{print $2}' /proc/meminfo 2>/dev/null || echo 0)
  if [ "${mem_kb:-0}" -gt 0 ] && [ "$mem_kb" -lt 1900000 ]; then
    warn "only $((mem_kb / 1024)) MB RAM — the frontend build may be OOM-killed."
    info "add swap, or build elsewhere and pull the image."
  fi
fi
if have df; then
  free_kb=$(df -Pk . 2>/dev/null | awk 'NR==2{print $4}' || echo 0)
  if [ "${free_kb:-0}" -gt 0 ] && [ "$free_kb" -lt 5000000 ]; then
    # Integer division to GB reports "0 GB" for anything under a gigabyte, which
    # hides exactly the cases worth warning about. Switch units below 1 GB.
    if [ "$free_kb" -lt 1048576 ]; then
      warn "only $((free_kb / 1024)) MB free in $(pwd) — images need roughly 5 GB."
    else
      warn "only $((free_kb / 1048576)) GB free in $(pwd) — images need roughly 5 GB."
    fi
    info "that is this directory's filesystem; pass --dir to install somewhere else."
  fi
fi

# ── 2. mode ───────────────────────────────────────────────────────────────────
primary_ip() {
  # The address a LAN client would actually reach this host on.
  if have ip; then
    ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' && return 0
  fi
  if have hostname; then
    hostname -I 2>/dev/null | awk '{print $1}' && return 0
  fi
  return 0
}

if [ -z "$MODE" ]; then
  [ "$ASSUME_YES" -eq 1 ] && die "--yes requires --local or --domain."
  [ -t 0 ] || die "not a terminal; pass --local or --domain <host>."
  echo
  step "How will you reach ProxMate?"
  info "1) HTTP on this machine's address — quick trial on a trusted network"
  info "2) HTTPS on a domain, behind your reverse proxy — real deployment"
  # A typo should re-ask, not throw away everything typed so far.
  while :; do
    printf '  choice [1/2]: '
    read -r _choice || die "no input."
    case "$_choice" in
      1) MODE="local"; break ;;
      2) MODE="domain"
         while [ -z "$DOMAIN" ]; do
           printf '  domain (e.g. proxmate.example.com): '
           read -r DOMAIN || die "no input."
         done
         break ;;
      *) warn "please type 1 or 2." ;;
    esac
  done
fi

if [ "$MODE" = "domain" ]; then
  validate_domain "$DOMAIN"
  ORIGIN="https://$DOMAIN"
else
  if [ -z "$HTTP_HOST" ]; then
    HTTP_HOST="$(primary_ip || true)"
    HTTP_HOST="${HTTP_HOST:-localhost}"
  fi
  case "$HTTP_HOST" in
    *[!a-zA-Z0-9.:-]*) die "--host may only contain letters, digits, dots, colons and hyphens (got: $HTTP_HOST)" ;;
  esac
  ORIGIN="http://$HTTP_HOST:3000"
  API_ORIGIN="http://$HTTP_HOST:4000"
fi

# ── 3. source tree ────────────────────────────────────────────────────────────
echo
step "Getting the ProxMate source"

if [ -z "$INSTALL_DIR" ] && [ -f docker-compose.yml ] && [ -d backend ] && [ -d frontend ]; then
  INSTALL_DIR="$PWD"
  ADOPTED_CWD=1
  ok "using the checkout you're standing in: $INSTALL_DIR"
else
  INSTALL_DIR="${INSTALL_DIR:-$PWD/proxmate}"
  if [ -d "$INSTALL_DIR/.git" ]; then
    ok "reusing existing checkout: $INSTALL_DIR"
  elif [ -e "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null || true)" ]; then
    die "$INSTALL_DIR exists and is not empty. Pass --dir <somewhere-else>."
  else
    info "cloning $REPO_URL"
    git clone --quiet "$REPO_URL" "$INSTALL_DIR" || die "clone failed."
    # Under `sudo bash install.sh` this would otherwise be root-owned, leaving the
    # human unable to read their own .env or run the compose commands we print.
    chown_to_invoker "$INSTALL_DIR"
    ok "cloned into $INSTALL_DIR"
  fi
fi

cd "$INSTALL_DIR"
[ -f docker-compose.yml ] || die "docker-compose.yml not found in $INSTALL_DIR — wrong directory?"

# Fetch before resolving a ref, or a pin resolves against stale local tags.
git fetch --tags --quiet 2>/dev/null || warn "could not fetch updates; using the refs already on disk"

# Standing in someone's working copy is their choice — silently detaching HEAD
# off their branch is not. Only auto-pin a checkout this script created.
if [ "$ADOPTED_CWD" -eq 1 ] && [ -z "$GIT_REF" ]; then
  info "leaving your checkout on $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD) (pass --ref to pin)"
else
  [ -n "$GIT_REF" ] || GIT_REF="$(git tag --list 'v[0-9]*' --sort=-v:refname 2>/dev/null | head -n1 || true)"
  if [ -n "$GIT_REF" ]; then
    # Resolve local refs, then remote-only branches, so --ref main works after a
    # fresh clone where "main" may exist only as origin/main.
    target=""
    if git rev-parse --verify --quiet "refs/tags/$GIT_REF" >/dev/null 2>&1 \
       || git rev-parse --verify --quiet "$GIT_REF" >/dev/null 2>&1; then
      target="$GIT_REF"
    elif git rev-parse --verify --quiet "origin/$GIT_REF" >/dev/null 2>&1; then
      target="origin/$GIT_REF"
    else
      # Never silently install an unintended revision.
      die "git ref '$GIT_REF' does not exist in $REPO_URL.
  Available releases:
$(git tag --list 'v[0-9]*' --sort=-v:refname 2>/dev/null | head -5 | sed 's/^/      /')"
    fi
    if [ -n "$(git status --porcelain 2>/dev/null || true)" ]; then
      warn "local changes present — not checking out $target"
    else
      git checkout --quiet "$target" || die "could not check out $target"
      ok "checked out $GIT_REF"
    fi
  fi
fi

# ── 4. .env ───────────────────────────────────────────────────────────────────
echo
step "Writing configuration"

env_value() {
  # Last assignment wins, matching how docker compose reads the file.
  grep -E "^[[:space:]]*$1=" .env 2>/dev/null | tail -n1 | sed "s/^[^=]*=//; s/^[\"']//; s/[\"']$//; s/[[:space:]]*$//" || true
}

# The compose file pins the project name, so every install on this host shares
# one database volume no matter which directory it was installed from. A fresh
# key generated here (lost .env, or a new --dir) would come up against that old
# database with setup already complete and every stored secret undecryptable —
# so refuse, and let the operator choose deliberately.
DB_VOLUME="${COMPOSE_PROJECT_NAME:-proxmate}_proxmate-data"
refuse_new_key_over_existing_db() {
  docker volume inspect "$DB_VOLUME" >/dev/null 2>&1 || return 0
  die "a ProxMate database already exists on this host (Docker volume $DB_VOLUME),
  but this checkout has no ENCRYPTION_KEY for it. A new key cannot decrypt anything
  stored in that database (Proxmox token, SMTP password, TOTP secrets), so the stack
  would come up broken.

  Either put the ORIGINAL key back: copy ENCRYPTION_KEY from the old install's .env
  (or your off-host backup of it) into $(pwd)/.env and re-run this script;
  or, only if that database is disposable, delete it first — this destroys it:
      docker volume rm $DB_VOLUME"
}

if [ -f .env ]; then
  ok ".env already exists — keeping it (your ENCRYPTION_KEY is preserved)"

  existing_key="$(env_value ENCRYPTION_KEY)"
  if [ -z "$existing_key" ]; then
    refuse_new_key_over_existing_db
    warn "ENCRYPTION_KEY is empty; generating one"
    KEY="$(gen_key)"
    # Temp file in the SAME directory so the rename is atomic and cannot land on
    # another filesystem. grep exits 1 when it selects no lines (an .env that is
    # empty, or only the blank key line) — that is the expected case here, so
    # only a real grep error (2) is fatal.
    TMP_ENV="$(mktemp .env.XXXXXX)"
    chmod 600 "$TMP_ENV"
    { grep -v '^[[:space:]]*ENCRYPTION_KEY=' .env || [ $? -eq 1 ]; } > "$TMP_ENV" \
      || die "could not read .env"
    printf 'ENCRYPTION_KEY=%s\n' "$KEY" >> "$TMP_ENV"
    mv "$TMP_ENV" .env || die "could not update .env"
    TMP_ENV=""
    unset KEY
    ok "filled in a fresh 64-hex ENCRYPTION_KEY"
  elif ! printf '%s' "$existing_key" | grep -qE '^[0-9a-fA-F]{64}$'; then
    die "the ENCRYPTION_KEY in .env is not 64 hex characters.
  The backend fails closed on an invalid key. Fix it in .env, or if this is a
  fresh install with no data yet, delete .env and re-run to generate a valid one."
  fi

  # NEXT_PUBLIC_* are frontend BUILD ARGS baked into the bundle. Rebuilding over a
  # stale .env would serve a UI that calls the OLD origin while step 7 advertises
  # the new one — and FRONTEND_URL is the backend's CORS allow-list, so it would
  # reject the new origin too. Neither is fixable after the fact, so stop.
  have_origin="$(env_value NEXT_PUBLIC_SITE_URL)"
  have_origin="${have_origin%/}"
  if [ -n "$have_origin" ] && [ "$have_origin" != "$ORIGIN" ]; then
    die "this .env is configured for $have_origin, but you asked for $ORIGIN.
  NEXT_PUBLIC_API_URL / NEXT_PUBLIC_SITE_URL are baked into the frontend at BUILD
  time and FRONTEND_URL is the backend's CORS allow-list, so rebuilding now would
  bring up a stack that still talks to $have_origin.

  To move this install to $ORIGIN, edit .env — keeping ENCRYPTION_KEY exactly as
  it is — and update: FRONTEND_URL, NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SITE_URL,
  BACKEND_PUBLIC_URL, WEBAUTHN_RP_ID, WEBAUTHN_ORIGIN, COOKIE_SECURE, TRUST_PROXY,
  CSP_CONNECT_SRC, BIND_ADDR. Then: docker compose up -d --build

  Never let a NEW ENCRYPTION_KEY be generated against an existing database — every
  stored secret (Proxmox token, SMTP password, TOTP secrets) becomes undecryptable."
  fi

  # The reuse path never chmodded, so a hand-made .env could be world-readable.
  chmod 600 .env 2>/dev/null || warn "could not chmod 600 .env — check its permissions"
else
  refuse_new_key_over_existing_db
  KEY="$(gen_key)"
  [ "${#KEY}" -eq 64 ] || die "generated key was not 64 hex characters."

  TMP_ENV="$(mktemp .env.XXXXXX)"
  chmod 600 "$TMP_ENV"
  if [ "$MODE" = "local" ]; then
    cat > "$TMP_ENV" <<EOF
# Generated by install.sh — HTTP trial on $HTTP_HOST.
# Full reference: .env.docker.example
ENCRYPTION_KEY=$KEY

FRONTEND_URL=$ORIGIN
NEXT_PUBLIC_API_URL=$API_ORIGIN/api
NEXT_PUBLIC_SITE_URL=$ORIGIN
BACKEND_PUBLIC_URL=$API_ORIGIN

# The UI (:3000) and the API (:4000) are separate origins here, and the shipped
# production CSP only allows 'self' https: wss: — which would block every API call
# and the console WebSocket over plain HTTP. Pin connect-src to this API origin.
# The value MUST be wrapped in double quotes: docker compose ends a value at the
# closing quote it opened, so a bare  CSP_CONNECT_SRC='self' http://…  is read as
# just  self  — silently dropping the API origin AND the quotes that make 'self'
# the CSP keyword rather than a hostname.
CSP_CONNECT_SRC="'self' $API_ORIGIN ws://$HTTP_HOST:4000"

# Plain HTTP: Secure cookies would never be sent back, so they stay off here.
COOKIE_SECURE=false
TRUST_PROXY=0
# Clients reach the API directly here, with no proxy in front to set a real-IP
# header, so none is trusted: the audit log records the socket address.
REAL_IP_HEADER=
# Published on all interfaces so you can reach it from another machine.
BIND_ADDR=0.0.0.0
BACKEND_PORT=4000
FRONTEND_PORT=3000
EOF
  else
    cat > "$TMP_ENV" <<EOF
# Generated by install.sh — single HTTPS origin behind a reverse proxy.
# Full reference: .env.docker.example
ENCRYPTION_KEY=$KEY

FRONTEND_URL=$ORIGIN
NEXT_PUBLIC_API_URL=$ORIGIN/api
NEXT_PUBLIC_SITE_URL=$ORIGIN
BACKEND_PUBLIC_URL=$ORIGIN

# Passkeys are bound to the exact origin the browser sees.
WEBAUTHN_RP_ID=$DOMAIN
WEBAUTHN_ORIGIN=$ORIGIN

COOKIE_SECURE=true
TRUST_PROXY=1
# Which header carries the real client IP for the audit log and rate limiting.
# Default is cf-connecting-ip (Cloudflare); a plain Caddy/nginx proxy sends
# X-Forwarded-For, and trusting the wrong one lets clients spoof their IP.
REAL_IP_HEADER=x-forwarded-for
# Only the local reverse proxy reaches the app ports.
BIND_ADDR=127.0.0.1
BACKEND_PORT=4000
FRONTEND_PORT=3000
EOF
  fi
  mv "$TMP_ENV" .env || die "could not write .env"
  TMP_ENV=""
  unset KEY
  # Mode 600 and root-owned would mean the human who ran `sudo bash install.sh`
  # cannot read the ENCRYPTION_KEY they are told to back up off the host.
  chown_to_invoker .env
  ok "wrote .env (mode 600) with a fresh 64-hex ENCRYPTION_KEY"
  warn "back that key up OFF this host — a database backup without it restores nothing"
fi

# Compose bind-mounts ${PROXMATE_BACKUP_DIR:-./backups}. If Docker has to create
# it, it lands root-owned and the scheduled backup (which runs as the unprivileged
# `node` user) fails at 02:30 with nobody watching.
backup_dir="$(env_value PROXMATE_BACKUP_DIR)"
backup_dir="${backup_dir:-./backups}"
if [ ! -d "$backup_dir" ]; then
  if mkdir -p "$backup_dir" 2>/dev/null; then
    # This directory is bind-mounted into the backend, which runs as uid 1000
    # (`node`). Created by root it lands root-owned and every nightly app-database
    # backup fails silently — the service warns and returns, the scheduler only
    # logs successes, and the admin UI shows no last-run status. Under sudo this
    # hands it to the invoking human; either way the container entrypoint chowns
    # it to uid 1000 on every start, which covers hand-rolled installs too.
    chown_to_invoker "$backup_dir"
    # Snapshots hold live session tokens. They are written 0600 by uid 1000, so
    # only uid 1000 (the container's `node`, and whichever host account has that
    # uid) and root can read them — and with 0700 only they can list them.
    chmod 0700 "$backup_dir" 2>/dev/null || warn "could not chmod 0700 $backup_dir"
    ok "created $backup_dir for app-database backups"
  else
    warn "could not create $backup_dir — create it yourself before enabling backups"
  fi
fi

# Warn on the ports actually in effect, not hardcoded ones.
be_port="$(env_value BACKEND_PORT)"; be_port="${be_port:-4000}"
fe_port="$(env_value FRONTEND_PORT)"; fe_port="${fe_port:-3000}"
if have ss; then
  for p in "$be_port" "$fe_port"; do
    ss -ltn 2>/dev/null | grep -qE "[:.]${p}[[:space:]]" \
      && warn "port $p already has a listener — if that isn't ProxMate, change BACKEND_PORT/FRONTEND_PORT in .env"
  done
fi

if [ "$DO_START" -eq 0 ]; then
  echo; step "Done (--no-start)"
  printf '  start it with:  cd %s && docker compose up -d --build\n' "$(printf '%q' "$INSTALL_DIR")"
  exit 0
fi

# ── 5. build + start ──────────────────────────────────────────────────────────
echo
step "Building and starting (the first build takes a few minutes)"
docker compose up -d --build || die "compose failed — see the output above.
  Most common causes: not enough RAM for the frontend build (add swap), no disk
  space, or no network access to pull base images."

# ── 6. wait for health ────────────────────────────────────────────────────────
echo
step "Waiting for the API to become healthy"
info "the backend applies database migrations on boot, so this is not instant"

cid=""; healthy=0
for _ in $(seq 1 60); do
  [ -n "$cid" ] || cid="$(docker compose ps -q backend 2>/dev/null || true)"
  if [ -n "$cid" ]; then
    state="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$cid" 2>/dev/null || echo unknown)"
    running="$(docker inspect -f '{{.State.Running}}' "$cid" 2>/dev/null || echo true)"
    case "$state" in
      healthy) healthy=1; break ;;
    esac
    # A crash-looping backend reports "starting" forever; check Running too.
    if [ "$running" = "false" ]; then
      die "the backend container stopped. See what it said:
      docker compose logs backend"
    fi
  fi
  sleep 5
done

# ── 7. what's next ────────────────────────────────────────────────────────────
echo
if [ "$healthy" -eq 1 ]; then
  ok "backend is healthy"
  step "ProxMate is up"
else
  warn "the backend did not report healthy within ~5 minutes"
  step "ProxMate started, but is not confirmed healthy"
  info "check it with:  cd $INSTALL_DIR && docker compose logs -f backend"
fi

echo
if [ "$MODE" = "local" ]; then
  printf '  Open %s%s%s and complete the setup wizard.\n' "$C_BOLD" "$ORIGIN" "$C_RESET"
  if [ "$HTTP_HOST" = "localhost" ]; then
    info "that address only works from THIS machine — re-run with --host <lan-ip> for others"
  fi
  echo
  warn "Until you finish the wizard, ANYONE who can reach $ORIGIN can claim the"
  warn "admin account. The ports are published on all interfaces. Do it now, or"
  warn "keep this host off untrusted networks until you have."
else
  printf '  Point your reverse proxy at this stack, then open %s%s%s\n' "$C_BOLD" "$ORIGIN" "$C_RESET"
  info "  /api  ->  127.0.0.1:$be_port     (keep the /api prefix — do NOT strip it)"
  info "  /     ->  127.0.0.1:$fe_port"
  info "WebSockets must pass through: the console and the IDE both need them."
  info "A ready-made Caddyfile is in this checkout: $INSTALL_DIR/deploy/Caddyfile"
  info "Full runbook: https://proxmate.host/docs/deployment"
fi

echo
info "The wizard asks for a Proxmox API token. The quickest token that works is:"
info "    pveum user token add root@pam proxmate --privsep 0"
info "That grants ProxMate full cluster rights. For anything you care about, create"
info "a dedicated least-privilege role instead: https://proxmate.host/docs/security"
info "Privilege separation must be OFF either way — a privsep token has NO"
info "permissions, and the connection test still passes before everything 403s."
echo
info "Before inviting anyone: enable the Proxmox cluster firewall."
info "The per-VM isolation rules do nothing until it is on."
echo