#!/bin/sh
# Anchorpoint, from one line.
#
#   curl -fsSL https://get.anchorpoint.fikia.app/install.sh | sh
#   curl -fsSL https://get.anchorpoint.fikia.app/install.sh | sh -s -- --install
#
# THIS SCRIPT INSTALLS NOTHING. In install mode it does four things: checks
# that docker, python3 and an ssh-keygen that knows -Y are present; downloads
# the release key, the bundle and its digest; prints the key's fingerprint and
# refuses to continue until an operator confirms it against a value they got
# somewhere else; and hands the whole job to install.py, which is inside the
# bundle and is covered by a signature this script did not produce and cannot
# forge. A security reviewer reads a fetch and a prompt, not an installer.
#
# The fingerprint is the only control that matters, and this script is not it.
# A compromised copy of this file could print a fingerprint matching an
# attacker's bundle. What defeats that is the operator having the real one
# from a different source: docs/INSTALL.md, the release notes, and one place
# with a different trust root than this host. That is why the prompt below
# offers no default, fetches no expected value, and does not accept a bare
# newline.
#
# POSIX sh, not bash: this runs on whatever /bin/sh the host has.
set -eu

BASE_URL="${ANCHORPOINT_BASE_URL:-https://get.anchorpoint.fikia.app}"
VERSION="${ANCHORPOINT_VERSION:-latest}"

# The digest of the verifier this script will accept out of a bundle. Stamped
# by `make install-script-pin`, held current by tests/test_install_script.py.
#
# This script is served from the same host the bundle is, so it cannot carry a
# verifier that host did not serve. It carries this number instead. An operator
# who checked this script against the digest published in docs/INSTALL.md, by a
# route the artifact did not travel, has transitively checked which verifier is
# acceptable. Without it the same host would supply both the artifact and the
# code deciding whether the artifact is ours, and the signature check below
# would be answering its own question.
VERIFIER_SHA256="bdd8a065c602a117bcac3b5c4a46177b80a51090d2fbe5b9264fabc54af978cf"
# The tty the prompt reads from. /dev/tty rather than stdin, because stdin is
# the pipe carrying this script. Overridable only so the test suite can drive
# the prompt; a real invocation never sets it.
TTY="${ANCHORPOINT_TTY:-/dev/tty}"

MODE=try
DIR=/opt/anchorpoint
# There is no tier and no receiver URL. Both were questions a first-time
# installer could not answer: the tier was a fact about their network they had
# not thought about yet, and the URL was a value only we could give them. What
# this deployment shares is one switch in the console afterwards, defaulting to
# on, and nothing is asked here.
ADMIN_USER=admin
VERIFY_FINGERPRINT=no

needs_value() {
  # $1 the flag as typed, $2 the remaining argument count including the flag.
  if [ "$2" -lt 2 ]; then
    echo "error: $1 needs a value" >&2
    exit 2
  fi
}

while [ $# -gt 0 ]; do
  case "$1" in
    --install)
      [ "$MODE" = upgrade ] && { echo "error: --install and --upgrade are both given; one of them" >&2; exit 2; }
      MODE=install ;;
    # Upgrading is the same fetch and the same verification as installing, and
    # the bundle's own install.py decides either way: it reads STATE and checks
    # the new bundle against the key this deployment pinned when it was
    # installed. This was three commands by hand until it was not.
    --upgrade)
      [ "$MODE" = install ] && { echo "error: --install and --upgrade are both given; one of them" >&2; exit 2; }
      MODE=upgrade ;;
    # Each value-taking flag checks it was GIVEN a value. Without the guard,
    # set -u aborts on the unset $2 with the shell's own "53: 2: parameter not
    # set": a line number inside a file the operator piped from a URL and
    # cannot read, naming the shell's positional parameter rather than the flag
    # they typed. A wrapped paste is enough to produce it.
    --version) needs_value "$1" $#; VERSION="$2"; shift ;;
    --dir) needs_value "$1" $#; DIR="$2"; shift ;;
    --admin-user) needs_value "$1" $#; ADMIN_USER="$2"; shift ;;
    --verify-fingerprint) VERIFY_FINGERPRINT=yes ;;
    -h|--help)
      echo "usage: install.sh [--install [--dir DIR] [--admin-user NAME]]"
      echo "                  [--upgrade [--dir DIR]] [--version V]"
      echo "  --verify-fingerprint prompts for the release key fingerprint you"
      echo "    were given out of band. Without it the install trusts TLS and"
      echo "    this download host."
      exit 0
      ;;
    *) echo "error: unknown option $1" >&2; exit 2 ;;
  esac
  shift
done

die() { echo "$*" >&2; exit 1; }
refuse() { echo "refused: $*" >&2; exit 3; }

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

