#!/usr/bin/env bash
#
# netio.ntp-relay installer.
#
#   curl -fsSL https://ntp.netio.com.tr/install.sh | sudo bash
#
# Installs the daemon as a system service that starts at boot and restarts
# automatically if it ever stops. Safe to run repeatedly: an existing
# configuration file is never overwritten, and re-running it upgrades in place.
#
# Options (pass after "-- " when piping, e.g. "| sudo bash -s -- --uninstall"):
#
#   --install            install or upgrade                        (default)
#   --uninstall          remove, keeping the config and drift file
#   --purge              remove everything
#   --version VERSION    install a specific version
#   --help               this text
#
# Environment:
#
#   NTP_RELAY_BASE_URL     where to download from
#   NTP_RELAY_VERSION      version to install
#   NTP_RELAY_ALLOW_INSECURE=1   permit a plain-http base URL (mirrors only)
#   NTP_RELAY_SKIP_VERIFY=1      skip checksum verification (not recommended)
#
# The whole script is function definitions followed by a single call to main at
# the very end. That is deliberate: a download truncated in the middle leaves an
# unterminated function and bash refuses to run any of it, rather than executing
# half an installation.

set -Eeuo pipefail

readonly BASE_URL_DEFAULT="https://ntp.netio.com.tr"

BIN_NAME=ntp-relay
BIN_DIR=/usr/local/bin
CONF_DIR=/etc/ntp-relay
CONF_FILE="${CONF_DIR}/ntp-relay.yaml"
STATE_DIR=/var/lib/ntp-relay
UNIT_NAME=ntp-relay.service
UNIT_FILE="/etc/systemd/system/${UNIT_NAME}"
DOC_DIR=/usr/share/doc/ntp-relay
SVC_USER=ntp-relay
SVC_GROUP=ntp-relay

BASE_URL="${NTP_RELAY_BASE_URL:-${BASE_URL_DEFAULT}}"
VERSION="${NTP_RELAY_VERSION:-}"

# Time daemons that would fight us for the clock or for port 123.
CONFLICTING_SERVICES=(systemd-timesyncd.service chronyd.service chrony.service ntp.service ntpsec.service)

# When piped from curl there is no script file on disk, so the local-artifact
# paths below simply will not match and everything is downloaded instead.
if [[ -f "${BASH_SOURCE[0]:-}" ]]; then
    SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
    REPO_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
else
    SCRIPT_DIR=""
    REPO_DIR=""
fi

WORK_DIR=""

# ---------------------------------------------------------------- output ----

if [[ -t 1 ]]; then
    C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'
    C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[34m'
else
    C_RESET=''; C_BOLD=''; C_RED=''; C_GREEN=''; C_YELLOW=''; C_BLUE=''
fi

step() { printf '%s==>%s %s\n' "${C_BLUE}${C_BOLD}" "${C_RESET}" "$*"; }
ok()   { printf '    %sok%s   %s\n' "${C_GREEN}" "${C_RESET}" "$*"; }
warn() { printf '    %swarn%s %s\n' "${C_YELLOW}" "${C_RESET}" "$*"; }
die()  { printf '%serror:%s %s\n' "${C_RED}${C_BOLD}" "${C_RESET}" "$*" >&2; exit 1; }

cleanup() {
    [[ -n "${WORK_DIR}" && -d "${WORK_DIR}" ]] && rm -rf "${WORK_DIR}"
    return 0
}
trap cleanup EXIT
trap 'die "failed at line ${LINENO}: ${BASH_COMMAND}"' ERR

usage() {
    cat <<'EOF'
netio.ntp-relay installer

  curl -fsSL https://ntp.netio.com.tr/install.sh | sudo bash

Installs the daemon as a system service that starts at boot and restarts
automatically if it ever stops. Safe to run repeatedly.

Options (pass after "-- " when piping, e.g. "| sudo bash -s -- --uninstall"):

  --install            install or upgrade                        (default)
  --uninstall          remove, keeping the config and drift file
  --purge              remove everything
  --version VERSION    install a specific version
  --help               this text

Environment:

  NTP_RELAY_BASE_URL           where to download from
  NTP_RELAY_VERSION            version to install
  NTP_RELAY_ALLOW_INSECURE=1   permit a plain-http base URL (mirrors only)
  NTP_RELAY_SKIP_VERIFY=1      skip checksum verification (not recommended)
EOF
}

# ----------------------------------------------------------- preflight ------

require_root() {
    [[ ${EUID} -eq 0 ]] || die "must be run as root. Try:
    curl -fsSL ${BASE_URL}/install.sh | sudo bash"
}

require_systemd() {
    [[ -d /run/systemd/system ]] || die "systemd is not running; this installer only supports systemd systems"
    command -v systemctl >/dev/null 2>&1 || die "systemctl not found"
}

check_base_url() {
    case "${BASE_URL}" in
        https://*) ;;
        http://*)
            # Downloading a binary that will run as root with CAP_SYS_TIME over
            # a channel anyone on the path can rewrite is not a decision to make
            # silently on someone's behalf.
            [[ "${NTP_RELAY_ALLOW_INSECURE:-}" == "1" ]] || die \
"refusing to download over plain http from ${BASE_URL}.
    Use https, or set NTP_RELAY_ALLOW_INSECURE=1 if this is a trusted local mirror."
            warn "downloading over plain http because NTP_RELAY_ALLOW_INSECURE=1"
            ;;
        *) die "NTP_RELAY_BASE_URL must start with https:// (got '${BASE_URL}')" ;;
    esac
    BASE_URL="${BASE_URL%/}"
}

