#!/usr/bin/env bash
# Tunnelry installer for a fresh Debian 13 or Ubuntu 24.04 server. Run as root:
#
#   ./install.sh --archive tunnelry-0.1.0.tar.gz            (archive, SHA256SUMS and SHA256SUMS.sig in one directory)
#   ./install.sh --archive tunnelry-0.1.0.tar.gz --unsafe-skip-verify      (development only)
#
# It installs the packages, unpacks the release, creates the unprivileged application user, lets `vpnctl
# bootstrap` write the system files (sudoers rule, php-fpm pool, nginx site, systemd units, environment, TLS
# certificate), initialises the database and prints the address, the certificate fingerprint and the one-time
# setup token. Running it again repairs or updates the installation without changing the key or the token.
#
# Options:
#   --archive FILE        release archive to install (downloads are not available yet)
#   --public-key FILE     verify the signature with this Ed25519 public key instead of the pinned one
#   --unsafe-skip-verify  do not verify the archive (development only)
#   --update-url URL      release feed the Updates page and tunnelry:update use (https; empty = none)
#   --gui-port PORT       port of the web interface (default 8443)
#   --address HOST        address shown in the URL and certificate (default: the server's primary IPv4 address)
#   --new-token           issue a new one-time setup token
#   --force               continue although the checks found something unusual (other nginx sites, other OS)
set -euo pipefail

# Ed25519 public key (PEM) of the release signing key. Empty until the project has published one.
RELEASE_PUBLIC_KEY='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAIrsepCPVpznm5TEAmy45SwKEZV4hVZlfhslJ0RTgzUM=
-----END PUBLIC KEY-----'

# Where new releases are announced (latest.json, SHA256SUMS and the archives live there). Empty until the project hosts them.
UPDATE_FEED_URL='https://updates.tunnelry.com'

APP_USER=tunnelry
INSTALL_ROOT=/opt/tunnelry
DATA_DIR=/var/lib/tunnelry
LOG=/var/log/tunnelry-install.log

ARCHIVE=""; PUBLIC_KEY_FILE=""; SKIP_VERIFY=0; GUI_PORT=8443; ADDRESS=""; NEW_TOKEN=0; FORCE=0; UPDATE_URL_GIVEN=0

die()  { echo "error: $*" >&2; exit 1; }
info() { echo "==> $*"; }
warn() { echo "warning: $*" >&2; }

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

while [ $# -gt 0 ]; do
  case "$1" in
    --archive)            ARCHIVE="${2:?--archive needs a file}"; shift 2 ;;
    --public-key)         PUBLIC_KEY_FILE="${2:?--public-key needs a file}"; shift 2 ;;
    --unsafe-skip-verify) SKIP_VERIFY=1; shift ;;
    --update-url)         UPDATE_FEED_URL="${2-}"; UPDATE_URL_GIVEN=1; shift 2 ;;
    --gui-port)           GUI_PORT="${2:?--gui-port needs a port}"; shift 2 ;;
    --address)            ADDRESS="${2:?--address needs a host}"; shift 2 ;;
    --new-token)          NEW_TOKEN=1; shift ;;
    --force)              FORCE=1; shift ;;
    -h|--help)            usage; exit 0 ;;
    *)                    die "unknown option: $1 (see --help)" ;;
  esac
done

[ "$(id -u)" = 0 ] || die "run as root (for example: sudo ./install.sh ...)"
for tool in ip ss flock tar sed awk; do
  command -v "$tool" >/dev/null || die "the command '$tool' is required (apt-get install iproute2 util-linux tar)"
done
[[ "$GUI_PORT" =~ ^[0-9]+$ ]] && [ "$GUI_PORT" -ge 1 ] && [ "$GUI_PORT" -le 65535 ] || die "invalid --gui-port"
[ -n "$ARCHIVE" ] || die "no release archive given: use --archive FILE (downloading releases is not available yet)"
[ -f "$ARCHIVE" ] || die "archive not found: $ARCHIVE"
if [ -n "$UPDATE_FEED_URL" ]; then
  [[ "$UPDATE_FEED_URL" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$ || "$UPDATE_FEED_URL" =~ ^http://(127\.0\.0\.1|localhost)(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$ ]] \
    || die "--update-url must be an https:// address (plain http only for this machine)"
fi

exec > >(tee -a "$LOG") 2>&1
exec 9>/run/tunnelry-install.lock
flock -n 9 || die "another installation is running"

# ---------------------------------------------------------------- checks
info "Checking the system"

. /etc/os-release
case "${ID:-}:${VERSION_ID:-}" in
  debian:13|ubuntu:24.04) ;;
  *) if [ "$FORCE" = 1 ]; then warn "untested system: ${PRETTY_NAME:-unknown}"; else die "supported systems: Debian 13 and Ubuntu 24.04 (found: ${PRETTY_NAME:-unknown}; --force to try anyway)"; fi ;;
