#!/usr/bin/env bash
# The one-line install. This is what is served at https://dashmox.com/install.sh
#
#   curl -fsSL https://dashmox.com/install.sh | sudo bash
#
# or, if you would rather read a script before running it as root, which is the
# better habit:
#
#   curl -fsSL https://dashmox.com/install.sh -o install.sh
#   less install.sh
#   sudo bash install.sh
#
# Arguments go to the real installer, so a scripted install is one line too:
#
#   curl -fsSL https://dashmox.com/install.sh | sudo bash -s -- \
#       --hostname cp.example.com --email you@example.com --tls auto
#
# What this does and nothing more: ask the licence service which release is
# newest, fetch that archive and its signature, check the archive against the
# checksum the service named, unpack it somewhere only root can read, and run
# the installer inside it. Every decision about what to install belongs to that
# installer; this file exists so nobody has to know a version number or a URL.
#
# **What vouches for the bytes.** On a first install, the answer is TLS to this
# service and nothing else: the archive is signed, but checking that signature
# here against a key from the same place would be the archive vouching for
# itself. The checksum is still shown, quietly, so it can be compared against a
# copy obtained another way.
#
# **The release key is installed from here, and that is the point of it.** This
# script carries the public half and writes it into the panel's key directory
# before handing over to the installer. Be clear about what that does and does
# not buy, because it is easy to overclaim: it adds nothing to *this* install -
# the key comes down the same TLS as everything else in this run - and it is not
# meant to. What it does is pin, at install time and from the website, the key
# that every later update is judged against. An in-panel update takes its URL
# and checksum from the licence service through the unprivileged panel, with
# nobody watching, so the signature is the only thing standing between that and
# running somebody else's code as root. The key being pinned earlier, from a
# different host, is what makes that check mean anything.
#
# Until 0.96.36 nothing put a key on a server at all. The paragraph here already
# said "the release key is on the machine by then and an update is refused
# unless that key vouches for it", and it was not true: the installer created an
# empty directory and no install path ever filled it, so the panel's own update
# button was unavailable on every installation anybody ever made. See DEC-176.
#
# Set DASHMOX_RELEASE_KEY=no to skip it. That is for somebody who builds and signs
# their own releases and does not want this key trusted on their server, which
# is a legitimate thing to want and the only reason to say no. It is an
# environment variable rather than a flag because every argument to this script
# is forwarded to the installer, which would refuse one it does not know.
#
# **How it looks.** A green banner, one line per step with a tick, and a moving
# bar while the release downloads. This is the first thing anybody sees of Dashmox
# and it used to read like a legal notice. Anywhere that is not a terminal it
# prints the same steps as plain lines with no escape sequences. See DEC-129.
#
# **A download that stops is picked up where it stopped.** The first public
# installs failed with `curl: (18) transfer closed`, which curl's own retry does
# not retry, and starting again from nothing fails the same way on the same slow
# line. Each attempt continues the partial file, up to five times.
#
# It is not part of a release and never ships to a server: it is the thing that
# fetches one, so a copy sitting on an installed machine would be a second
# opinion about where releases come from. It lives here and on the website.
set -Eeuo pipefail

RELEASE_SERVICE=${DASHMOX_RELEASE_SERVICE:-https://license.dashmox.com/api/v1/release}

# Colour and symbols only on a terminal that has not asked for none. Piped into
# a file or a CI log, the same steps are plain words.
if [[ -t 1 && -z ${NO_COLOR:-} && ${TERM:-dumb} != dumb ]]; then
    FANCY=yes
    GREEN=$'\033[38;5;46m' LIME=$'\033[1;38;5;118m' DIM=$'\033[38;5;244m' RED=$'\033[1;38;5;203m' RESET=$'\033[0m'
    TICK='✓' CROSS='✗' MARK='›'
else
    FANCY=no
    GREEN='' LIME='' DIM='' RED='' RESET=''
    TICK='ok' CROSS='error:' MARK='-'
fi

banner() {
    if [[ $FANCY != yes ]]; then
        printf 'Dashmox installer\n\n'
        return 0
    fi
    # The same picture installer/lib/ui.sh draws, and a test asserts they match.
    # The rename missed this copy for five days because the name here is drawn
    # rather than written: nothing that greps for the old name can see a picture
    # of it, and the plain-text line above was already correct, so only a terminal
    # good enough for the art got it wrong.
    printf '\n%s' "$GREEN"
    cat <<'ART'
   ██████╗   █████╗  ███████╗ ██╗  ██╗ ███╗   ███╗  ██████╗  ██╗  ██╗
   ██╔══██╗ ██╔══██╗ ██╔════╝ ██║  ██║ ████╗ ████║ ██╔═══██╗ ╚██╗██╔╝
   ██║  ██║ ███████║ ███████╗ ███████║ ██╔████╔██║ ██║   ██║  ╚███╔╝
   ██║  ██║ ██╔══██║ ╚════██║ ██╔══██║ ██║╚██╔╝██║ ██║   ██║  ██╔██╗
   ██████╔╝ ██║  ██║ ███████║ ██║  ██║ ██║ ╚═╝ ██║ ╚██████╔╝ ██╔╝ ██╗
   ╚═════╝  ╚═╝  ╚═╝ ╚══════╝ ╚═╝  ╚═╝ ╚═╝     ╚═╝  ╚═════╝  ╚═╝  ╚═╝
ART
    printf '%s\n   %sYour own hosting control panel, one command away.%s\n\n' "$RESET" "$LIME" "$RESET"
}
step() { printf '  %s%s%s %s\n' "$GREEN" "$MARK" "$RESET" "$*"; }
ok() { printf '  %s%s%s %s\n' "$GREEN" "$TICK" "$RESET" "$*"; }
note() { printf '    %s%s%s\n' "$DIM" "$*" "$RESET"; }
# die says what went wrong in one line and what to do about it underneath.
die() {
    printf '\n  %s%s %s%s\n' "$RED" "$CROSS" "$1" "$RESET" >&2
    shift
    local line
    for line in "$@"; do printf '    %s\n' "$line" >&2; done
    printf '\n' >&2
    exit 1
}

banner

if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
    die "Run this as root." "Pipe it to 'sudo bash' rather than to 'bash'."
fi
if [[ $(uname -s) != Linux ]]; then
    die "Dashmox installs on Linux." "This machine reports $(uname -s)."
fi
case $(uname -m) in
    x86_64) ARCH=amd64;;
    aarch64|arm64) ARCH=arm64;;
    *) die "There is no Dashmox release for $(uname -m)." "Supported: x86_64 and aarch64.";;
