#!/bin/sh
#
# Steid installer for a fresh Debian/Ubuntu server.
#
#     curl -fsSL https://.../install.sh | sh -s -- --domain git.example.com
#
# It installs Steid to /opt/steid, keeps state in /var/lib/steid, runs it as an
# unprivileged system user under systemd, and puts Caddy in front of it with an
# automatic HTTPS certificate for the domain you give.
#
# Re-running it is an upgrade: it downloads the requested version, replaces the
# binary and assets, rewrites the unit and the Caddyfile, and restarts. It never
# touches /var/lib/steid, and it never overwrites /etc/steid/steid.env once that
# exists, so anything you have edited there survives.
#
# ON `curl | sh`: you are being asked to run a script you have not read, as root,
# from a URL. That is a real trust decision and "it's convenient" is not an
# answer to it. Two honest alternatives: download it first and read it
# (`curl -fsSL … -o install.sh; less install.sh; sh install.sh --domain …`), or
# follow the manual path in README.md, which is the same dozen commands written
# out. Nothing here is magic; the script exists to save typing, not to be trusted
# blindly.

set -eu

# --- PLACEHOLDER ------------------------------------------------------------
#
# !! Nothing is published there yet — the host does not resolve until the first
# !! instance is up. That is the bootstrap `--tarball` exists for.
#
# The layout expected here, and produced by release.sh, is:
#
#     ${RELEASE_BASE_URL}/v${VERSION}/steid-${VERSION}-${TARGET}.tar.gz
#     ${RELEASE_BASE_URL}/v${VERSION}/steid-${VERSION}-${TARGET}.tar.gz.sha256
#
# Steid is distributed from a Steid instance rather than from a code-hosting
# service, which is the point of the project rather than a flourish. The cost is
# discovery: nobody stumbles across it. That is a marketing problem, not a
# technical dependency, and a mirror can solve it later without this URL moving.
# The *project's* distribution host — a constant, and NOT the `--domain` the
# person running this installs onto. Everyone downloads Steid from here; each
# installer then runs their own instance on their own hostname.
#
# Scoped under the repository rather than a root-level /releases: Steid serves
# profiles at /{handle}, so a root path would squat its own namespace. This is
# also exactly where a real release feature would put these files, so published
# links survive that feature landing.
RELEASE_BASE_URL="${STEID_RELEASE_BASE_URL:-https://jpgill.dev/jamesgill/repos/steid/releases}"

# The version to install. Pinned rather than "latest" because there is no
# redirect to resolve "latest" against, and a pinned default makes the upgrade
# path explicit: `--version 0.2.0`.
VERSION="${STEID_VERSION:-0.2.0}"
# ----------------------------------------------------------------------------

# The build target to fetch. musl is preferred — one static binary that does not
# care which glibc the host has — but whether Steid builds against musl at all is
# still being established, so the choice is a variable rather than a fact.
# Override with --flavour gnu if the published artefacts are glibc.
# gnu, matching what release.sh builds. musl was tried and rejected: it fails on
# `ring` with Debian's musl-gcc wrapper, and — the decisive part — musl buys a
# binary with no runtime dependencies while Steid hard-requires `git` on PATH, so
# the portability cannot be used. THIS MUST AGREE WITH release.sh: they were
# briefly out of step and the symptom was a confusing "checksum mismatch",
# because the installer was looking for a musl tarball that was never built.
FLAVOUR="gnu"

DOMAIN=""
# A local artefact to install instead of downloading one. This exists because of
# a bootstrap: the very first instance is what will *serve* the releases, so at
# that moment there is nowhere to download from. It doubles as the offline and
# air-gapped path.
TARBALL=""
PORT="3000"
INSTALL_DIR="/opt/steid"
STATE_DIR="/var/lib/steid"
CONF_DIR="/etc/steid"
STEID_USER="steid"
INSTALL_CADDY=1

die() { echo "install.sh: $*" >&2; exit 1; }
say() { echo "==> $*"; }