esac

case "$(uname -m)" in x86_64|aarch64) ;; *) die "unsupported architecture: $(uname -m)" ;; esac
[ -d /run/systemd/system ] || die "systemd is required"
[ -c /dev/net/tun ] || die "/dev/net/tun is missing (on a VPS or container enable TUN/TAP)"

if ss -H -ltn "( sport = :$GUI_PORT )" 2>/dev/null | grep -q . && [ ! -e /etc/nginx/sites-enabled/tunnelry ]; then
  die "TCP port $GUI_PORT is already in use (choose another with --gui-port)"
fi

if [ -d /etc/nginx/sites-enabled ]; then
  others="$(find /etc/nginx/sites-enabled -mindepth 1 ! -name default ! -name tunnelry | head -1)"
  if [ -n "$others" ] && [ "$FORCE" != 1 ]; then
    die "nginx already serves other sites ($others); Tunnelry expects a dedicated server (--force to continue)"
  fi
fi

if [ -z "$ADDRESS" ]; then
  ADDRESS="$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -1)"
  [ -n "$ADDRESS" ] || die "could not detect the server address; pass --address"
fi
info "Address $ADDRESS, web interface port $GUI_PORT"

# ---------------------------------------------------------------- verify the archive
info "Verifying the archive"

ARCHIVE_DIR="$(cd "$(dirname "$ARCHIVE")" && pwd)"
ARCHIVE_FILE="$(basename "$ARCHIVE")"
WORK="$(mktemp -d /tmp/tunnelry-install.XXXXXX)"
trap 'rm -rf "${WORK:?}"' EXIT

if [ "$SKIP_VERIFY" = 1 ]; then
  warn "signature and checksum are NOT verified (--unsafe-skip-verify)"
else
  command -v openssl >/dev/null || { apt-get update -qq && apt-get install -y -qq openssl >/dev/null; }
  if [ -n "$PUBLIC_KEY_FILE" ]; then cp "$PUBLIC_KEY_FILE" "$WORK/key.pem"
  elif [ -n "$RELEASE_PUBLIC_KEY" ]; then printf '%s\n' "$RELEASE_PUBLIC_KEY" > "$WORK/key.pem"
  else die "no release signing key is configured; pass --public-key FILE (or --unsafe-skip-verify for development)"; fi

  [ -f "$ARCHIVE_DIR/SHA256SUMS" ] && [ -f "$ARCHIVE_DIR/SHA256SUMS.sig" ] || die "SHA256SUMS and SHA256SUMS.sig must be next to the archive"
  openssl pkeyutl -verify -pubin -inkey "$WORK/key.pem" -rawin -in "$ARCHIVE_DIR/SHA256SUMS" -sigfile "$ARCHIVE_DIR/SHA256SUMS.sig" >/dev/null \
    || die "the signature of SHA256SUMS is invalid"
  expected="$(awk -v f="$ARCHIVE_FILE" '$2 == f || $2 == "*" f {print $1}' "$ARCHIVE_DIR/SHA256SUMS")"
  [ -n "$expected" ] || die "$ARCHIVE_FILE is not listed in SHA256SUMS"
  actual="$(sha256sum "$ARCHIVE" | cut -d' ' -f1)"
  [ "$expected" = "$actual" ] || die "checksum mismatch for $ARCHIVE_FILE"
  info "Signature and checksum are valid"
fi

# ---------------------------------------------------------------- packages
info "Installing packages"
export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
  nginx php-fpm php-cli php-sqlite3 php-mbstring php-xml php-curl php-zip php-intl \
  openvpn easy-rsa nftables unbound dns-root-data sqlite3 python3 openssl curl ca-certificates sudo iproute2 >/dev/null