esac
for tool in tar sha256sum python3; do
    command -v "$tool" >/dev/null 2>&1 || die "This needs $tool, which this machine does not have." "Install it and run this again."
done

# curl or wget, whichever is here. A fresh Ubuntu has one of them and often only
# one, and the failure "curl: not found" from inside a pipe is a confusing way to
# meet a product.
#
# A large file is fetched with a continue: curl's -C - and wget's -c pick up a
# partial file rather than replacing it. Neither gives up on a slow line, only on
# one that has stopped: curl abandons a transfer below a kilobyte a second for a
# minute, and the attempt after it carries on from there.
RETRY_ALL=()
if command -v curl >/dev/null 2>&1; then
    if curl --help all 2>/dev/null | grep -q -- '--retry-all-errors'; then RETRY_ALL=(--retry-all-errors); fi
    ask_release() { curl -fsSL --retry 3 --retry-delay 2 --max-time 60 \
        -H 'Content-Type: application/json' --data "$2" "$1"; }
    fetch_file() {
        local progress=(-sS)
        if [[ $FANCY == yes && -z ${3:-} ]]; then progress=(--progress-bar); fi
        curl -fL "${progress[@]}" --connect-timeout 20 --speed-limit 1024 --speed-time 60 \
            --retry 3 --retry-delay 2 "${RETRY_ALL[@]}" -C - -o "$2" "$1"
    }
elif command -v wget >/dev/null 2>&1; then
    ask_release() { wget -q --tries=3 --timeout=60 --header='Content-Type: application/json' \
        --post-data="$2" -O - "$1"; }
    fetch_file() {
        local progress=(-q)
        if [[ $FANCY == yes && -z ${3:-} ]]; then progress=(-q --show-progress); fi
        wget "${progress[@]}" -c --tries=3 --timeout=60 -O "$2" "$1"
    }
else
    die "This needs curl or wget, and this machine has neither." "Install one of them and run this again."
fi

# download fetches one file and, when the transfer stops partway, continues it.
download() {
    local attempt
    for attempt in 1 2 3 4 5; do
        if fetch_file "$@"; then
            return 0
        fi
        if [[ $attempt -lt 5 ]]; then
            note "The download stopped. Carrying on from where it got to ($attempt of 5)."
            sleep 3
        fi
    done
    return 1
}

size_of() {
    local bytes
    bytes=$(stat -c %s "$1" 2>/dev/null || echo 0)
    numfmt --to=iec --suffix=B "$bytes" 2>/dev/null || printf '%s bytes' "$bytes"
}

step "Finding the newest release"
# **Which machine is asking is part of the question.** The service keeps one
# release per version per architecture and answers for amd64 when nothing says
# otherwise, which is what a GET is. So this asked as a GET, an arm64 server was
# told about the amd64 archive, and the check below refused it - "Dashmox 0.96.15
# has no build for arm64 yet" - with the arm64 archive published, signed and
# sitting there to be downloaded. No arm64 machine could install from this
# script. An installed panel had always said which machine it was; the first
# install was the one place that never did.
ANSWER=$(ask_release "$RELEASE_SERVICE" "$(printf '{"arch":"%s"}' "$ARCH")") \
    || die "Could not reach the release service." "Check this server's network and run the same command again."