detect_arch() {
    local machine
    machine="$(uname -m)"
    case "${machine}" in
        x86_64|amd64)  echo amd64 ;;
        aarch64|arm64) echo arm64 ;;
        armv7l|armv6l) echo arm ;;
        i386|i686)     echo 386 ;;
        *) die "unsupported architecture '${machine}'. Supported: x86_64, aarch64, armv7l, i686" ;;
    esac
}

# ------------------------------------------------------------- download -----

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

# fetch downloads a URL to a file, failing on any HTTP error rather than
# writing an error page to disk and pretending it is a binary.
fetch() {
    local url="$1" dest="$2"
    if have curl; then
        curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 600 \
             -o "${dest}" "${url}"
    elif have wget; then
        wget -q --tries=3 --timeout=60 -O "${dest}" "${url}"
    else
        die "neither curl nor wget is installed; cannot download anything"
    fi
}

fetch_optional() {
    local url="$1" dest="$2"
    fetch "${url}" "${dest}" 2>/dev/null || return 1
}

# artifact_url builds the download URL, honouring a pinned version.
artifact_url() {
    local name="$1"
    if [[ -n "${VERSION}" ]]; then
        printf '%s/v%s/%s' "${BASE_URL}" "${VERSION#v}" "${name}"
    else
        printf '%s/%s' "${BASE_URL}" "${name}"
    fi
}

sha256_of() {
    if have sha256sum; then
        sha256sum "$1" | awk '{print $1}'
    elif have shasum; then
        shasum -a 256 "$1" | awk '{print $1}'
    else
        return 1
    fi
}

# verify_checksum checks a downloaded file against the published SHA256SUMS.
#
# This is the step that makes "curl | bash" defensible. Without it, anything
# able to answer for the host, or to write to the web root, could hand this
# machine a binary that runs as root and is allowed to set the clock.
verify_checksum() {
    local file="$1" name="$2" sums="${WORK_DIR}/SHA256SUMS" expected actual

    if [[ "${NTP_RELAY_SKIP_VERIFY:-}" == "1" ]]; then
        warn "skipping checksum verification because NTP_RELAY_SKIP_VERIFY=1"
        return 0
    fi

    if [[ ! -f "${sums}" ]]; then
        if ! fetch_optional "$(artifact_url SHA256SUMS)" "${sums}"; then
            die "could not download $(artifact_url SHA256SUMS).
    The installer will not run an unverified binary as root.
    If you are using a mirror that does not publish checksums, set
    NTP_RELAY_SKIP_VERIFY=1 to override this deliberately."
        fi
    fi

    expected="$(awk -v n="${name}" '$2 == n || $2 == "*" n {print $1}' "${sums}" | head -n1)"
    [[ -n "${expected}" ]] || die "${name} is not listed in SHA256SUMS; refusing to install it"

    actual="$(sha256_of "${file}")" || die "no sha256sum or shasum available to verify the download"

    if [[ "${actual}" != "${expected}" ]]; then
        die "checksum mismatch for ${name}
    expected ${expected}
    actual   ${actual}
    The download is corrupt or has been tampered with. Nothing has been installed."
    fi
    ok "verified ${name} (sha256 ${actual:0:16}...)"
}

