#!/usr/bin/env bash
# install.sh: Proofroom on this server with one command (docs/self-install.md, proofroom.com/install):
#
#   curl -fsSL https://proofroom.com/install.sh | sudo bash -s -- --code XXXX-XXXX-XXXX
#
# It checks the server and stops at the first problem, saying what to do; fetches the licence with the install code
# (and saves it, so running this again needs no code); installs Docker if it is missing, then proofroomctl; runs
# proofroomctl init and create; and prints the admin's one-time setup link. Safe to run twice. It sends the install
# code to the licence server and nothing else, and never asks for a password or a key.
#
# Piped into bash, the script is bash's stdin: everything runs from main, called on the last line, so bash has read it
# all before anything starts, and every command that might read stdin gets /dev/null. Questions go to the terminal
# (/dev/tty) only when there is one; otherwise --yes is needed.
#
# For tests and mirrors only: PROOFROOM_LICENCE_SERVER, PROOFROOM_IMAGE, PROOFROOM_REGISTRY_URL, PROOFROOM_INSTALL_DIR,
# PROOFROOMCTL_PATH, PROOFROOM_ROOT, PROOFROOM_CADDY_DIR, PROOFROOM_OS_RELEASE, PROOFROOM_MEMINFO, PROOFROOM_TTY,
# PROOFROOM_PROXY=own (no Caddy: the ports are not checked and init gets --own-proxy), PROOFROOM_CHECKINS=off (init gets
# --licence-server ""), PROOFROOM_INSTALL_SKIP (names of checks to skip).
set -euo pipefail
umask 077

HELP='Usage: install.sh --code CODE [options]     (or: install.sh --licence FILE --email EMAIL [options])

  --code CODE        the install code from your licence email or download page
  --licence FILE     a licence file you copied to this server instead of a code
  --email EMAIL      the first admin'"'"'s email (with --licence; a code already names it)
  --name NAME        the stack'"'"'s name (default: from your company'"'"'s name)
  --yes              do not ask before installing
  --dry-run          check the server and print the commands; nothing is changed and the code is not used
  --skip-dns-check   for a server behind a proxy or NAT: the HTTPS certificate will then fail unless that proxy
                     sends the domain'"'"'s traffic here

The guide: https://proofroom.com/install'

SERVER="${PROOFROOM_LICENCE_SERVER:-https://licence.proofroom.com}"
IMAGE="${PROOFROOM_IMAGE:-ghcr.io/proofroom-team/proofroom:1.14}"
REGISTRY_URL="${PROOFROOM_REGISTRY_URL:-https://ghcr.io/v2/}"
DIR="${PROOFROOM_INSTALL_DIR:-/root}"
CTL="${PROOFROOMCTL_PATH:-/usr/local/sbin/proofroomctl}"
ROOT="${PROOFROOM_ROOT:-/srv/proofroom}"
CADDY_DIR="${PROOFROOM_CADDY_DIR:-/etc/caddy}"
OS_RELEASE="${PROOFROOM_OS_RELEASE:-/etc/os-release}"
MEMINFO="${PROOFROOM_MEMINFO:-/proc/meminfo}"
TTY="${PROOFROOM_TTY:-/dev/tty}"
PKG="$DIR/proofroom-licence.json" SETTINGS="$DIR/proofroom-install.env"
readonly NAME_RE='^[a-z][a-z0-9-]{1,30}$'
readonly DOMAIN_RE='^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$'
readonly EMAIL_RE='^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$'
readonly CODE_RE='^[0-9A-Za-z -]{12,20}$'
readonly IPV4_RE='^[0-9]{1,3}(\.[0-9]{1,3}){3}$'
readonly MIN_MEM_MB=1800 MIN_DISK_GB=20   # a "2 GB" server reports a little under 2048 MB

code="" licence="" email="" name="" yes=0 dry=0 skip_dns=0
DOMAIN="" NAME="" EMAIL="" LINK="" SRC=""