PHP_VERSION="$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')"
[ "$(php -r 'echo PHP_VERSION_ID >= 80300 ? "ok" : "old";')" = ok ] || die "PHP 8.3 or newer is required (found $PHP_VERSION)"
missing=""
for ext in ctype curl dom fileinfo filter mbstring openssl pdo_sqlite session tokenizer xml; do
  php -m | grep -qix "$ext" || missing="$missing $ext"
done
[ -z "$missing" ] || die "missing PHP extensions:$missing"
command -v easyrsa >/dev/null || [ -x /usr/share/easy-rsa/easyrsa ] || die "easy-rsa is not installed correctly"
info "PHP $PHP_VERSION, $(openvpn --version | head -1 | cut -d' ' -f1-2)"

# ---------------------------------------------------------------- user and directories
info "Preparing the user and directories"
getent passwd "$APP_USER" >/dev/null || useradd --system --user-group --home-dir "$DATA_DIR" --no-create-home --shell /usr/sbin/nologin "$APP_USER"

install -d -m 0755 -o root -g root "$INSTALL_ROOT" "$INSTALL_ROOT/releases" /usr/lib/tunnelry
install -d -m 0700 -o root -g root /etc/tunnelry
install -d -m 0750 -o "$APP_USER" -g "$APP_USER" "$DATA_DIR" "$DATA_DIR/storage" "$DATA_DIR/storage/logs" \
  "$DATA_DIR/storage/framework" "$DATA_DIR/storage/framework/cache" "$DATA_DIR/storage/framework/cache/data" \
  "$DATA_DIR/storage/framework/sessions" "$DATA_DIR/storage/framework/views" "$DATA_DIR/storage/app"
[ -e "$DATA_DIR/database.sqlite" ] || install -m 0640 -o "$APP_USER" -g "$APP_USER" /dev/null "$DATA_DIR/database.sqlite"

# ---------------------------------------------------------------- unpack the release
info "Unpacking the release"
tar -xzf "$ARCHIVE" -C "$WORK" --no-same-owner --no-same-permissions --warning=no-unknown-keyword
SRC="$(find "$WORK" -mindepth 1 -maxdepth 1 -type d -name 'tunnelry-*' | head -1)"
[ -n "$SRC" ] || die "the archive does not contain a tunnelry-* directory"
for required in artisan vendor/autoload.php public/index.php bin/vpnctl VERSION; do
  [ -e "$SRC/$required" ] || die "the archive is incomplete (missing $required)"
done

VERSION="$(tr -d '[:space:]' < "$SRC/VERSION")"
[[ "$VERSION" =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]] || die "invalid version in the archive"
RELEASE="$INSTALL_ROOT/releases/$VERSION"

if [ -d "$RELEASE" ]; then
  info "Release $VERSION is already unpacked"
else
  rm -rf "$RELEASE.new"
  cp -a "$SRC" "$RELEASE.new"
  chown -R root:root "$RELEASE.new"
  chmod -R u=rwX,go=rX "$RELEASE.new"
  rm -rf "$RELEASE.new/storage" "$RELEASE.new/.env"
  ln -s "$DATA_DIR/storage" "$RELEASE.new/storage"
  ln -s "$DATA_DIR/.env" "$RELEASE.new/.env"
  install -d -m 0750 -o "$APP_USER" -g "$APP_USER" "$RELEASE.new/bootstrap/cache"
  # nginx (www-data) must read public/ but never the cache.
  mv "$RELEASE.new" "$RELEASE"
fi

install -m 0755 -o root -g root "$RELEASE/bin/vpnctl" /usr/lib/tunnelry/vpnctl.new
mv /usr/lib/tunnelry/vpnctl.new /usr/lib/tunnelry/vpnctl

# The public key that updates are verified with (the one this installation was verified with), and the update feed.
if [ -s "$WORK/key.pem" ]; then
  install -m 0644 -o root -g root "$WORK/key.pem" /usr/lib/tunnelry/release-key.pub.pem
elif [ ! -e /usr/lib/tunnelry/release-key.pub.pem ]; then
  warn "no signing key was used for this installation, so updates cannot be verified and are disabled"
fi
if [ "$UPDATE_URL_GIVEN" = 1 ] || { [ -n "$UPDATE_FEED_URL" ] && [ ! -e /etc/tunnelry/update.json ]; }; then
  if [ -n "$UPDATE_FEED_URL" ]; then
    python3 -c 'import json,sys; print(json.dumps({"feed": sys.argv[1]}))' "$UPDATE_FEED_URL" > /etc/tunnelry/update.json
    chmod 0600 /etc/tunnelry/update.json
  else
    rm -f /etc/tunnelry/update.json
  fi