# obtain places a required artifact into WORK_DIR, from a local checkout if
# this script is running inside one, otherwise from the network.
#
# local_paths are tried in order; the first that exists wins. This is what lets
# one script serve both "curl | bash" and "run it from a git clone".
obtain() {
    local name="$1"; shift
    local dest="${WORK_DIR}/${name}" candidate

    for candidate in "$@"; do
        if [[ -n "${candidate}" && -f "${candidate}" ]]; then
            cp -f "${candidate}" "${dest}"
            ok "using local ${candidate}"
            printf '%s' "${dest}"
            return 0
        fi
    done

    fetch "$(artifact_url "${name}")" "${dest}" \
        || die "could not download $(artifact_url "${name}")"
    printf '%s' "${dest}"
}

# stage_artifacts collects everything needed before touching the system, so a
# failure halfway through a download cannot leave a half-installed service.
stage_artifacts() {
    local arch="$1" binary_name="${BIN_NAME}-linux-${arch}"

    BINARY_SRC="$(obtain "${binary_name}" \
        "${REPO_DIR:+${REPO_DIR}/dist/${binary_name}}" \
        "${SCRIPT_DIR:+${SCRIPT_DIR}/${binary_name}}")"
    if [[ "${BINARY_SRC}" == "${WORK_DIR}/${binary_name}" && ! -f "${REPO_DIR:-/nonexistent}/dist/${binary_name}" ]]; then
        verify_checksum "${BINARY_SRC}" "${binary_name}"
    fi
    chmod 0755 "${BINARY_SRC}"

    # A downloaded file that is not an executable for this machine is the
    # symptom of a web server returning an error page or a redirect to HTML.
    if ! head -c 4 "${BINARY_SRC}" | grep -q $'\x7fELF'; then
        die "the downloaded ${binary_name} is not a Linux executable.
    Check that ${BASE_URL} is serving the binary and not an error page."
    fi

    CONFIG_SRC="$(obtain ntp-relay.yaml \
        "${REPO_DIR:+${REPO_DIR}/ntp-relay.yaml}" \
        "${SCRIPT_DIR:+${SCRIPT_DIR}/../ntp-relay.yaml}")"
    UNIT_SRC="$(obtain ntp-relay.service \
        "${REPO_DIR:+${REPO_DIR}/deploy/ntp-relay.service}" \
        "${SCRIPT_DIR:+${SCRIPT_DIR}/ntp-relay.service}")"

    # Documentation is nice to have and never worth failing over.
    README_SRC=""
    if [[ -n "${REPO_DIR}" && -f "${REPO_DIR}/README.md" ]]; then
        README_SRC="${REPO_DIR}/README.md"
    elif fetch_optional "$(artifact_url README.md)" "${WORK_DIR}/README.md"; then
        README_SRC="${WORK_DIR}/README.md"
    fi
}

# --------------------------------------------------------------- install ----

create_user() {
    if getent group "${SVC_GROUP}" >/dev/null; then
        ok "group ${SVC_GROUP} exists"
    else
        groupadd --system "${SVC_GROUP}"
        ok "created group ${SVC_GROUP}"
    fi

    if getent passwd "${SVC_USER}" >/dev/null; then
        ok "user ${SVC_USER} exists"
    else
        useradd --system --gid "${SVC_GROUP}" \
                --home-dir "${STATE_DIR}" --no-create-home \
                --shell /usr/sbin/nologin \
                --comment "netio.ntp-relay NTP server" \
                "${SVC_USER}"
        ok "created system user ${SVC_USER}"
    fi
}

install_binary() {
    install -o root -g root -m 0755 "${BINARY_SRC}" "${BIN_DIR}/${BIN_NAME}"
    ok "installed ${BIN_DIR}/${BIN_NAME} ($("${BIN_DIR}/${BIN_NAME}" version 2>/dev/null || echo 'version unknown'))"
}

install_config() {
    install -d -o root -g "${SVC_GROUP}" -m 0750 "${CONF_DIR}"

    if [[ -f "${CONF_FILE}" ]]; then
        # An operator's configuration is theirs. Ship the new sample alongside
        # so they can see what changed, but never overwrite what is running.
        install -o root -g "${SVC_GROUP}" -m 0640 "${CONFIG_SRC}" "${CONF_FILE}.sample"
        ok "kept existing ${CONF_FILE}; new sample at ${CONF_FILE}.sample"
        return
    fi
    install -o root -g "${SVC_GROUP}" -m 0640 "${CONFIG_SRC}" "${CONF_FILE}"
    ok "installed ${CONF_FILE}"
}

install_state_dir() {
    install -d -o "${SVC_USER}" -g "${SVC_GROUP}" -m 0750 "${STATE_DIR}"
    ok "state directory ${STATE_DIR}"
}