# Read with python3 rather than by pattern: the installer needs python3 anyway,
# and a regular expression over JSON is a thing that works until the day it
# quietly matches the wrong field.
read -r VERSION URL SHA256 < <(printf '%s' "$ANSWER" | python3 -c '
import json, sys
try:
    release = json.load(sys.stdin).get("release") or {}
except Exception:
    sys.exit(1)
version, url, digest = release.get("version",""), release.get("url",""), release.get("sha256","")
if not (version and url and digest):
    sys.exit(1)
print(version, url, digest)
') || die "The release service answered something this does not understand." "Nothing was installed."

case $URL in
    https://*) ;;
    *) die "The release service named a download address that is not HTTPS." "Nothing was installed.";;
esac
if [[ $URL != *"-linux-$ARCH.tar.gz" ]]; then
    die "Dashmox $VERSION has no build for $ARCH yet." "The only archive published for it is $URL"
fi
ok "Dashmox $VERSION"
note "sha256   $SHA256"

WORK=$(mktemp -d /var/tmp/dashmox-install-XXXXXX)
chmod 0700 "$WORK"
trap 'rm -rf -- "$WORK"' EXIT

ARCHIVE="$WORK/dashmox-$VERSION-linux-$ARCH.tar.gz"
step "Downloading"
download "$URL" "$ARCHIVE" \
    || die "The release could not be downloaded." "Nothing was installed. Check this server's network and run the same command again."
ok "Downloaded $(size_of "$ARCHIVE")"
# Fetched beside it because the installer looks for it there, and its absence is
# worth saying now rather than after the services have been stopped.
fetch_file "$URL.sig" "$ARCHIVE.sig" quiet || note "No signature is published beside this release."

step "Checking it"
printf '%s  %s\n' "$SHA256" "$ARCHIVE" | sha256sum --check --status \
    || die "The download does not match the checksum the release service named." \
        "Nothing was installed. This is either a broken download or something worse." \
        "Run it again, and if it happens twice, stop and ask."
tar -xzf "$ARCHIVE" -C "$WORK"
# The archive is removed before the installer runs, and that is not tidiness.
# release_install copies its whole working directory into the staged release,
# and validate_release requires a release to hold exactly what its manifest
# lists. An archive left beside the tree it unpacked therefore fails the
# release it just delivered - "release content checksum mismatch", naming the
# archive itself - and no current symlink is ever made. Every install done
# this way failed at the systemd stage with four "No such file or directory"
# lines and nothing saying why.
rm -f "$ARCHIVE" "$ARCHIVE.sig"
[[ -x $WORK/install.sh ]] || [[ -f $WORK/install.sh ]] \
    || die "That archive does not contain a Dashmox installer." "Nothing was installed."
ok "Checked and unpacked"
printf '\n'

# The release key, before the installer rather than after it: the last line of
# this script replaces the process, so there is no "after". The installer
# creates this directory itself with the same owner and mode and will not mind
# finding it already there, and it never removes a key it did not put there.
#
# Written only once the archive has been checked and unpacked, so a run that
# fetched nothing leaves nothing behind.
#
# 0600 is not secrecy - this half is public and is printed in the README. It is
# so that nothing but root can add or alter a key this server will trust, which
# is the same reason the directory is 0700.
#
# The file is named for the key rather than for the product, so rotating to a
# new one adds a second file instead of quietly replacing the first: several
# keys are trusted at once, deliberately, so a rotation can overlap. Retiring
# the old one is then a separate act, which is the right shape for it.
if [[ ${DASHMOX_RELEASE_KEY:-yes} == no ]]; then
    note 'Not installing the Dashmox release key, because DASHMOX_RELEASE_KEY=no.'
    note 'Updates from the panel will be unavailable until a key you trust is installed.'
else
    step "Trusting the Dashmox release key"
    install -d -o root -g root -m 0700 /etc/dashmox/release-keys
    cat > /etc/dashmox/release-keys/dashmox-release-key-2026-09-13.pem <<'RELEASE_KEY'
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA4ZHdSa4buQ7zHjHUziBDiuYqWF9IRPLo97WoAFdSYQs=
-----END PUBLIC KEY-----
RELEASE_KEY
    chown root:root /etc/dashmox/release-keys/dashmox-release-key-2026-09-13.pem
    chmod 0600 /etc/dashmox/release-keys/dashmox-release-key-2026-09-13.pem
    ok "This server will refuse a release Dashmox has not signed"
    printf '\n'
fi

# The banner is on the screen already, so the installer does not draw it again.
export DASHMOX_BANNER_SHOWN=yes

# **Nothing on standard input, ever.** The installer asks nothing (DEC-161), so
# it needs no terminal, and handing it one would only mean a stray keystroke
# could be read by something it runs. /dev/null is also what makes this line
# work in the places a one-line install belongs: cloud-init, a provisioning
# script, a CI job, `ssh server 'curl ... | sudo bash'`. This script used to
# hunt for a terminal to hold the interview on, and refuse to install at all
# when it could not find one unless --non-interactive was passed - a one-line
# install that only worked where somebody was watching.
exec bash "$WORK/install.sh" "$@" </dev/null
