#!/usr/bin/env bash
# Installs the PeerBlade control plane on a fresh Debian or Ubuntu host.
#
#   curl -fsSL https://peerblade.com/install.sh | sudo bash -s -- panel.example.com
#
# With --with-node the same host also becomes the first VPN node: the installer
# registers it, prepares a WireGuard interface and connects the agent, so the
# panel opens with a server already online.
#
# Everything it does is spelled out in the deployment guide; this only saves
# the typing. Re-running it updates an existing installation instead of
# starting over — secrets are never overwritten.

set -euo pipefail

readonly repository="https://github.com/peerblade/PeerBlade.git"
readonly install_directory="/opt/peerblade"
readonly installation_id_file="$install_directory/.installation-id"
readonly installation_reported_file="$install_directory/.installation-reported"
readonly installation_collector_url="https://peerblade.com/api/installations"

fail() {
  echo "peerblade install: $*" >&2
  exit 1
}

step() {
  echo
  echo "==> $*"
}

require_apt() {
  command -v apt-get >/dev/null 2>&1 ||
    fail "this installer supports Debian and Ubuntu; follow the manual guide instead"
}

# The certificate is requested on first start, so a name pointing somewhere
# else fails minutes later inside Caddy. Better to say so now.
check_dns() {
  local domain=$1
  local resolved
  local public

  command -v getent >/dev/null 2>&1 || return 0
  resolved=$(getent ahostsv4 "$domain" 2>/dev/null | awk 'NR==1{print $1}' || true)

  if [[ -z "$resolved" ]]; then
    fail "$domain does not resolve yet; point an A record at this host first"
  fi

  public=$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)

  if [[ -n "$public" && "$resolved" != "$public" ]]; then
    echo "peerblade install: warning: $domain resolves to $resolved," \
      "this host answers as $public" >&2
    echo "peerblade install: continuing anyway — a proxy in front can explain it" >&2
  fi
}

