#!/bin/sh
# jssh device agent — one-command installer.
#
#   curl -fsSL https://get.jssh.io | sh                              # browser-approve
#   curl -fsSL https://get.jssh.io | JSSH_ENROLL_TOKEN=<TOKEN> sh    # token (fleets)
#   curl -fsSL .../install.sh | sh -s -- --token <TOKEN> --name <NAME>
#
# No curl/wget on the box (Ubuntu Core ships neither)? Bootstrap with python3 —
# the custom User-Agent matters, Cloudflare 403s python's default one:
#   python3 -c "import urllib.request as u,subprocess; subprocess.run(['sh'],
#     input=u.urlopen(u.Request('https://get.jssh.io',headers={'User-Agent':'jssh-install'})).read())"
# (the script's own downloads already fall back to python3 by themselves)
#
# With NO token, the agent prints a short code + dashboard URL and waits for an
# operator to approve THIS device in the browser (good for installing one box by
# hand). With a token it enrolls non-interactively (fleets / golden images).
#
# UPDATE: just re-run this same one-liner (reusing the enrolled identity — no
# re-enroll). On a slot-managed box (systemd/openrc/procd) the RUNNING agent is
# updated through the SIGNED update channel: the re-run triggers a signed
# self-update rather than seeding the unsigned download into the boot slots.
# On platforms without slots (sysv, Batocera, Termux, macOS) it swaps the binary
# directly and restarts the service. Already newest + running ⇒ does nothing.
#
# Works from a root shell (containers, routers, NVRs — no sudo there) AND from a
# sudo-capable user: when not root, only the privileged steps self-elevate.
#
# This script ONLY downloads + verifies the right static binary and places it.
# The binary's own `install` subcommand then detects the init system, enrolls
# the device, writes the service unit, and starts it — one smart binary instead
# of a per-distro packaging matrix. Auto-update is ON by default (trust anchor
# + update base are embedded in the binary); opt out with --no-auto-update.
#
# Automation/golden-image: set JSSH_ENROLL_TOKEN in the env
# instead of passing flags. Air-gapped/testing: set JSSH_AGENT_BINARY=/path to
# skip the download and install a local binary.
set -eu

# ---- defaults (env-overridable) --------------------------------------------
JSSH_DOWNLOAD_BASE="${JSSH_DOWNLOAD_BASE:-https://get.jssh.io}"
JSSH_VERSION="${JSSH_VERSION:-latest}"
JSSH_RELAY_URL="${JSSH_RELAY_URL:-https://app.jssh.io}"
JSSH_PREFIX="${JSSH_PREFIX:-/usr/local/bin}"
JSSH_AGENT_BINARY="${JSSH_AGENT_BINARY:-}"

TOKEN="${JSSH_ENROLL_TOKEN:-}"
URL="$JSSH_RELAY_URL"
TARGET="127.0.0.1:22"
NAME=""
TAGS=""
PRINT=0
AUTO_UPDATE=1
[ "${JSSH_NO_AUTO_UPDATE:-0}" = "1" ] && AUTO_UPDATE=0

say() { printf 'jssh: %s\n' "$*"; }
err() { printf 'jssh: error: %s\n' "$*" >&2; exit 1; }
# Guard value-taking options so a missing trailing value gives a friendly error
# instead of a raw `$2: unbound variable` under `set -u`. Call: needval "$1" "$#".
needval() { [ "$2" -ge 2 ] || err "$1 needs a value"; }
# True if a jssh-agent is currently running (linux `pidof` / macOS `pgrep`).
agent_running() { pidof jssh-agent >/dev/null 2>&1 || pgrep -x jssh-agent >/dev/null 2>&1; }
# Version string of a jssh-agent binary ("0.2.15"), or empty if it can't run.
# MUST return 0 on a missing binary: it's called in `var="$(...)"` assignments,
# and under `set -e` a nonzero assignment kills the whole script — on a FIRST
# install (no $dest yet) it died silently right after "checksum ok".
agent_ver() { [ -x "$1" ] || return 0; "$1" --version 2>/dev/null | awk 'NR==1{print $NF}'; }