install_unit() {
    install -o root -g root -m 0644 "${UNIT_SRC}" "${UNIT_FILE}"
    systemctl daemon-reload
    ok "installed ${UNIT_FILE}"
}

install_docs() {
    [[ -n "${README_SRC}" ]] || return 0
    install -d -m 0755 "${DOC_DIR}"
    install -m 0644 "${README_SRC}" "${DOC_DIR}/README.md"
    install -m 0644 "${CONFIG_SRC}" "${DOC_DIR}/ntp-relay.yaml"
    ok "documentation in ${DOC_DIR}"
}

# disable_conflicts stops anything else that disciplines the clock.
#
# Two time daemons on one machine is not a degraded configuration, it is a
# broken one: they fight over the same clock and the result is worse than
# either alone. Ubuntu enables systemd-timesyncd by default, so this is the
# normal case rather than an unusual one.
disable_conflicts() {
    local svc found=0 state
    for svc in "${CONFLICTING_SERVICES[@]}"; do
        # list-unit-files exits 0 with empty output for an absent unit, so the
        # test is on the output rather than the status. It lists masked units
        # too, which "systemctl cat" does not: a masked unit is a symlink to
        # /dev/null and cat reports it as missing.
        if [[ -z "$(systemctl list-unit-files --no-legend "${svc}" 2>/dev/null)" ]]; then
            continue
        fi
        found=1

        state="$(systemctl is-enabled "${svc}" 2>/dev/null || true)"
        if [[ "${state}" == "masked" || "${state}" == "masked-runtime" ]]; then
            ok "${svc} is already masked"
            continue
        fi

        if systemctl is-active --quiet "${svc}"; then
            systemctl stop "${svc}" || warn "could not stop ${svc}"
            ok "stopped ${svc}"
        fi
        if [[ "${state}" == "enabled" || "${state}" == "enabled-runtime" || "${state}" == "alias" ]]; then
            systemctl disable "${svc}" >/dev/null 2>&1 || warn "could not disable ${svc}"
            ok "disabled ${svc}"
        fi
        # Masking is what stops it coming back: a package update or a
        # dependency can otherwise re-enable it behind your back.
        if systemctl mask "${svc}" >/dev/null 2>&1; then
            ok "masked ${svc}"
        else
            warn "could not mask ${svc}"
        fi
    done
    [[ ${found} -eq 1 ]] || ok "no conflicting time daemons present"

    # timedatectl's NTP switch drives timesyncd; turn it off so the desktop
    # tooling agrees with what we have done.
    if have timedatectl; then
        timedatectl set-ntp false >/dev/null 2>&1 || true
    fi
}

check_port() {
    have ss || return 0

    # Our own daemon may already hold the port when this is an upgrade, which
    # is not a conflict: start_service restarts it in place.
    # ss columns: Netid State Recv-Q Send-Q Local:Port Peer:Port Process
    local holder
    holder="$(ss -lunp 2>/dev/null \
        | awk '$5 ~ /:123$/ {print $NF}' \
        | grep -v "${BIN_NAME}" \
        | head -n1 || true)"

    if [[ -n "${holder}" ]]; then
        warn "something else is already listening on UDP/123: ${holder}"
        warn "the service will fail to start until it is stopped"
    else
        ok "UDP/123 is available"
    fi
}

validate_config() {
    # Validating before starting turns a crash loop into one clear message.
    if "${BIN_DIR}/${BIN_NAME}" check -config "${CONF_FILE}"; then
        ok "configuration is valid"
    else
        die "configuration at ${CONF_FILE} is not valid; fix it and re-run"
    fi
}

start_service() {
    systemctl enable "${UNIT_NAME}" >/dev/null 2>&1
    ok "enabled at boot"

    if systemctl is-active --quiet "${UNIT_NAME}"; then
        systemctl restart "${UNIT_NAME}"
        ok "restarted"
    else
        systemctl start "${UNIT_NAME}"
        ok "started"
    fi
}

# verify checks that what we installed is actually working, rather than
# assuming that the absence of an error meant success.
verify() {
    local i
    for i in $(seq 1 15); do
        systemctl is-active --quiet "${UNIT_NAME}" && break
        sleep 1
    done

    if ! systemctl is-active --quiet "${UNIT_NAME}"; then
        printf '\n'
        warn "the service is not running. Recent log:"
        journalctl -u "${UNIT_NAME}" -n 30 --no-pager || true
        die "installation completed but the service did not start"
    fi
    ok "service is active"

    for i in $(seq 1 15); do
        if "${BIN_DIR}/${BIN_NAME}" status -config "${CONF_FILE}" >/dev/null 2>&1; then
            ok "control interface is answering"
            return 0
        fi
        sleep 1
    done
    warn "the control interface did not answer within 15s; check 'journalctl -u ${UNIT_NAME}'"
}