ok() { printf '  ✓ %s\n' "$*"; }
note() { printf '  ! %s\n' "$*"; }
bad() { printf '  ✗ %s\n' "$*" >&2; exit 1; }
say() { printf '\n%s\n' "$*"; }
usage() { printf 'install.sh: %s\nRun it with --help for the options.\n' "$*" >&2; exit 2; }
run() { if (( dry )); then printf '  + %s\n' "$*"; else "$@" </dev/null; fi; }
skipped() {
  if [[ " ${PROOFROOM_INSTALL_SKIP:-} " == *" $1 "* ]]; then note "$1: not checked (PROOFROOM_INSTALL_SKIP)"; return 0; fi
  return 1
}
# A string field of a JSON file written one field per line (the licence server's replies and files are).
field() { sed -n "s/^ *\"$1\": *\"\\([^\"]*\\)\".*/\\1/p" "$2" | head -n 1; }
# The same string, still JSON-escaped (\" and \\ included), for a value that may hold anything, such as a company name.
json_string() { LC_ALL=C sed -En "s/^ *\"$1\": *\"(([^\"\\\\]|\\\\.)*)\".*/\\1/p" "$2" | head -n 1; }
# stackNameFor in licence/src/mail/trial.ts, which names the stack in the licence email and on the download page:
# lower-case; every run of characters other than a-z and 0-9 becomes one dash; leading characters up to the first
# letter go; cut to 31; trailing dashes go; "proofroom" if under 2 characters. Its input here is still JSON-escaped:
# an escape stands for a character that is not a-z or 0-9, so it becomes a dash too. The only characters outside
# ASCII that JavaScript lower-cases into a-z are U+212A (Kelvin sign, to k) and U+0130 (to i and a combining dot).
stack_name_for() {
  local s
  s="$(printf '%s' "$1" | LC_ALL=C sed -E -e $'s/\xe2\x84\xaa/k/g' -e $'s/\xc4\xb0/i-/g' -e 's#\\(["\\/bfnrt]|u[0-9a-fA-F]{4})#-#g' \
    | LC_ALL=C tr '[:upper:]' '[:lower:]' | LC_ALL=C sed -E -e 's/[^a-z0-9]+/-/g' -e 's/^[^a-z]+//' | cut -c1-31 | sed 's/-*$//')"
  if (( ${#s} >= 2 )); then printf '%s' "$s"; else printf proofroom; fi
}
# A reply from /v1/install, whole: the licence, the install secret and the install fields, and the closing brace.
whole_package() {
  grep -q '^ *"licence": {' "$1" && [[ -n "$(field installSecret "$1")" && -n "$(field domain "$1")" ]] \
    && [[ -n "$(field installDomain "$1")" && -n "$(field stackName "$1")" && "$(tail -n 1 "$1")" == "}" ]]
}
setting() { if [[ -r "$SETTINGS" ]]; then grep -m1 "^$1=" "$SETTINGS" | cut -d= -f2- || true; fi; }
# A terminal to ask on: one that opens for reading and writing. Without a controlling terminal (cloud-init, CI,
# ssh host 'curl … | bash') /dev/tty does not open, and stdin is not an answer (piped, it is this script).
have_tty() { [[ -e "$TTY" ]] && { true < "$TTY"; } 2>/dev/null && { true >> "$TTY"; } 2>/dev/null; }
ask() {   # ask PROMPT: the answer on stdout; fails when there is no terminal
  have_tty || return 1
  local answer=""
  printf '%s' "$1" >> "$TTY"
  read -r answer < "$TTY" || true
  printf '%s' "$answer"
}

# --- checks ---------------------------------------------------------------------------------------------------------
check_root() {
  if [[ "$(id -u)" == 0 ]]; then ok "running as root"; else bad "Run this with sudo (or as root): put sudo before bash, as in the command from your email."; fi
}
check_os() {
  skipped os && return 0
  local id="" ver="" pretty=""
  if [[ -r "$OS_RELEASE" ]]; then
    id="$(sed -n 's/^ID=//p' "$OS_RELEASE" | tr -d '"')"
    ver="$(sed -n 's/^VERSION_ID=//p' "$OS_RELEASE" | tr -d '"')"
    pretty="$(sed -n 's/^PRETTY_NAME=//p' "$OS_RELEASE" | tr -d '"')"
  fi
  if [[ "$id $ver" == "ubuntu 24.04" || "$id $ver" == "debian 12" ]]; then ok "$pretty"; else bad "Proofroom needs Ubuntu 24.04 or Debian 12; this server runs ${pretty:-an unknown system}."; fi
}
check_arch() {
  skipped arch && return 0
  local a; a="$(uname -m)"
  if [[ "$a" == x86_64 ]]; then ok "x86-64"; else bad "Proofroom needs an x86-64 (amd64) server; this one is $a."; fi
}
check_memory() {
  skipped memory && return 0
  local kb mb; kb="$(awk '/^MemTotal:/ {print $2}' "$MEMINFO" 2>/dev/null || true)"; mb=$(( ${kb:-0} / 1024 ))
  if (( mb >= MIN_MEM_MB )); then ok "memory: $mb MB"; else bad "Proofroom needs at least 2 GB of memory; this server has $mb MB."; fi
}
check_disk() {
  skipped disk && return 0
  local kb gb; kb="$(df -Pk / | awk 'NR == 2 {print $4}')"; gb=$(( ${kb:-0} / 1024 / 1024 ))
  if (( gb >= MIN_DISK_GB )); then ok "disk: $gb GB free"; else bad "Proofroom needs at least 20 GB of free disk; this server has $gb GB free on /."; fi
}
check_ports() {
  skipped ports && return 0
  if [[ "${PROOFROOM_PROXY:-}" == own ]]; then note "ports: not checked (your own proxy)"; return 0; fi
  if ! command -v ss >/dev/null; then note "ports: not checked (ss is not installed)"; return 0; fi
  local p line proc
  for p in 80 443; do
    line="$(ss -ltnpH "sport = :$p" 2>/dev/null | head -n 1 || true)"
    if [[ -z "$line" ]]; then ok "port $p is free"; continue; fi
    proc="$(sed -n 's/.*users:(("\([^"]*\)".*/\1/p' <<<"$line")"
    if [[ "$proc" == caddy ]] && grep -qF 'proofroom.d/*.caddy' "$CADDY_DIR/Caddyfile" 2>/dev/null; then ok "port $p: Caddy, set up by proofroomctl"; continue; fi
    bad "Port $p is in use${proc:+ by $proc}. Proofroom's web server needs ports 80 and 443 for its HTTPS certificate: stop that service, or use a fresh server."
  done
}
check_reach() {
  skipped reach && return 0
  if curl -fsS -o /dev/null --max-time 15 "$SERVER/health" </dev/null; then ok "the licence server answers"
  else bad "Cannot reach $SERVER. Check that this server can make outgoing HTTPS connections (port 443) and resolve names."; fi
  local s; s="$(curl -sS -o /dev/null --max-time 15 -w '%{http_code}' "$REGISTRY_URL" </dev/null 2>/dev/null || true)"
  if [[ -n "$s" && "$s" != 000 ]]; then ok "the image registry answers"
  else bad "Cannot reach the image registry (${REGISTRY_URL%/v2/}). Check that this server can make outgoing HTTPS connections."; fi
}

# --- the licence ------------------------------------------------------------------------------------------------------
fetch_licence() {
  if [[ -s "$PKG" ]]; then
    ok "using the licence saved by an earlier run ($PKG)"; SRC=$PKG
    local fresh="To install with a new code or licence file instead, remove $PKG and $SETTINGS first."
    if [[ -n "$code" ]]; then note "the code given was not sent: this run uses the licence saved by an earlier run ($PKG). $fresh"
    elif [[ -n "$licence" && "$(cat "$licence" 2>/dev/null || true)" != "$(cat "$PKG")" ]]; then
      note "$licence was not used: this run uses the licence saved by an earlier run ($PKG). $fresh"
    fi
    return 0
  fi
  if [[ -n "$licence" ]]; then
    [[ -r "$licence" ]] || bad "Cannot read the licence file $licence."
    [[ -n "$(field installSecret "$licence")" && -n "$(field domain "$licence")" ]] \
      || bad "$licence is not a Proofroom licence file as it came from the licence server: use the file from your email or download page, unchanged."
    SRC=$licence
    if (( dry )); then printf '  + install -m 600 %s %s\n' "$licence" "$PKG"; return 0; fi
    install -m 600 "$licence" "$PKG"; SRC=$PKG; ok "licence file saved to $PKG"; return 0
  fi
  if (( dry )); then printf '  + POST %s/v1/install (your code; not sent in a dry run: it works once)\n' "$SERVER"; return 0; fi
  local tmp status msg rc=0 again="if it then says the code is not valid, write to hello@proofroom.com for a new one."
  tmp="$(mktemp "$DIR/.proofroom-licence.XXXXXX")"
  # The code goes on curl's stdin, not its command line. A transfer cut short exits non-zero after the 200.
  status="$(curl -sS -o "$tmp" -w '%{http_code}' --max-time 30 -X POST -H 'Content-Type: application/json' --data @- "$SERVER/v1/install" <<<"{\"code\":\"$code\"}")" || rc=$?
  if [[ -z "$status" || "$status" == 000 ]]; then
    rm -f "$tmp"; bad "Could not reach $SERVER to fetch the licence. Check this server's outgoing HTTPS connections and run this command again; $again"
  fi
  if [[ "$status" != 200 ]]; then
    msg="$(sed -n 's/.*"error": *"\([^"]*\)".*/\1/p' "$tmp")"; rm -f "$tmp"
    bad "${msg:-The licence server answered $status.} To install by hand instead: https://proofroom.com/install"
  fi
  if (( rc != 0 )) || ! whole_package "$tmp"; then
    rm -f "$tmp"; bad "The licence server's reply was incomplete, so nothing was saved. Run this command again; $again"
  fi
  chmod 600 "$tmp"; mv "$tmp" "$PKG"; SRC=$PKG; ok "licence fetched and saved to $PKG"
}
read_settings() {
  if [[ -z "$SRC" ]]; then DOMAIN="<your domain>" NAME="${name:-<name>}" EMAIL="${email:-<email>}"; return 0; fi   # a dry run with a code
  local d n e
  d="$(field installDomain "$SRC")"; [[ -n "$d" ]] || d="$(field domain "$SRC")"
  n="${name:-$(setting NAME)}"; [[ -n "$n" ]] || n="$(field stackName "$SRC")"
  [[ -n "$n" ]] || n="$(stack_name_for "$(json_string customer "$SRC")")"   # a file copied by hand names no stack
  e="${email:-$(setting EMAIL)}"; [[ -n "$e" ]] || e="$(field adminEmail "$SRC")"
  if [[ -z "$e" ]] && (( ! yes )); then e="$(ask 'Email of the first admin: ' || true)"; fi
  if [[ -z "$e" ]]; then
    if [[ -z "$(field installDomain "$SRC")" ]]; then bad "Give the first admin's email with --email (a licence file copied by hand does not name one)."; fi
    bad "Give the first admin's email with --email (the licence does not name one); the licence is saved, so the code is not needed again."
  fi
  d="$(printf '%s' "$d" | tr '[:upper:]' '[:lower:]')"
  [[ "$d" =~ $DOMAIN_RE ]] || bad "The licence's domain (${d:-none}) is not a domain name: write to hello@proofroom.com."
  [[ "$n" =~ $NAME_RE ]] || bad "The stack name $n is not 2 to 31 lower-case letters, digits or dashes: give one with --name."
  [[ "$e" =~ $EMAIL_RE ]] || bad "Not an email address: $e"
  DOMAIN=$d NAME=$n EMAIL=$e
  if (( ! dry )); then printf 'DOMAIN=%s\nNAME=%s\nEMAIL=%s\n' "$DOMAIN" "$NAME" "$EMAIL" > "$SETTINGS"; chmod 600 "$SETTINGS"; fi
}
check_dns() {
  if (( skip_dns )); then note "DNS: not checked (--skip-dns-check). The HTTPS certificate will fail unless $DOMAIN reaches this server."; return 0; fi
  skipped dns && return 0
  if [[ -z "$SRC" ]]; then note "DNS: checked once the licence is fetched"; return 0; fi
  local ip found
  # IPv4: the check compares A records. The server may answer "unknown" when it cannot tell; that is no address.
  ip="$(curl -4 -fsS --max-time 15 "$SERVER/v1/ip" </dev/null | tr -d '[:space:]' || true)"
  if [[ ! "$ip" =~ $IPV4_RE ]]; then
    bad "Could not check DNS: $SERVER did not say what this server's public IPv4 address is. If $DOMAIN already points to this server, run this command again with --skip-dns-check (the code is not needed again)."
  fi
  found="$(getent ahostsv4 "$DOMAIN" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ' | sed 's/ *$//' || true)"
  if [[ " $found " == *" $ip "* ]]; then ok "$DOMAIN points to this server ($ip)"; return 0; fi
  bad "$DOMAIN points to ${found:-nothing}, not to this server ($ip). Add a DNS record:  $DOMAIN  A  $ip  then wait a few minutes and run this command again (the code is not needed again). Behind a proxy or NAT? Run it again with --skip-dns-check; the HTTPS certificate will then fail unless the proxy sends $DOMAIN's traffic here."
}

# --- installing -----------------------------------------------------------------------------------------------------
# A stack of that name is there already: from an earlier run, finished or not (create leaves a stack that did not
# become healthy in place for a look). Nothing is installed; its status and the ways on are shown instead.
# The stack is there already: show its status and what to do, and exit, 0 only when it is healthy (cloud-init or CI
# reads the exit status, not the words).
existing_stack() {
  say "A stack named $NAME already exists here (https://$DOMAIN), so nothing new was installed. Its status:"
  local status="" healthy=0
  if [[ -x "$CTL" ]]; then status="$("$CTL" status "$NAME" </dev/null 2>&1)" || healthy=-1; printf '%s\n' "$status"; fi
  if (( healthy == 0 )) && grep -Eq '^health:[[:space:]]+healthy[[:space:]]*$' <<<"$status"; then healthy=1; fi
  printf '\nFor a new one-time setup link for the admin:\n  sudo proofroomctl admin-link %s --email %s\n' "$NAME" "$EMAIL"
  printf 'If it is not healthy, see why with sudo proofroomctl logs %s. To start over, remove it and run this command again (the licence is saved, so the code is not needed again):\n  sudo proofroomctl remove %s --confirm %s --no-export\n' "$NAME" "$NAME" "$NAME"
  if (( healthy != 1 )); then bad "The stack $NAME is not healthy."; fi
  exit 0
}
confirm() {
  if (( yes || dry )); then return 0; fi
  local answer
  answer="$(ask "Install Proofroom as $NAME for https://$DOMAIN? [y/N] ")" || bad "There is no terminal to ask on: run again with --yes."
  if [[ "$answer" != y && "$answer" != Y && "$answer" != yes ]]; then say "Nothing was installed."; exit 1; fi
}
install_all() {
  say "Installing"
  # The Docker installer writes apt sources and keys that apt's own user must read: not under this script's umask.
  if command -v docker >/dev/null; then ok "Docker is installed"; else run sh -c 'umask 022; curl -fsSL https://get.docker.com | sh'; fi
  if (( dry )); then printf '  + docker run --rm --entrypoint cat %s /app/release/proofroomctl > %s\n' "$IMAGE" "$CTL"
  else
    # -q: one line, not a progress line per image layer (the log of a real install held hundreds).
    printf '  pulling %s\n' "$IMAGE"; docker pull -q "$IMAGE" >/dev/null </dev/null
    docker run --rm --entrypoint cat "$IMAGE" /app/release/proofroomctl > "$CTL.next" </dev/null
    chmod 755 "$CTL.next"; mv "$CTL.next" "$CTL"; ok "proofroomctl installed ($CTL)"
  fi
  local init=(init --registry "${IMAGE%:*}")
  if [[ "${PROOFROOM_PROXY:-}" == own ]]; then init+=(--own-proxy); fi
  if [[ "${PROOFROOM_CHECKINS:-}" == off ]]; then init+=(--licence-server ""); fi
  run "$CTL" "${init[@]}"
  if (( dry )); then printf '  + %s create %s --domain %s --licence %s --admin-email %s\n' "$CTL" "$NAME" "$DOMAIN" "$PKG" "$EMAIL"; return 0; fi
  local out; out="$(mktemp "$DIR/.proofroom-create.XXXXXX")"
  if ! "$CTL" create "$NAME" --domain "$DOMAIN" --licence "$PKG" --admin-email "$EMAIL" </dev/null | tee "$out"; then
    rm -f "$out"
    bad "proofroomctl create did not finish (above). Fix what it says and run this command again: the licence is saved, so the code is not needed again."
  fi
  LINK="$(grep -Eo 'https://[^ ]+/auth/link/[^ ]+' "$out" | tail -n 1 || true)"
  rm -f "$out"
}
summary() {
  say "Proofroom is installed: https://$DOMAIN"
  if [[ -n "$LINK" ]]; then printf '\nOpen this one-time setup link to choose your password and add your authenticator (it works once, for 24 hours):\n  %s\n' "$LINK"; fi
  printf '\nNext:\n  sudo proofroomctl status %s     health, version, licence and last backup\n  sudo proofroomctl backup %s     a backup now (one is also taken every night)\n  sudo proofroomctl update %s     the latest release; it backs up first and rolls back if anything fails\n  sudo proofroomctl admin-link %s --email %s     a new setup link\nIn Proofroom, set up email in Admin > Settings > Email so invitations are sent for you.\nThe guide: https://proofroom.com/install\n' "$NAME" "$NAME" "$NAME" "$NAME" "$EMAIL"
}

main() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --code) [[ $# -ge 2 ]] || usage "--code needs the code"; code=$2; shift 2 ;;
      --licence) [[ $# -ge 2 ]] || usage "--licence needs a file"; licence=$2; shift 2 ;;
      --email) [[ $# -ge 2 ]] || usage "--email needs an address"; email=$2; shift 2 ;;
      --name) [[ $# -ge 2 ]] || usage "--name needs a name"; name=$2; shift 2 ;;
      --yes) yes=1; shift ;;
      --dry-run) dry=1; shift ;;
      --skip-dns-check) skip_dns=1; shift ;;
      -h|--help) printf '%s\n' "$HELP"; exit 0 ;;
      *) usage "unknown option $1" ;;
    esac
  done
  [[ -z "$code" || "$code" =~ $CODE_RE ]] || usage "that does not look like an install code: $code"
  [[ -z "$name" || "$name" =~ $NAME_RE ]] || usage "a stack name is 2 to 31 lower-case letters, digits or dashes, starting with a letter: $name"
  [[ -z "$email" || "$email" =~ $EMAIL_RE ]] || usage "not an email address: $email"
  if [[ -z "$code" && -z "$licence" && ! -s "$PKG" ]]; then usage "give --code with the install code from your licence email, or --licence with a licence file"; fi

  say "Checking this server"
  check_root; check_os; check_arch; check_memory; check_disk; check_ports; check_reach
  say "Your licence"
  fetch_licence; read_settings; check_dns
  if [[ -d "$ROOT/stacks/$NAME" ]]; then existing_stack; fi
  confirm
  install_all
  if (( dry )); then say "Dry run: nothing was changed."; exit 0; fi
  summary
  exit 0
}

main "$@"