usage() {
  cat <<'EOF'
jssh agent installer

Usage: install.sh [options]
  --token <TOKEN>     enrollment token (or env JSSH_ENROLL_TOKEN); omit to
                      browser-approve this device in the dashboard instead
  --version <v>       release to install (default: latest)
  --target <host:port> local service to expose (default 127.0.0.1:22)
  --name <n>          device name (default: hostname, chosen by the relay)
  --tag <k:v>         tag to attach (repeatable)
  --prefix <dir>      install dir for the binary (default /usr/local/bin)
  --no-auto-update    skip the signed A/B auto-update setup (or JSSH_NO_AUTO_UPDATE=1)
  --print             show the service unit instead of installing it
  -h, --help          this help

To UPDATE an installed agent, just re-run this installer (same command). Slot-
managed boxes (systemd/openrc/procd) update the running agent via the SIGNED
channel; direct installs swap + restart the binary. Identity is reused.
EOF
}

# ---- args (accept --opt=val and --opt val) ---------------------------------
while [ $# -gt 0 ]; do
  # Valueless flags first, so the --opt=val splitter below can't inject a phantom
  # value (`--print=1` would otherwise fall through as "unknown option: 1").
  case "$1" in
    --print|--print=*) PRINT=1; shift; continue ;;
    --no-auto-update|--no-auto-update=*) AUTO_UPDATE=0; shift; continue ;;
    -h|--help)         usage; exit 0 ;;
    --*=*)             k="${1%%=*}"; v="${1#*=}"; shift; set -- "$k" "$v" "$@"; continue ;;
  esac
  case "$1" in
    --token)   needval "$1" "$#"; TOKEN="$2"; shift 2 ;;
    --url)     needval "$1" "$#"; URL="$2"; shift 2 ;;
    --version) needval "$1" "$#"; JSSH_VERSION="$2"; shift 2 ;;
    --target)  needval "$1" "$#"; TARGET="$2"; shift 2 ;;
    --name)    needval "$1" "$#"; NAME="$2"; shift 2 ;;
    --tag)     needval "$1" "$#"; TAGS="${TAGS:+$TAGS,}$2"; shift 2 ;;
    --prefix)  needval "$1" "$#"; JSSH_PREFIX="$2"; shift 2 ;;
    *)         err "unknown option: $1 (try --help)" ;;
  esac
done

# ---- platform detection ----------------------------------------------------
os="$(uname -s)"
arch="$(uname -m)"
# Termux (Android): uname says Linux, but the musl binaries can't run there
# (Android's loader wants PIE, and musl DNS needs /etc/resolv.conf) — it gets a
# real bionic build. Every Termux session exports TERMUX_VERSION; the path
# fallback is gated on `uname -o` = Android because proot/chroot distros on a
# phone (Linux Deploy, proot-distro) bind-mount the Termux prefix while being
# real Linux userlands (coreutils there reports GNU/Linux) that need musl.
IS_TERMUX=0
if [ -n "${TERMUX_VERSION:-}" ]; then
  IS_TERMUX=1
elif [ -d /data/data/com.termux/files/usr ] && [ "$(uname -o 2>/dev/null)" = "Android" ]; then
  IS_TERMUX=1
fi
case "$os" in
  Linux)
    if [ "$IS_TERMUX" -eq 1 ]; then
      case "$arch" in
        aarch64|arm64) target="aarch64-linux-android" ;;
        *) err "unsupported Termux architecture: $arch (only aarch64 for now)" ;;
      esac
    else
    case "$arch" in
      x86_64|amd64)  target="x86_64-unknown-linux-musl" ;;
      aarch64|arm64) target="aarch64-unknown-linux-musl" ;;
      armv7l)        target="armv7-unknown-linux-musleabihf" ;;
      armv6l)        target="arm-unknown-linux-musleabihf" ;;
      *)             err "unsupported architecture: $arch" ;;
    esac
    fi ;;
  Darwin)
    # macOS runs the agent as a launchd LaunchDaemon (needs sudo). No slot
    # auto-update: launchd isn't a crash supervisor, so it gets a plain service.
    case "$arch" in
      arm64)  target="aarch64-apple-darwin" ;;
      x86_64) target="x86_64-apple-darwin" ;;
      *)      err "unsupported macOS architecture: $arch" ;;
    esac ;;
  *)
    err "this installer supports Linux and macOS (got $os). On Windows (elevated PowerShell): iwr $JSSH_DOWNLOAD_BASE/latest/install.ps1 -OutFile install.ps1; powershell -ExecutionPolicy Bypass -File .\\install.ps1" ;;