summary() {
    printf '\n%snetio.ntp-relay is installed and running.%s\n\n' "${C_GREEN}${C_BOLD}" "${C_RESET}"
    cat <<EOF
  Configuration   ${CONF_FILE}
  Binary          ${BIN_DIR}/${BIN_NAME}
  Drift file      ${STATE_DIR}/state
  Service         ${UNIT_NAME}   (enabled at boot, Restart=always)

  sudo ntp-relay status     overall synchronization state
  sudo ntp-relay sources    per-upstream measurements and verdicts
  sudo ntp-relay stats      packet counters
  systemctl reload ntp-relay    apply configuration changes
  journalctl -u ntp-relay -f    follow the log

  To query it without sudo, join the ${SVC_GROUP} group and log in again:
      sudo usermod -aG ${SVC_GROUP} \$USER

EOF
    printf '%sOn a first start with no drift file the daemon spends about 15 minutes
measuring this machine'"'"'s oscillator, and advertises stratum 16 until it is
done. That is deliberate: it will not serve time it cannot vouch for. Later
restarts reuse the measurement and synchronize in seconds.%s\n\n' "${C_YELLOW}" "${C_RESET}"
}

do_install() {
    require_root
    require_systemd
    check_base_url

    local arch
    arch="$(detect_arch)"
    step "Installing netio.ntp-relay for linux/${arch}"
    [[ -n "${VERSION}" ]] && ok "pinned to version ${VERSION}"

    WORK_DIR="$(mktemp -d)"

    step "Fetching"
    stage_artifacts "${arch}"

    step "Creating the service account"
    create_user

    step "Installing files"
    install_binary
    install_config
    install_state_dir
    install_unit
    install_docs

    step "Clearing the way for it to own the clock"
    disable_conflicts
    check_port

    step "Checking the configuration"
    validate_config

    step "Starting the service"
    start_service
    verify

    summary
}

# ------------------------------------------------------------- uninstall ----

do_uninstall() {
    local purge="$1"
    require_root

    step "Removing netio.ntp-relay"

    if [[ -n "$(systemctl list-unit-files --no-legend "${UNIT_NAME}" 2>/dev/null)" ]]; then
        systemctl disable --now "${UNIT_NAME}" >/dev/null 2>&1 || true
        ok "stopped and disabled ${UNIT_NAME}"
    fi
    rm -f "${UNIT_FILE}"
    systemctl daemon-reload
    systemctl reset-failed "${UNIT_NAME}" >/dev/null 2>&1 || true
    ok "removed the unit file"

    rm -f "${BIN_DIR}/${BIN_NAME}"
    rm -rf "${DOC_DIR}"
    ok "removed the binary and documentation"

    if [[ "${purge}" == "yes" ]]; then
        rm -rf "${CONF_DIR}" "${STATE_DIR}"
        ok "removed the configuration and drift file"
        if getent passwd "${SVC_USER}" >/dev/null; then
            userdel "${SVC_USER}" >/dev/null 2>&1 || warn "could not remove the user ${SVC_USER}"
            ok "removed the user ${SVC_USER}"
        fi
        getent group "${SVC_GROUP}" >/dev/null && groupdel "${SVC_GROUP}" >/dev/null 2>&1 || true
    else
        ok "kept ${CONF_DIR} and ${STATE_DIR} (use --purge to remove them)"
    fi

    # Leaving the machine with no timekeeping at all would be a worse state
    # than we found it in, so say so plainly rather than silently.
    printf '\n'
    warn "this machine now has no time daemon running."
    warn "to restore Ubuntu's default:"
    warn "    sudo systemctl unmask systemd-timesyncd && sudo timedatectl set-ntp true"
    printf '\n'
}

# ------------------------------------------------------------------ main ----

main() {
    local action=install
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --install|install)     action=install ;;
            --uninstall|uninstall) action=uninstall ;;
            --purge|purge)         action=purge ;;
            --version)
                [[ $# -ge 2 ]] || die "--version needs a value"
                VERSION="$2"; shift ;;
            --version=*)           VERSION="${1#*=}" ;;
            -h|--help|help)        usage; return 0 ;;
            *) die "unknown option '$1' (try --help)" ;;
        esac
        shift
    done

    case "${action}" in
        install)   do_install ;;
        uninstall) do_uninstall no ;;
        purge)     do_uninstall yes ;;
    esac
}

main "$@"