check_ports() {
  local busy=()
  local port

  command -v ss >/dev/null 2>&1 || return 0

  for port in 80 443; do
    if ss -ltn "( sport = :$port )" 2>/dev/null | grep -q LISTEN; then
      busy+=("$port")
    fi
  done

  if [[ ${#busy[@]} -gt 0 ]] && [[ ! -d "$install_directory/.git" ]]; then
    fail "port(s) ${busy[*]} are already in use; free them or stop the other web server"
  fi
}

install_prerequisites() {
  step "Installing prerequisites"
  apt-get update -qq
  apt-get install -y -qq curl git ca-certificates >/dev/null

  if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
    echo "Docker is already installed."
    return
  fi

  step "Installing Docker"
  curl -fsSL https://get.docker.com | sh >/dev/null
  docker compose version >/dev/null ||
    fail "Docker installed without the compose plugin; install it and re-run"
}

fetch_bundle() {
  if [[ -d "$install_directory/.git" ]]; then
    step "Updating the deployment bundle in $install_directory"
    git -C "$install_directory" pull --quiet --ff-only
    return
  fi

  [[ ! -e "$install_directory" ]] ||
    fail "$install_directory exists but is not a PeerBlade checkout"

  step "Fetching the deployment bundle into $install_directory"
  git clone --quiet "$repository" "$install_directory"
}

# The panel answers over its own certificate, and Caddy asks for one on first
# start. Nothing can be provisioned until that finishes.
wait_for_panel() {
  local domain=$1
  local attempt

  step "Waiting for https://$domain to answer"

  for attempt in $(seq 1 60); do
    if curl -fsS --max-time 5 "https://$domain/api/health" >/dev/null 2>&1; then
      return 0
    fi

    sleep 5
  done

  return 1
}

# The first node is the only thing the panel cannot hand out before an
# administrator exists, so the API is asked directly — a shell on this host is
# already full trust.
connect_local_node() {
  local domain=$1
  local port=$2
  local provisioning
  local enrollment_token

  if systemctl is-active --quiet peerblade-agent.service; then
    step "The agent on this host is already connected"
    return 0
  fi

  wait_for_panel "$domain" ||
    fail "the panel did not answer in five minutes; check 'docker compose logs caddy' and re-run"

  step "Registering this host as a node"
  provisioning=$(docker compose exec -T api node dist/cli.js \
    provision-node --host "$domain" --name "$domain") ||
    fail "could not register the node; check 'docker compose logs api'"

  enrollment_token=$(printf '%s' "$provisioning" |
    sed -n 's/.*"enrollmentToken"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')

  [[ "$enrollment_token" =~ ^pen_ ]] || fail "the API returned no enrollment token"

  step "Installing the agent and preparing WireGuard"
  curl -fsSL "https://$domain/install-agent.sh" | bash -s -- \
    --url "https://$domain" \
    --token "$enrollment_token" \
    --endpoint "$domain:$port"
}

# This is deliberately a one-time installation receipt rather than product
# analytics. The payload contains only a random UUID generated on this host.
# A failure never makes an otherwise successful self-hosted deployment fail.
report_first_installation() {
  local domain=$1
  local installation_id

  [[ "${domain,,}" != "my.peerblade.com" ]] || return 0
  [[ ! -f "$installation_reported_file" ]] || return 0

  if [[ -f "$installation_id_file" ]]; then
    installation_id=$(tr -d '[:space:]' <"$installation_id_file")
  else
    installation_id=$(cat /proc/sys/kernel/random/uuid)
    umask 077
    printf '%s\n' "$installation_id" >"$installation_id_file"
  fi

  [[ "$installation_id" =~ ^[0-9a-fA-F-]{36}$ ]] || return 0

  if curl -fsS --max-time 10 \
    --request POST \
    --header 'Content-Type: application/json' \
    --data "{\"installationId\":\"$installation_id\"}" \
    "$installation_collector_url" >/dev/null 2>&1; then
    umask 077
    : >"$installation_reported_file"
  fi
}

main() {
  [[ $# -ge 1 ]] ||
    fail "usage: curl -fsSL https://peerblade.com/install.sh | sudo bash -s -- DOMAIN [--with-node] [--no-installation-receipt]"
  [[ ${EUID:-$(id -u)} -eq 0 ]] || fail "run this command as root"

  local domain=$1
  local setup_token_expires_at
  local with_node=false
  local send_installation_receipt=true
  local vpn_port=51820

  shift

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --with-node)
        with_node=true
        shift
        ;;
      --vpn-port)
        [[ $# -ge 2 ]] || fail "--vpn-port requires a value"
        vpn_port=$2
        shift 2
        ;;
      --no-installation-receipt)
        send_installation_receipt=false
        shift
        ;;
      *)
        fail "unknown argument: $1"
        ;;
    esac
  done

  [[ "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ && "$domain" == *.* ]] ||
    fail "DOMAIN must be a fully-qualified DNS name, for example panel.example.com"
  [[ "$vpn_port" =~ ^[0-9]+$ ]] && (( 10#$vpn_port >= 1 && 10#$vpn_port <= 65535 )) ||
    fail "--vpn-port must be between 1 and 65535"

  require_apt
  command -v curl >/dev/null 2>&1 || apt-get install -y -qq curl >/dev/null
  check_dns "$domain"
  check_ports
  install_prerequisites
  fetch_bundle

  cd "$install_directory"

  if [[ -f .env ]]; then
    step "Keeping the existing configuration"
    setup_token_expires_at=$(sed -n 's/^AUTH_SETUP_TOKEN_EXPIRES_AT=//p' .env | tail -n 1)
    if ! grep -Eq '^AUTH_SETUP_TOKEN_HASH=[a-fA-F0-9]{64}$' .env || \
      [[ ! "$setup_token_expires_at" =~ ^[0-9]+$ ]] || \
      (( setup_token_expires_at <= $(date +%s) )); then
      ./setup-token.sh "$domain"
    fi
  else
    step "Generating secrets"
    ./bootstrap.sh "$domain"
  fi

  step "Starting the stack"
  docker compose pull --quiet
  docker compose up -d
  wait_for_panel "$domain" ||
    fail "the panel did not answer in five minutes; check 'docker compose logs caddy' and re-run"

  if [[ "$with_node" == true ]]; then
    connect_local_node "$domain" "$vpn_port"
  fi

  if [[ "$send_installation_receipt" == true ]]; then
    report_first_installation "$domain"
  fi

  step "PeerBlade is starting on https://$domain"
  echo
  if [[ "$with_node" == true ]]; then
    echo "This host is connected as a node on UDP $vpn_port — open that port in"
    echo "the provider firewall if one runs outside the machine."
    echo
  fi

  if grep -q '^PEERBLADE_AUTH_PROXY_MODE=basic$' .env; then
    echo "This existing installation still uses its Basic Auth compatibility mode."
    [[ -f admin-credentials.txt ]] && cat admin-credentials.txt
  elif curl -fsS --max-time 10 "https://$domain/api/auth/setup-status" | \
    grep -q '"setupRequired":true'; then
    echo "Create the first administrator with this one-time link:"
    cat setup-url.txt
    echo
    echo "The link is valid for 60 minutes. To replace an expired link:"
    echo "  cd $install_directory && sudo ./setup-token.sh"
    echo "  docker compose up -d --force-recreate api"
  else
    echo "Open the panel: https://$domain"
  fi
  echo
  echo "If the panel does not answer, the certificate is the usual reason:"
  echo "  docker compose logs --tail 50 caddy"
}

main "$@"