usage() {
    cat >&2 <<'USAGE'
Usage: install.sh --domain <hostname> [options]

  --tarball <path>      install from a local tarball instead of downloading.
                        Needed for the first install, which has nowhere to
                        download from yet, and for offline installs.
  --domain <hostname>   the public hostname, e.g. git.example.com. Its DNS must
                        already point at this machine or the certificate cannot
                        be issued. Required.
  --version <v>         release to install (default: the pinned one above)
  --flavour <musl|gnu>  which build to fetch (default: musl)
  --port <n>            loopback port Steid listens on (default: 3000)
  --no-caddy            install and run Steid but do not touch Caddy. Only for
                        putting your own TLS-terminating proxy in front. Steid
                        has no TLS of its own; without a proxy, access tokens
                        cross the network in cleartext.
  -h, --help            this
USAGE
    exit "${1:-0}"
}

# --- arguments --------------------------------------------------------------

while [ $# -gt 0 ]; do
    case "$1" in
        --domain)  DOMAIN="${2:-}"; [ -n "$DOMAIN" ] || die "--domain needs a hostname"; shift 2 ;;
        --tarball) TARBALL="${2:-}"; [ -n "$TARBALL" ] || die "--tarball needs a path"; shift 2 ;;
        --version) VERSION="${2:-}"; [ -n "$VERSION" ] || die "--version needs a value"; shift 2 ;;
        --flavour) FLAVOUR="${2:-}"; [ -n "$FLAVOUR" ] || die "--flavour needs a value"; shift 2 ;;
        --port)    PORT="${2:-}"; [ -n "$PORT" ] || die "--port needs a number"; shift 2 ;;
        --no-caddy) INSTALL_CADDY=0; shift ;;
        -h|--help) usage 0 ;;
        *) echo "install.sh: unknown argument: $1" >&2; usage 1 ;;
    esac
done

# Fail early and loudly rather than half-installing. Everything below this point
# assumes these hold.
[ "$(id -u)" = "0" ] || die "must run as root (try: sudo sh install.sh --domain …)"

if [ "$INSTALL_CADDY" = 1 ]; then
    [ -n "$DOMAIN" ] || die "--domain is required. Caddy needs a real hostname to
  obtain a certificate for, and Steid has no TLS of its own. If you are putting
  your own proxy in front, pass --no-caddy."
fi

if [ -n "$DOMAIN" ]; then
    case "$DOMAIN" in
        *[!A-Za-z0-9.-]*|-*|.*|*.) die "'$DOMAIN' does not look like a hostname" ;;
    esac
fi

case "$PORT" in
    ''|*[!0-9]*) die "--port must be a number" ;;
esac

case "$FLAVOUR" in
    musl|gnu) ;;
    *) die "--flavour must be 'musl' or 'gnu'" ;;
esac

case "${TARBALL:+local}${RELEASE_BASE_URL}" in
    local*) : ;;  # installing from a file; the download URL is irrelevant
    *REPLACE-ME*) die "RELEASE_BASE_URL is still the placeholder. Edit the top of
  this script (or set STEID_RELEASE_BASE_URL) to point at real release artefacts,
  or pass --tarball to install from a local file." ;;
esac

command -v systemctl >/dev/null 2>&1 || die "no systemd here; follow the manual path in README.md"
command -v apt-get >/dev/null 2>&1 || die "this installer only knows apt (Debian/Ubuntu).
  The manual path in README.md works on anything with systemd."

case "$(uname -m)" in
    x86_64|amd64)  ARCH="x86_64" ;;
    aarch64|arm64) ARCH="aarch64" ;;
    *) die "unsupported architecture: $(uname -m). Only x86_64 and aarch64 are built." ;;
esac

TARGET="${ARCH}-unknown-linux-${FLAVOUR}"
NAME="steid-${VERSION}-${TARGET}"
URL="${RELEASE_BASE_URL}/v${VERSION}/${NAME}.tar.gz"

say "installing Steid ${VERSION} (${TARGET}) for ${DOMAIN:-<no domain>}"

# --- prerequisites ----------------------------------------------------------

# git is not optional and not a runtime nicety: Steid shells out to `git init
# --bare` to create a repository and to `git http-backend` to serve every clone
# and push. Without it the install succeeds and the first repository fails.
say "installing prerequisites (git, curl, ca-certificates)"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq --no-install-recommends git curl ca-certificates

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

TMP="$(mktemp -d)"
# shellcheck disable=SC2064  # $TMP is expanded now on purpose: it never changes.
trap "rm -rf '$TMP'" EXIT INT TERM