esac
say "platform: $os/$arch -> $target"

# ---- Batocera: read-only rootfs — persist everything under /userdata ---------
# /usr/local is overlay-tmpfs (lost on reboot); the agent's own `install`
# registers the service via the Batocera `services/` framework (not /etc/init.d).
IS_BATOCERA=0
# `-e` (not `-d`) mirrors the agent's `.exists()` check (config.rs) so both sides
# recognise a Batocera box identically. Auto-update is off here (no supervisor,
# SLOT_UPDATE stays 0 below), so JSSH_UPDATE_DIR is intentionally not set.
if [ -e /usr/share/batocera ]; then
  IS_BATOCERA=1
  JSSH_PREFIX=/userdata/system/jssh
  say "Batocera detected — installing under /userdata (persistent)"
fi

# ---- Termux: everything under $PREFIX (user-writable, on PATH), never root ---
# The agent's own `install` registers a runit service via termux-services
# (`pkg install termux-services` first). Slot auto-update stays off: there is no
# cron/systemd to fire the periodic check (runsvdir only supervises).
if [ "$IS_TERMUX" -eq 1 ]; then
  JSSH_PREFIX="${PREFIX:-/data/data/com.termux/files/usr}/bin"
  # Termux's sshd (pkg install openssh) listens on 8022, not 22 — retarget the
  # stock default so the out-of-the-box install exposes a live port. An explicit
  # --target always wins.
  [ "$TARGET" = "127.0.0.1:22" ] && TARGET="127.0.0.1:8022"
  say "Termux detected — installing to $JSSH_PREFIX (no root needed; target $TARGET)"
  # Preflight the deps BEFORE downloading/enrolling: the service install would
  # otherwise fail AFTER the single-use token is spent, and without sshd the
  # first `jssh ssh` gets connection refused on a perfectly enrolled device.
  missing=""
  command -v sv >/dev/null 2>&1 || missing="termux-services"
  command -v sshd >/dev/null 2>&1 || missing="$missing openssh"
  if [ -n "$missing" ]; then
    err "missing Termux packages — run: pkg install$(printf ' %s' $missing) — then open a NEW session and re-run this installer"
  fi
fi