# Checked here as well as inside install.py, deliberately. install.py's own
# preflight runs after a download that is hundreds of megabytes, and a person
# whose host has no docker should learn that in the first second.
# The command that installs Docker Engine on this host, named for the distro
# rather than left to the operator to find. THIS SCRIPT STILL INSTALLS
# NOTHING: the line is printed, never run. Acquiring root and mutating package
# state would contradict the sentence this file leads with, on the path
# somebody runs to evaluate the product.
#
# The fallback matters more than any of the named cases. An unrecognised
# distro gets the upstream page, because a confidently wrong package command
# is worse than no command: it sends an operator to install something that is
# not Docker Engine, and the most common wrong turn here (`apt install
# docker.io`, or a distro `docker-compose` v1) lands exactly on the second
# refusal below.
docker_install_hint() {
  id=""
  like=""
  if [ -r /etc/os-release ]; then
    id=$(. /etc/os-release 2>/dev/null && echo "${ID:-}")
    like=$(. /etc/os-release 2>/dev/null && echo "${ID_LIKE:-}")
  fi
  case "$id $like" in
    *debian*|*ubuntu*)
      echo "  curl -fsSL https://get.docker.com | sudo sh" ;;
    *rhel*|*fedora*|*centos*|*rocky*|*almalinux*)
      echo "  sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin" ;;
    *amzn*)
      echo "  sudo dnf install -y docker docker-compose-plugin && sudo systemctl enable --now docker" ;;
    *suse*|*sles*)
      echo "  sudo zypper install -y docker docker-compose && sudo systemctl enable --now docker" ;;
    *)
      echo "  see https://docs.docker.com/engine/install/ for this distribution" ;;
  esac
}

check_docker() {
  if ! have docker; then
    echo "error: docker is not on PATH; this needs Docker Engine with the compose plugin" >&2
    echo "on this host:" >&2
    docker_install_hint >&2
    echo "then re-run this command." >&2
    exit 1
  fi
  if ! docker compose version >/dev/null 2>&1; then
    echo "error: docker is on PATH but its compose plugin did not answer 'docker compose version'" >&2
    echo "the compose PLUGIN is a separate package from docker itself, and a" >&2
    echo "standalone 'docker-compose' v1 does not satisfy it. On this host:" >&2
    docker_install_hint >&2
    echo "then re-run this command." >&2
    exit 1
  fi
}

check_install_prerequisites() {
  check_docker
  have python3 || die "error: python3 is not on PATH; this installer needs Python 3.11 or later"
  have ssh-keygen || die "error: ssh-keygen is not on PATH; it is what verifies the signature"
  said=$(ssh-keygen -Y 2>&1 || true)
  case "$said" in
    *"invalid option"*|*"illegal option"*|*"unknown option"*)
      die "error: this ssh-keygen does not know the -Y verbs, which is what verifies a release signature; OpenSSH 8.2 or later is needed"
      ;;
  esac
}

fetch() {
  curl -fsSL "$1" -o "$2" || die "error: could not fetch $1"
}

resolve_version() {
  if [ "$VERSION" = latest ]; then
    tmp=$(mktemp)
    fetch "$BASE_URL/latest.txt" "$tmp"
    VERSION=$(tr -d ' \n' < "$tmp")
    rm -f "$tmp"
    [ -n "$VERSION" ] || die "error: $BASE_URL/latest.txt named no version"
  fi
}

try_mode() {
  check_docker
  resolve_version
  work=$(mktemp -d)
  fetch "$BASE_URL/$VERSION/try-compose.yml" "$work/compose.yml"
  echo "Starting an evaluation instance. This holds generated data and is destroyed by one command."
  docker compose -p anchorpoint-try -f "$work/compose.yml" up -d
  echo ""
  echo "  console:  http://127.0.0.1:8092"
  echo "  gateway:  http://127.0.0.1:9020"
  echo "  teardown: docker compose -p anchorpoint-try down -v"
  echo ""
  echo "Every trace on it was generated, not captured. No provider was called."
}