if [ -n "$TARBALL" ]; then
    [ -f "$TARBALL" ] || die "--tarball: no such file: ${TARBALL}"
    say "installing from ${TARBALL}"
    cp "$TARBALL" "${TMP}/${NAME}.tar.gz"

    # A checksum beside a local file is verified when present, but not demanded:
    # whoever passes --tarball already chose the bytes, so refusing to proceed
    # without a .sha256 would block the bootstrap this option exists for.
    if [ -f "${TARBALL}.sha256" ]; then
        say "verifying checksum"
        # Compared by value, not with `sha256sum -c`: that matches on the filename
        # recorded inside the .sha256, which need not be what the file is called
        # by the time someone passes it here.
        EXPECTED="$(cut -d" " -f1 < "${TARBALL}.sha256")"
        ACTUAL="$(sha256sum < "${TMP}/${NAME}.tar.gz" | cut -d" " -f1)"
        [ "$EXPECTED" = "$ACTUAL" ] \
            || die "checksum mismatch — ${TARBALL} does not match its .sha256"
    else
        say "no ${TARBALL}.sha256 beside it; installing unverified"
    fi
else
    say "downloading ${URL}"
    curl -fsSL "$URL" -o "${TMP}/${NAME}.tar.gz" \
        || die "download failed. Is version ${VERSION} published for ${TARGET}?"
    curl -fsSL "${URL}.sha256" -o "${TMP}/${NAME}.tar.gz.sha256" \
        || die "checksum file missing next to the tarball; refusing to install unverified"

    say "verifying checksum"
    ( cd "$TMP" && sha256sum -c "${NAME}.tar.gz.sha256" >/dev/null ) \
        || die "checksum mismatch — the download is corrupt or tampered with"
fi

tar -xzf "${TMP}/${NAME}.tar.gz" -C "$TMP"
[ -x "${TMP}/${NAME}/steid" ] || die "tarball has no steid binary at ${NAME}/steid"
# The bundle must ship and must land beside the binary: AssetBundle::load() walks
# up from the executable looking for assets/manifest.toml and the process exits
# at startup without it.
[ -f "${TMP}/${NAME}/assets/manifest.toml" ] || die "tarball has no assets/manifest.toml"

# --- user and directories ---------------------------------------------------

if ! id "$STEID_USER" >/dev/null 2>&1; then
    say "creating system user ${STEID_USER}"
    # --home is the state directory: git wants a HOME, and giving it the one
    # directory the service can write keeps that from being a surprise later.
    useradd --system --home-dir "$STATE_DIR" --shell /usr/sbin/nologin "$STEID_USER"
fi

mkdir -p "$INSTALL_DIR" "$STATE_DIR" "$CONF_DIR"
# State is exactly two things: the SQLite database file and the repository
# directory. Both live here, and together they are the entire backup surface.
mkdir -p "${STATE_DIR}/repos"
chown -R "${STEID_USER}:${STEID_USER}" "$STATE_DIR"
chmod 750 "$STATE_DIR"

# --- install files ----------------------------------------------------------

# Stop before replacing the binary: overwriting a running executable in place
# fails with ETXTBSY, and a half-swapped install/assets pair would serve stale
# hashed CSS until the next restart anyway.
if systemctl is-active --quiet steid 2>/dev/null; then
    say "stopping steid for the upgrade"
    systemctl stop steid
fi

say "installing to ${INSTALL_DIR}"
install -m 0755 "${TMP}/${NAME}/steid" "${INSTALL_DIR}/steid"
rm -rf "${INSTALL_DIR}/assets"
cp -R "${TMP}/${NAME}/assets" "${INSTALL_DIR}/assets"
if [ -f "${TMP}/${NAME}/README.md" ]; then
    cp "${TMP}/${NAME}/README.md" "${INSTALL_DIR}/README.md"
fi
chown -R root:root "$INSTALL_DIR"
# Read-only to the service user on purpose: Steid never writes here.
chmod -R a+rX "$INSTALL_DIR"

# --- configuration ----------------------------------------------------------

# Written once and then left alone, so an upgrade cannot silently revert a
# setting someone deliberately changed.
if [ ! -f "${CONF_DIR}/steid.env" ]; then
    say "writing ${CONF_DIR}/steid.env"
    cat > "${CONF_DIR}/steid.env" <<EOF
# Steid configuration. Restart after editing: systemctl restart steid

# All state lives under ${STATE_DIR}. SQLite writes -wal and -shm siblings, so
# the directory must be writable, not just the file.
STEID_DATABASE_URL=sqlite:${STATE_DIR}/steid.db?mode=rwc
STEID_DATA_DIR=${STATE_DIR}/repos