# ---- scope: system-wide (root) vs per-user (rootless / read-only rootfs) ----
# Ubuntu Core and similar immutable images have a read-only /usr and /etc, and run
# the agent as a `systemctl --user` service in $HOME. Detect them (Ubuntu Core id,
# or a read-only system unit dir) and install per-user with NO sudo. The same
# oneliner therefore works on both a normal server and an Ubuntu Core device.
am_root=0
[ "$(id -u)" = "0" ] && am_root=1
# Whether we can actually ELEVATE (not merely whether the `sudo` binary exists —
# a box can ship sudo while the user isn't a sudoer). `sudo -n true` succeeds
# only with NOPASSWD/cached creds; on an interactive TTY a plain sudo could
# still prompt, so also accept sudo-present + stdin-is-a-TTY. In a `curl | sh`
# pipe stdin is NOT a TTY, so a password-only sudo correctly reads as "can't".
# Termux never elevates (no-root install) and its tsu `sudo` shim chats on
# STDOUT ("No superuser binary detected. Are you rooted?") — skip the probe.
HAVE_SUDO=0
if [ "$am_root" -ne 1 ] && [ "$IS_TERMUX" -ne 1 ]; then
  if sudo -n true >/dev/null 2>&1; then
    HAVE_SUDO=1
  elif command -v sudo >/dev/null 2>&1 && [ -t 0 ]; then
    HAVE_SUDO=1
  elif command -v sudo >/dev/null 2>&1 && [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
    # `curl | sh`: stdin is the pipe, but the controlling terminal can still
    # prompt — validate (and cache) sudo creds via /dev/tty so the piped
    # one-liner works on a password-sudo box (the stock macOS/desktop-Linux
    # case, which previously hit "need root … and no sudo found"). The [ -t 2 ]
    # gate keeps non-interactive runs that redirect output (cloud-init, cron)
    # from ever reaching a password prompt.
    say "sudo needs your password:"
    if sudo -v </dev/tty >/dev/tty 2>&1; then HAVE_SUDO=1; fi
  fi
fi
USER_SCOPE=0
# Rootless (per-user systemd) mode is a Linux / Ubuntu-Core concept — never macOS,
# and only meaningful where systemd runs (it's a `systemctl --user` install).
if [ "$os" = "Linux" ]; then
  if grep -q '^ID=ubuntu-core' /etc/os-release 2>/dev/null; then
    USER_SCOPE=1
  elif [ "$am_root" -ne 1 ] && [ "$HAVE_SUDO" -ne 1 ] && [ -d /run/systemd/system ] \
       && [ ! -w /etc/systemd/system ] 2>/dev/null; then
    # Can't elevate, systemd IS present, and its unit dir is unwritable → the
    # immutable-rootfs rootless fallback. Requiring /run/systemd/system means a
    # non-systemd host (Alpine/OpenRC, minimal container) does NOT get demoted
    # to a bogus `systemctl --user` install; it hits the clear "need root" error.
    # A non-root user WHO CAN elevate stays system-scope (self-elevated below).
    USER_SCOPE=1
  fi
fi

if [ "$USER_SCOPE" -eq 1 ]; then
  # Per-user: everything under $HOME (writable + exec), no root needed.
  say "rootless host detected (Ubuntu Core / read-only rootfs) — installing per-user"
  [ "$am_root" -eq 1 ] && err "on Ubuntu Core run WITHOUT sudo (per-user install): curl -fsSL https://get.jssh.io | sh -s -- --token <TOKEN>"
  JSSH_PREFIX="${JSSH_PREFIX_USER:-$HOME/.local/bin}"
  : "${JSSH_UPDATE_DIR:=$HOME/.local/share/jssh}"
fi

# ---- privileges (system scope): root directly, or self-elevate via sudo -----
# Root shells (containers, routers, NVRs) have no sudo and don't need it; a
# sudo-capable user gets elevated only for the privileged steps. The same
# one-liner therefore works in both worlds.
SUDO=""
if [ "$IS_TERMUX" -ne 1 ] && [ "$USER_SCOPE" -ne 1 ] && [ "$am_root" -ne 1 ] && [ "$PRINT" -ne 1 ]; then
  if [ "$HAVE_SUDO" -eq 1 ]; then
    SUDO="sudo"
    say "not root — the install/service steps will use sudo"
  else
    err "need root (installs to $JSSH_PREFIX and sets up a service) and no sudo found — re-run from a root shell"
  fi
fi

# ---- helpers ---------------------------------------------------------------
fetch() { # url dest
  # python3 fallback: Ubuntu Core (and other minimal images) ship neither curl
  # nor wget, but do ship python3. Its stdlib urllib does verified HTTPS.
  if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2"
  elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1"
  elif command -v python3 >/dev/null 2>&1; then
    # A custom User-Agent is REQUIRED: Cloudflare 403s the default `Python-urllib`
    # UA (curl/wget UAs pass), so urlretrieve() — which sends it — would fail.
    python3 - "$1" "$2" <<'PY'
import sys, urllib.request
req = urllib.request.Request(sys.argv[1], headers={"User-Agent": "jssh-install"})
with urllib.request.urlopen(req) as r, open(sys.argv[2], "wb") as f:
    f.write(r.read())
PY
  else err "need curl, wget, or python3 to download"; fi
}

verify_sha256() { # file expected-file
  # Fail CLOSED: a missing checksum or no hashing tool aborts the install, so a
  # tampered/absent .sha256 can never silently skip verification of a root daemon.
  [ -f "$2" ] || err "checksum file missing — refusing to install an unverified binary"
  exp="$(awk '{print $1}' "$2")"
  [ -n "$exp" ] || err "empty/invalid checksum file"
  if command -v sha256sum >/dev/null 2>&1; then act="$(sha256sum "$1" | awk '{print $1}')"
  elif command -v shasum >/dev/null 2>&1; then act="$(shasum -a 256 "$1" | awk '{print $1}')"
  else err "no sha256 tool (sha256sum/shasum) found — refusing to install unverified"; fi
  [ "$exp" = "$act" ] || err "checksum mismatch (expected $exp, got $act)"
  say "checksum ok"
}

# ---- obtain the binary -----------------------------------------------------
tmp="$(mktemp -d "${TMPDIR:-/tmp}/jssh-install.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT
bin="$tmp/jssh-agent"

if [ -n "$JSSH_AGENT_BINARY" ]; then
  say "using local binary: $JSSH_AGENT_BINARY"
  cp "$JSSH_AGENT_BINARY" "$bin"
else
  base="$JSSH_DOWNLOAD_BASE/$JSSH_VERSION"
  say "downloading $base/jssh-agent-$target"
  fetch "$base/jssh-agent-$target" "$bin" || err "download failed"
  fetch "$base/jssh-agent-$target.sha256" "$tmp/sum" || err "checksum download failed ($base/jssh-agent-$target.sha256)"
  verify_sha256 "$bin" "$tmp/sum"
fi
chmod 0755 "$bin"

# ---- place it --------------------------------------------------------------
dest="$JSSH_PREFIX/jssh-agent"
if [ "$PRINT" -eq 1 ]; then
  dest="$bin" # print mode: run from temp, never write to the system (even as root)
else
  # Version-aware re-run: this turns "re-run the one-liner" into a safe update path.
  # If the downloaded release matches the installed one AND the agent is already
  # running AND no config-changing flag was passed, do NOTHING — bouncing a healthy
  # agent would drop live SSH sessions for no reason. A newer release swaps + restarts.
  new_ver="$(agent_ver "$bin")"
  cur_ver="$(agent_ver "$dest")"
  if [ -n "$cur_ver" ] && [ "$cur_ver" = "$new_ver" ] \
     && [ -z "$NAME" ] && [ -z "$TAGS" ] && [ "$TARGET" = "127.0.0.1:22" ] \
     && agent_running; then
    say "already up to date (jssh-agent $new_ver) and running — nothing to do"
    exit 0
  fi
  [ -n "$cur_ver" ] && [ "$cur_ver" != "$new_ver" ] \
    && say "updating jssh-agent $cur_ver -> ${new_ver:-?}"
  $SUDO install -m 0755 "$bin" "$dest" 2>/dev/null \
    || { $SUDO mkdir -p "$JSSH_PREFIX" && $SUDO cp "$bin" "$dest" && $SUDO chmod 0755 "$dest"; }
  say "installed $dest ($("$dest" --version 2>/dev/null || echo '?'))"
fi

# ---- auto-update (DEFAULT ON): the trust anchor + update base are embedded in
# the binary, so there is nothing to configure. Opt out with --no-auto-update /
# JSSH_NO_AUTO_UPDATE=1; self-hosted/test fleets override the anchors via
# JSSH_UPDATE_ROOT / JSSH_UPDATE_BASE (read by `install` through clap).
# Slot-based auto-update ships the jssh-boot launcher for Linux only (launchd on
# macOS is not a rollback supervisor, so it gets a plain service). Air-gapped
# installs (JSSH_AGENT_BINARY) have no download base for the launcher → plain
# service. SLOT_UPDATE gates the launcher download here and --update-root below.
SLOT_UPDATE=0
if [ "$IS_BATOCERA" -eq 1 ] || [ "$IS_TERMUX" -eq 1 ]; then
  : # no rollback supervisor / periodic trigger (Batocera respawn, Termux runit) — direct install
elif [ "$AUTO_UPDATE" -eq 1 ] && [ "$os" = "Linux" ] && [ -z "$JSSH_AGENT_BINARY" ]; then
  SLOT_UPDATE=1
elif [ "$AUTO_UPDATE" -eq 1 ] && [ "$os" != "Linux" ]; then
  say "note: slot-based auto-update is Linux-only; installing a plain $os service"
elif [ "$AUTO_UPDATE" -eq 1 ]; then
  say "note: local-binary install (air-gapped) — skipping the auto-update launcher"
fi
if [ "$SLOT_UPDATE" -eq 1 ]; then
  bootbin="$tmp/jssh-boot"
  say "downloading $base/jssh-boot-$target (auto-update launcher)"
  fetch "$base/jssh-boot-$target" "$bootbin" || err "jssh-boot download failed"
  fetch "$base/jssh-boot-$target.sha256" "$tmp/bootsum" || err "jssh-boot checksum download failed"
  verify_sha256 "$bootbin" "$tmp/bootsum"
  $SUDO install -m 0755 "$bootbin" "$JSSH_PREFIX/jssh-boot" 2>/dev/null \
    || { $SUDO cp "$bootbin" "$JSSH_PREFIX/jssh-boot" && $SUDO chmod 0755 "$JSSH_PREFIX/jssh-boot"; }
  say "installed $JSSH_PREFIX/jssh-boot"
fi

# ---- enroll + service (handled by the binary's `install` subcommand) -------
set -- install --relay "$URL" --target "$TARGET"
[ -n "$NAME" ] && set -- "$@" --name "$NAME"
[ -n "$TAGS" ] && set -- "$@" --tags "$TAGS"
[ "$PRINT" -eq 1 ] && set -- "$@" --print
# Per-user install (Ubuntu Core / rootless): `systemctl --user`, units in $HOME.
[ "$USER_SCOPE" -eq 1 ] && set -- "$@" --user
# Slot-based auto-update: base URL + trust anchor default to the values embedded
# in the binary; JSSH_UPDATE_BASE / JSSH_UPDATE_ROOT env override them (clap env).
[ "$SLOT_UPDATE" -eq 1 ] && set -- "$@" --update-root "${JSSH_UPDATE_DIR:-/var/lib/jssh}"

# Pass the single-use enrollment token via the ENVIRONMENT, never on argv: argv
# is world-readable through `ps` / `/proc/<pid>/cmdline` for the life of the
# enrollment, so a local user could scrape it. `jssh-agent install` reads it from
# JSSH_ENROLL_TOKEN (clap `env`). Under sudo, `-E` carries the exported vars
# across — and `-H` resets HOME to root's, or the system identity would land in
# the INVOKING user's ~/.config (breaking if that account is ever removed).
[ -n "$TOKEN" ] && export JSSH_ENROLL_TOKEN="$TOKEN"
[ -n "${JSSH_UPDATE_BASE:-}" ] && export JSSH_UPDATE_BASE
[ -n "${JSSH_UPDATE_ROOT:-}" ] && export JSSH_UPDATE_ROOT
say "enrolling device and installing service..."
# Updating a RUNNING agent restarts it — and if this very session is tunneled
# through it (jssh ssh / ssh <device>.jssh.dev to this box), the restart cuts
# the session and this shell's output stops mid-install. Say so BEFORE it
# happens: the install finishes on-device and the agent reconnects on its own.
if agent_running; then
  say "note: the running agent will restart — if you're connected THROUGH it, this session will drop now; the install continues on the device and the agent reconnects in seconds"
fi
if [ -n "$SUDO" ]; then
  exec sudo -H -E "$dest" "$@"
fi
exec "$dest" "$@"