fi
ln -sfn "$RELEASE" "$INSTALL_ROOT/current"

# ---------------------------------------------------------------- system files
info "Writing the system files (vpnctl bootstrap)"
request="$(python3 -c 'import json,sys; print(json.dumps({"address": sys.argv[1], "php_version": sys.argv[2], "gui_port": int(sys.argv[3]), "new_token": sys.argv[4] == "1"}))' "$ADDRESS" "$PHP_VERSION" "$GUI_PORT" "$NEW_TOKEN")"
result="$(printf '%s' "$request" | /usr/lib/tunnelry/vpnctl bootstrap)" || { echo "$result" >&2; die "vpnctl bootstrap failed"; }
field() { printf '%s' "$result" | python3 -c 'import json,sys; v=json.load(sys.stdin).get(sys.argv[1]); print("" if v is None else v)' "$1"; }
TOKEN="$(field setup_token)"
FINGERPRINT="$(field certificate_fingerprint)"

# ---------------------------------------------------------------- application
info "Initialising the database"
as_app() { runuser -u "$APP_USER" -- "$@"; }
as_app php "$INSTALL_ROOT/current/artisan" migrate --force --no-interaction
as_app php "$INSTALL_ROOT/current/artisan" optimize --no-interaction >/dev/null

# ---------------------------------------------------------------- services
info "Starting the services"
systemctl daemon-reload
systemctl enable tunnelry-firewall.service >/dev/null 2>&1 || true

# The stock site would answer on port 80 and tells nothing useful.
[ -L /etc/nginx/sites-enabled/default ] && rm -f /etc/nginx/sites-enabled/default
nginx -t >/dev/null 2>&1 || { nginx -t; die "the nginx configuration is invalid"; }
systemctl enable --now "php${PHP_VERSION}-fpm" nginx >/dev/null 2>&1
systemctl reload "php${PHP_VERSION}-fpm"
systemctl reload nginx

# ---------------------------------------------------------------- self-test
info "Checking the installation"
ok=1
check() { local name="$1"; shift; if "$@" >/dev/null 2>&1; then echo "  ok    $name"; else echo "  FAIL  $name"; ok=0; fi; }

for _ in 1 2 3 4 5 6 7 8 9 10; do
  curl -ks -o /dev/null -w '%{http_code}' "https://127.0.0.1:$GUI_PORT/up" | grep -q 200 && break
  sleep 1
done
check "the web interface answers on port $GUI_PORT" bash -c "curl -ks -o /dev/null -w '%{http_code}' https://127.0.0.1:$GUI_PORT/up | grep -q 200"
check "the application can call vpnctl through sudo" runuser -u "$APP_USER" -- sudo -n /usr/lib/tunnelry/vpnctl version --actor=cli:install
check "the application cannot run anything else as root" bash -c "! runuser -u $APP_USER -- sudo -n /usr/bin/id"
check "the application cannot call console-only commands" bash -c "! runuser -u $APP_USER -- sudo -n /usr/lib/tunnelry/vpnctl bootstrap --actor=cli:install"
check "the application cannot read /etc/tunnelry" bash -c "! runuser -u $APP_USER -- ls /etc/tunnelry"
check "the application cannot write its own code" bash -c "! runuser -u $APP_USER -- touch $INSTALL_ROOT/current/artisan"
[ "$ok" = 1 ] || die "the installation did not pass its own checks (see above and $LOG)"

# ---------------------------------------------------------------- summary
echo
echo "Tunnelry $VERSION is installed."
echo
echo "  Open:         https://$ADDRESS:$GUI_PORT"
echo "  Certificate:  self-signed; SHA-256 fingerprint"
echo "                $FINGERPRINT"
echo "                (compare it with what the browser shows before accepting the warning)"
if [ -n "$TOKEN" ]; then
  echo "  Setup token:  $TOKEN"
  echo "                (shown once; enter it in the setup wizard. Lost it? run: ./install.sh --archive ... --new-token)"
else
  echo "  Setup token:  was shown when Tunnelry was first installed (use --new-token for a new one)"
fi
echo