# HOST and PORT are read by the web framework itself and are deliberately not
# STEID_-prefixed. Loopback only: Caddy is the way in, and binding 0.0.0.0 would
# expose plain HTTP — and therefore access tokens in cleartext — to the internet.
HOST=127.0.0.1
PORT=${PORT}

# Deliberately absent: STEID_INSECURE_COOKIES. It strips Secure from the session
# cookie and exists only for plain-HTTP local development. Setting it here would
# hand out a session cookie that any network hop can read.
EOF
    chmod 640 "${CONF_DIR}/steid.env"
    chown "root:${STEID_USER}" "${CONF_DIR}/steid.env"
else
    say "keeping existing ${CONF_DIR}/steid.env"
fi

say "writing /etc/systemd/system/steid.service"
cat > /etc/systemd/system/steid.service <<EOF
# Managed by install.sh. Re-running the installer rewrites this file.
[Unit]
Description=Steid
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=${STEID_USER}
Group=${STEID_USER}
WorkingDirectory=${INSTALL_DIR}
ExecStart=${INSTALL_DIR}/steid
EnvironmentFile=${CONF_DIR}/steid.env
Environment=HOME=${STATE_DIR}
Restart=on-failure
RestartSec=2s

NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
ReadWritePaths=${STATE_DIR}

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --quiet steid
say "starting steid"
systemctl restart steid

# --- caddy ------------------------------------------------------------------

if [ "$INSTALL_CADDY" = 1 ]; then
    if ! command -v caddy >/dev/null 2>&1; then
        say "installing Caddy from its official apt repository"
        apt-get install -y -qq --no-install-recommends debian-keyring debian-archive-keyring apt-transport-https gnupg
        curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
            | gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
        curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
            > /etc/apt/sources.list.d/caddy-stable.list
        apt-get update -qq
        apt-get install -y -qq caddy
    else
        say "Caddy already installed"
    fi

    # Rewritten every run so the domain and port always match this install. If
    # you have hand-edited it, back it up first — this is the one file the
    # installer overwrites.
    say "writing /etc/caddy/Caddyfile for ${DOMAIN}"
    mkdir -p /etc/caddy
    cat > /etc/caddy/Caddyfile <<EOF
# Managed by install.sh. Re-running the installer rewrites this file.
#
# Steid has no TLS of its own (Topcoat 0.5 ships none), and it authenticates git
# over HTTP Basic — so without this proxy every push would send a personal access
# token in cleartext. Caddy obtains and renews the certificate automatically,
# provided ${DOMAIN} resolves here and ports 80 and 443 are open.
${DOMAIN} {
	reverse_proxy 127.0.0.1:${PORT} {
		# Git's smart HTTP is a streaming protocol in both directions; buffering
		# it turns a clone into a long silence and can stall negotiation.
		flush_interval -1
	}
}
EOF
    systemctl enable --quiet caddy
    systemctl reload caddy 2>/dev/null || systemctl restart caddy
fi

# --- report -----------------------------------------------------------------

# A moment for the service to either come up or fall over, so the message below
# reflects reality rather than optimism.
sleep 2
if ! systemctl is-active --quiet steid; then
    echo >&2
    echo "install.sh: steid is installed but not running. Look at:" >&2
    echo "    journalctl -u steid -n 50 --no-pager" >&2
    exit 1
fi

if [ "$INSTALL_CADDY" = 1 ]; then
    BASE_URL="https://${DOMAIN}"
else
    BASE_URL="http://127.0.0.1:${PORT}  (put your own TLS proxy in front of this)"
fi

cat <<EOF

Steid ${VERSION} is running.

  Service     systemctl status steid
  Logs        journalctl -u steid -f
  Config      ${CONF_DIR}/steid.env
  State       ${STATE_DIR}   (the database and the repos — back up this directory)

Next: claim the instance.

  The setup token is printed to the log at startup, and ONLY while the instance
  is unclaimed. It is held in memory, so every restart mints a new one:

      journalctl -u steid --no-pager | tail -n 30

  Then open ${BASE_URL}/auth/setup and paste it in.

To upgrade later, re-run this script with a newer --version. It replaces the
binary and assets and leaves ${STATE_DIR} untouched.
EOF