# --dir has to be creatable before anything is fetched. install.py checks this
# too and is the authority, but its check runs after this script has downloaded
# 300 MB and after install.py has loaded two images: several minutes in, on the
# commonest first install of all, because the default /opt/anchorpoint needs
# root on every distribution this supports. Refusing here costs nothing and
# refuses in the first second.
#
# Nothing is created by the check. Walks up to the nearest existing ancestor
# and tests that, rather than attempting a mkdir, so a refusal on a later step
# never leaves an empty directory behind on a path the operator then corrects.
check_directory_writable() {
  if [ -e "$DIR" ]; then
    [ -w "$DIR" ] || die "error: $DIR exists but is not writable by this user. Re-run with sudo, or pass --dir with somewhere you can write."
    return
  fi
  # Parameter expansion rather than dirname(1). This runs on whatever /bin/sh
  # a host has, and a minimal image may carry no coreutils at all; a check that
  # needs an external binary to decide whether a directory is writable can fail
  # for a reason unrelated to the thing it is checking.
  case "$DIR" in
    */*) parent="${DIR%/*}"; [ -n "$parent" ] || parent=/ ;;
    *) parent=. ;;
  esac
  while [ ! -e "$parent" ]; do
    case "$parent" in
      /|.) break ;;
    esac
    next="${parent%/*}"
    [ -n "$next" ] || next=/
    [ "$next" != "$parent" ] || break
    parent="$next"
  done
  [ -w "$parent" ] || die "error: cannot create $DIR: $parent is not writable by this user. Re-run with sudo, or pass --dir with somewhere you can write."
}

install_mode() {
  check_install_prerequisites
  check_directory_writable
  resolve_version

  work=$(mktemp -d)

  # The fingerprint prompt is OPT IN since 0.75.3, and what that trades away is
  # worth stating rather than burying. TLS authenticates the download host and
  # the signature check below proves the bundle is intact, so a tampered or
  # truncated download is still caught. Neither establishes that the key which
  # signed the bundle is OURS: somebody who controls the download host can
  # serve their own bundle signed with their own key and pass every check an
  # unattended install makes. Only a fingerprint obtained by a route the
  # artifact did not travel closes that, which is why --verify-fingerprint
  # exists and why a bundle carried in by hand, which has no TLS at all,
  # should use it.
  confirmed=""
  if [ "$VERIFY_FINGERPRINT" = yes ]; then
    fetch "$BASE_URL/$VERSION/release-key.pub" "$work/release-key.pub"
    found=$(ssh-keygen -lf "$work/release-key.pub" | sed -n 's/.*\(SHA256:[A-Za-z0-9+/=]*\).*/\1/p')
    [ -n "$found" ] || die "error: could not read a fingerprint from the release key"

    echo ""
    echo "This bundle is signed by a key whose fingerprint is:"
    echo ""
    echo "    $found"
    echo ""
    echo "Compare it with the fingerprint you were given, from docs/INSTALL.md or"
    echo "the release notes. Do not copy the line above: it came from the same"
    echo "place the bundle did, and comparing it with itself proves nothing."
    echo ""
    printf "Fingerprint you were given: "
    read -r confirmed < "$TTY" || confirmed=""

    [ -n "$confirmed" ] || refuse "nothing was entered, and --verify-fingerprint needs a fingerprint you obtained elsewhere"
    if [ "$confirmed" != "$found" ]; then
      echo "refused: the fingerprint you entered does not match the one on this bundle." >&2
      echo "  you entered: $confirmed" >&2
      echo "  this bundle: $found" >&2
      exit 3
    fi
  fi

  tarball="anchorpoint-$VERSION-linux-amd64.tar.gz"
  echo "Fetching $tarball. This is image sized; it will take a while."
  fetch "$BASE_URL/$VERSION/$tarball" "$work/$tarball"
  tar -xzf "$work/$tarball" -C "$work"
  bundle="$work/anchorpoint-$VERSION-linux-amd64"

  # python3 rather than sha256sum, which would be a host requirement this
  # script does not otherwise have. python3 is already checked in
  # check_install_prerequisites and is what runs the verifier a line later, so
  # this adds nothing to what an operator must already have installed.
  found=$(python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$bundle/anchorpoint_verify.py")
  if [ "$found" != "$VERIFIER_SHA256" ]; then
    echo "refused: the bundle's verifier is not the one this installer expects." >&2
    echo "  expected: $VERIFIER_SHA256" >&2
    echo "  found:    $found" >&2
    echo "This is exactly what the check exists to catch. Do not work around it." >&2
    exit 3
  fi

  python3 "$bundle/install.py" verify "$bundle"
  # A positional list rather than four spellings of the same command. The
  # fingerprint is the only conditional left: install.py takes
  # --trust-fingerprint only when the operator asked to be shown one.
  #
  # upgrade takes neither an admin user nor a fingerprint. There is already an
  # administrator, and the bundle is checked against the key this deployment
  # pinned rather than one named here, which is what makes an upgrade safe.
  if [ "$MODE" = upgrade ]; then
    set -- upgrade "$bundle" --dir "$DIR"
  else
    set -- install "$bundle" --dir "$DIR" --admin-user "$ADMIN_USER"
    [ -n "$confirmed" ] && set -- "$@" --trust-fingerprint "$confirmed"
  fi
  python3 "$bundle/install.py" "$@"
}

case "$MODE" in
  install|upgrade) install_mode ;;
  *) try_mode ;;
esac
