#!/bin/sh # HostSSH universal agent installer. # # curl -fsSL https://get.hostssh.com | sh # curl -fsSL https://get.hostssh.com | sh -s -- --license HSSH-XXXX-XXXX-XXXX # # Loads the HostSSH agent onto ANY VPS (Hostinger, Hetzner, DO, Vultr, AWS, bare # metal, BYO). Installs the single Go binary, prompts for a license key, validates # it against the control plane, and brings up the agent as a service. The license # unlocks the local admin panel + the backup / clone / relocate / Web-SSH features. # # Safe to re-run (idempotent). POSIX sh; no bashisms. set -eu # ── Config (overridable via env) ─────────────────────────────────────────── HOSTSSH_CHANNEL="${HOSTSSH_CHANNEL:-stable}" HOSTSSH_DL_BASE="${HOSTSSH_DL_BASE:-https://dl.hostssh.com}" HOSTSSH_API="${HOSTSSH_API:-https://api.hostssh.com}" HOSTSSH_PREFIX="${HOSTSSH_PREFIX:-/usr/local/bin}" HOSTSSH_ETC="${HOSTSSH_ETC:-/etc/hostssh}" HOSTSSH_LICENSE="${HOSTSSH_LICENSE:-}" HOSTSSH_PORT="${HOSTSSH_PORT:-8765}" # Where the capture/restore engine scripts live; the agent reads this env var. HOSTSSH_ENGINE_DIR="${HOSTSSH_ENGINE_DIR:-/usr/local/lib/hostssh/engine}" # HostPack + BuildKit are installed beside the agent so a fresh Node can build # apps without a manual Railpack/BuildKit bootstrap. HOSTSSH_HOSTPACK_BIN="${HOSTSSH_HOSTPACK_BIN:-${HOSTSSH_PREFIX}/hostpack}" HOSTSSH_BUILDKIT_ADDR="${HOSTSSH_BUILDKIT_ADDR:-tcp://127.0.0.1:1234}" HOSTSSH_BUILDKIT_ROOT="${HOSTSSH_BUILDKIT_ROOT:-/var/lib/hostssh/buildkit}" # Optional whitespace/newline-separated list. If unset, the installer fetches # hostpack-bases.txt from the release mirror. Every entry must be digest-pinned. HOSTSSH_HOSTPACK_BASE_IMAGES="${HOSTSSH_HOSTPACK_BASE_IMAGES:-}" # Host hardening is part of the reproducible build — a (re)built Node self-hardens # its firewall here rather than being hand-patched later. Set HOSTSSH_HARDEN=0 to # skip (rare). TCP defaults cover the managed proxy's 80/443 front door. HOSTSSH_HARDEN="${HOSTSSH_HARDEN:-1}" HOSTSSH_FIREWALL_TCP="${HOSTSSH_FIREWALL_TCP:-80,443}" HOSTSSH_FIREWALL_UDP="${HOSTSSH_FIREWALL_UDP:-}" # Escape hatch for dev mirrors that aren't signed yet. NEVER set on a real host: # it disables the binary signature/hash check that stops a tampered agent. HOSTSSH_ALLOW_UNVERIFIED="${HOSTSSH_ALLOW_UNVERIFIED:-0}" # Embedded minisign public key. The release pipeline signs every binary with the # matching secret key; this installer refuses to run a binary that doesn't verify # against this key. Replace the placeholder when the release signing key is minted # (the same key's pub is also baked into the agent via -ldflags). Until then, # unsigned dev mirrors require HOSTSSH_ALLOW_UNVERIFIED=1. HOSTSSH_MINISIGN_PUBKEY="${HOSTSSH_MINISIGN_PUBKEY:-RWTnXMxhi2jj1h2w+MwhDcJUA7aaVVakcG1qVEWpFOSMAr0Zqkz8jIP7}" # ── Pretty output ────────────────────────────────────────────────────────── if [ -t 1 ]; then BOLD="$(printf '\033[1m')"; DIM="$(printf '\033[2m')"; RED="$(printf '\033[31m')" GRN="$(printf '\033[32m')"; CYN="$(printf '\033[36m')"; RST="$(printf '\033[0m')" else BOLD=""; DIM=""; RED=""; GRN=""; CYN=""; RST="" fi say() { printf '%s\n' "$*"; } step() { printf '%s▸%s %s\n' "$CYN" "$RST" "$*"; } ok() { printf '%s✓%s %s\n' "$GRN" "$RST" "$*"; } die() { printf '%s✖ %s%s\n' "$RED" "$*" "$RST" >&2; exit 1; } # ── Args ─────────────────────────────────────────────────────────────────── while [ $# -gt 0 ]; do case "$1" in --license) HOSTSSH_LICENSE="${2:-}"; shift 2 ;; --license=*) HOSTSSH_LICENSE="${1#*=}"; shift ;; --channel) HOSTSSH_CHANNEL="${2:-stable}"; shift 2 ;; --role) HOSTSSH_NODE_ROLE="${2:-}"; shift 2 ;; --role=*) HOSTSSH_NODE_ROLE="${1#*=}"; shift ;; --help|-h) say "Usage: install.sh [--license KEY] [--channel stable|beta] [--role general|inference|automation]" exit 0 ;; *) die "Unknown argument: $1" ;; esac done # Optional fleet role (from dashboard Add-node wizard or HOSTSSH_NODE_ROLE env). HOSTSSH_NODE_ROLE="${HOSTSSH_NODE_ROLE:-}" # ── Preflight ────────────────────────────────────────────────────────────── [ "$(id -u)" = "0" ] || die "Run as root (sudo). The agent installs a system service." say "" say "${BOLD}HostSSH agent installer${RST} ${DIM}(channel: ${HOSTSSH_CHANNEL})${RST}" say "${DIM}Servers that can't be held hostage — backup · clone · relocate · Web-SSH${RST}" say "" OS="$(uname -s | tr '[:upper:]' '[:lower:]')" ARCH="$(uname -m)" case "$ARCH" in x86_64|amd64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; *) die "Unsupported architecture: $ARCH" ;; esac [ "$OS" = "linux" ] || die "The agent targets Linux hosts (found: $OS)." HOSTSSH_HOSTPACK_URL="${HOSTSSH_HOSTPACK_URL:-${HOSTSSH_DL_BASE}/${HOSTSSH_CHANNEL}/hostpack-${OS}-${ARCH}}" HOSTSSH_BUILDKITD_URL="${HOSTSSH_BUILDKITD_URL:-${HOSTSSH_DL_BASE}/${HOSTSSH_CHANNEL}/buildkitd-${OS}-${ARCH}}" HOSTSSH_BUILDCTL_URL="${HOSTSSH_BUILDCTL_URL:-${HOSTSSH_DL_BASE}/${HOSTSSH_CHANNEL}/buildctl-${OS}-${ARCH}}" HOSTSSH_HOSTPACK_BASES_URL="${HOSTSSH_HOSTPACK_BASES_URL:-${HOSTSSH_DL_BASE}/${HOSTSSH_CHANNEL}/hostpack-bases.txt}" have() { command -v "$1" >/dev/null 2>&1; } have curl || have wget || die "Need curl or wget." fetch() { if have curl; then curl -fsSL "$1"; else wget -qO- "$1"; fi; } fetch_out() { if have curl; then curl -fsSL "$1" -o "$2"; else wget -qO "$2" "$1"; fi; } # ── Supply-chain verification ──────────────────────────────────────────────── sha256_of() { if have sha256sum; then sha256sum "$1" | awk '{print $1}' elif have shasum; then shasum -a 256 "$1" | awk '{print $1}' else echo ""; fi } # minisign is the verifier the release pipeline signs with; install it from the # distro's signed package repo if it isn't already present. ensure_minisign() { have minisign && return 0 step "Installing minisign (signature verifier)" if have apt-get; then apt-get update -qq >/dev/null 2>&1; apt-get install -y -qq minisign >/dev/null 2>&1 elif have dnf; then dnf install -y -q minisign >/dev/null 2>&1 elif have yum; then yum install -y -q minisign >/dev/null 2>&1 elif have apk; then apk add --no-cache minisign >/dev/null 2>&1 elif have pacman; then pacman -Sy --noconfirm minisign >/dev/null 2>&1 elif have brew; then brew install minisign >/dev/null 2>&1 fi have minisign } # verify_binary refuses to proceed unless the downloaded binary carries a valid # minisign signature from the embedded HostSSH key. Fails CLOSED; the only bypass # is HOSTSSH_ALLOW_UNVERIFIED=1, for trusted dev mirrors only. verify_binary() { _bin="$1"; _sig_url="$2" # The minisign signature below is the real integrity+authenticity gate (it is a # signature over the file's content). The SHA-256 is shown only for the operator's # records — it is NOT the security check on its own. _sum="$(sha256_of "$_bin")" [ -n "$_sum" ] && step "SHA-256 ${DIM}${_sum}${RST} (informational)" # No real signing key minted yet → can't verify. Honour the override, else stop. case "$HOSTSSH_MINISIGN_PUBKEY" in RWQ0000000000000000000000000000000000000000000000000000000000000000000000000) if [ "$HOSTSSH_ALLOW_UNVERIFIED" = "1" ]; then say "${DIM}(no release signing key embedded yet; HOSTSSH_ALLOW_UNVERIFIED=1 — skipping signature check)${RST}" return 0 fi die "Refusing to install an unverified agent: no signing key is embedded in this installer build. Override only on a trusted dev mirror with HOSTSSH_ALLOW_UNVERIFIED=1." ;; esac _sigfile="${_bin}.minisig" if ! fetch_out "$_sig_url" "$_sigfile"; then [ "$HOSTSSH_ALLOW_UNVERIFIED" = "1" ] && { say "${DIM}(signature not found; HOSTSSH_ALLOW_UNVERIFIED=1 — skipping)${RST}"; return 0; } die "Signature ${_sig_url} not found — refusing to install an unverified binary." fi if ! ensure_minisign; then [ "$HOSTSSH_ALLOW_UNVERIFIED" = "1" ] && { say "${DIM}(minisign unavailable; HOSTSSH_ALLOW_UNVERIFIED=1 — skipping)${RST}"; return 0; } die "minisign is required to verify the agent and could not be installed. Install it and re-run." fi step "Verifying agent signature (minisign)" if minisign -V -P "$HOSTSSH_MINISIGN_PUBKEY" -m "$_bin" -x "$_sigfile" >/dev/null 2>&1; then ok "Signature verified" else die "Signature verification FAILED — the agent does not match the HostSSH signing key. Aborting (possible tampering)." fi } install_hostpack() { if [ -x "$HOSTSSH_HOSTPACK_BIN" ]; then ok "HostPack present (${HOSTSSH_HOSTPACK_BIN})" return 0 fi step "Downloading HostPack ${DIM}${HOSTSSH_HOSTPACK_URL}${RST}" TMP_HOSTPACK="$(mktemp)" if ! fetch_out "$HOSTSSH_HOSTPACK_URL" "$TMP_HOSTPACK"; then rm -f "$TMP_HOSTPACK" die "HostPack download failed. Publish hostpack-${OS}-${ARCH} to the ${HOSTSSH_CHANNEL} mirror or set HOSTSSH_HOSTPACK_URL." fi verify_binary "$TMP_HOSTPACK" "${HOSTSSH_HOSTPACK_URL}.minisig" chmod +x "$TMP_HOSTPACK" install -m 0755 "$TMP_HOSTPACK" "$HOSTSSH_HOSTPACK_BIN" rm -f "$TMP_HOSTPACK" "${TMP_HOSTPACK}.minisig" ok "Installed HostPack (${HOSTSSH_HOSTPACK_BIN})" } install_signed_tool() { _name="$1"; _url="$2"; _dest="$3" if [ -x "$_dest" ]; then ok "$_name present ($_dest)" return 0 fi step "Downloading ${_name} ${DIM}${_url}${RST}" _tmp="$(mktemp)" if ! fetch_out "$_url" "$_tmp"; then rm -f "$_tmp" die "${_name} download failed. Publish $(basename "$_url") to the ${HOSTSSH_CHANNEL} mirror or override its URL." fi verify_binary "$_tmp" "${_url}.minisig" chmod +x "$_tmp" install -m 0755 "$_tmp" "$_dest" rm -f "$_tmp" "${_tmp}.minisig" ok "Installed ${_name} ($_dest)" } install_buildkit_tools() { install_signed_tool "buildkitd" "$HOSTSSH_BUILDKITD_URL" "${HOSTSSH_PREFIX}/buildkitd" install_signed_tool "buildctl" "$HOSTSSH_BUILDCTL_URL" "${HOSTSSH_PREFIX}/buildctl" } write_hostpack_env() { mkdir -p "$HOSTSSH_ETC" cat > "${HOSTSSH_ETC}/hostpack.env" < /etc/systemd/system/hostssh-buildkit.service </dev/null 2>&1 || \ say "${DIM}(BuildKit service will start once buildkitd can bind ${HOSTSSH_BUILDKIT_ADDR})${RST}" ok "BuildKit service enabled" else say "${DIM}No systemd — start manually: BUILDKIT_HOST=${HOSTSSH_BUILDKIT_ADDR} ${HOSTSSH_PREFIX}/buildkitd --addr ${HOSTSSH_BUILDKIT_ADDR} --root ${HOSTSSH_BUILDKIT_ROOT}${RST}" fi } prepull_hostpack_bases() { _bases="$HOSTSSH_HOSTPACK_BASE_IMAGES" if [ -z "$_bases" ]; then TMP_BASES="$(mktemp)" if fetch_out "$HOSTSSH_HOSTPACK_BASES_URL" "$TMP_BASES"; then verify_binary "$TMP_BASES" "${HOSTSSH_HOSTPACK_BASES_URL}.minisig" _bases="$(cat "$TMP_BASES")" fi rm -f "${TMP_BASES}" "${TMP_BASES}.minisig" fi if [ -z "$_bases" ]; then say "${DIM}(HostPack base manifest not found; publish ${HOSTSSH_HOSTPACK_BASES_URL} or set HOSTSSH_HOSTPACK_BASE_IMAGES to pre-pull bases.)${RST}" return 0 fi step "Pre-pulling digest-pinned HostPack base images" _bases_file="$(mktemp)" printf '%s\n' "$_bases" > "$_bases_file" _pulled=0 while IFS= read -r line; do line="${line%%#*}" for image in $line; do [ -n "$image" ] || continue case "$image" in *@sha256:*) ;; *) rm -f "$_bases_file"; die "HostPack base image must be digest-pinned: $image" ;; esac if ! docker pull "$image" >/dev/null; then rm -f "$_bases_file" die "Could not pre-pull HostPack base image: $image" fi _pulled=$((_pulled + 1)) done done < "$_bases_file" rm -f "$_bases_file" if [ "$_pulled" -gt 0 ]; then ok "Pre-pulled ${_pulled} HostPack base image(s)" else say "${DIM}(HostPack base manifest was empty after comments/blank lines.)${RST}" fi } # detect_ssh_port reads the active SSH port from sshd_config so hardening keeps the # operator's real management port open (defaults to 22). Never lock anyone out. detect_ssh_port() { _p="" [ -r /etc/ssh/sshd_config ] && _p="$(awk '/^[Pp]ort[[:space:]]+[0-9]+/ {print $2; exit}' /etc/ssh/sshd_config 2>/dev/null)" [ -n "$_p" ] || _p=22 printf '%s' "$_p" } # harden_host runs the reproducible host-firewall posture as a BUILD step: a fresh # or rebuilt Node self-hardens (deny inbound, SSH rate-limited + kept open, proxy # 80/443 allowed) instead of relying on a hand-applied change that can't be # reproduced. `hostssh firewall` is ungated (works pre-activation) and idempotent. harden_host() { if [ "$HOSTSSH_HARDEN" != "1" ]; then say "${DIM}(host hardening skipped: HOSTSSH_HARDEN=0)${RST}" return 0 fi _ssh_port="$(detect_ssh_port)" step "Hardening host firewall ${DIM}(deny inbound; SSH ${_ssh_port} rate-limited; TCP ${HOSTSSH_FIREWALL_TCP})${RST}" set -- firewall --ssh-port "$_ssh_port" --tcp "$HOSTSSH_FIREWALL_TCP" [ -n "$HOSTSSH_FIREWALL_UDP" ] && set -- "$@" --udp "$HOSTSSH_FIREWALL_UDP" if "${HOSTSSH_PREFIX}/hostssh" "$@"; then ok "Host firewall hardened" else say "${RED}⚠ host firewall step did not complete — run '${HOSTSSH_PREFIX}/hostssh firewall' after install${RST}" fi } step "Host: ${OS}/${ARCH}" # Docker is required for full-server capture/restore of Coolify/Docker stacks. if have docker; then ok "Docker present" else step "Docker not found — installing (get.docker.com)" fetch https://get.docker.com | sh || die "Docker install failed." ok "Docker installed" fi # ── Download the agent binary ────────────────────────────────────────────── BIN_URL="${HOSTSSH_DL_BASE}/${HOSTSSH_CHANNEL}/hostssh-${OS}-${ARCH}" step "Downloading agent ${DIM}${BIN_URL}${RST}" TMP="$(mktemp)" if ! fetch_out "$BIN_URL" "$TMP"; then die "Download failed. (Phase-1 note: the binary endpoint is not live yet — set HOSTSSH_DL_BASE to a reachable mirror to test.)" fi # Verify signature + hash BEFORE the binary ever runs or is installed. verify_binary "$TMP" "${BIN_URL}.minisig" chmod +x "$TMP" install -m 0755 "$TMP" "${HOSTSSH_PREFIX}/hostssh" rm -f "$TMP" "${TMP}.minisig" ok "Installed ${HOSTSSH_PREFIX}/hostssh" # ── Engine scripts (capture / restore) ─────────────────────────────────────── # The agent shells out to the proven capture/restore engine until the Go port # lands; it resolves the scripts via HOSTSSH_ENGINE_DIR (default below). Ship # them next to the binary so a fresh box can capture/restore immediately. step "Installing capture/restore engine ${DIM}${HOSTSSH_ENGINE_DIR}${RST}" mkdir -p "$HOSTSSH_ENGINE_DIR" engine_ok=1 for script in fleet-capture.sh fleet-restore.sh restic-retention.sh; do if fetch_out "${HOSTSSH_DL_BASE}/${HOSTSSH_CHANNEL}/engine/${script}" "${HOSTSSH_ENGINE_DIR}/${script}"; then # retention helper is sourced (not executed) — 0644 is fine; keep 0755 for the runners if [ "$script" = "restic-retention.sh" ]; then chmod 0644 "${HOSTSSH_ENGINE_DIR}/${script}" else chmod 0755 "${HOSTSSH_ENGINE_DIR}/${script}"; fi else engine_ok=0 fi done if [ "$engine_ok" = "1" ]; then ok "Engine installed" else say "${DIM}(Phase-1 note: engine endpoint not live yet — capture/restore warns until ${HOSTSSH_ENGINE_DIR} is populated.)${RST}" fi # ── HostPack + BuildKit ───────────────────────────────────────────────────── install_hostpack install_buildkit_tools write_hostpack_env install_buildkit_service prepull_hostpack_bases # ── License key ──────────────────────────────────────────────────────────── if [ -z "$HOSTSSH_LICENSE" ]; then if [ -t 0 ]; then printf '%s' "Enter your HostSSH license key: " read -r HOSTSSH_LICENSE /dev/null 2>&1; then ok "License activated — features unlocked" else say "${DIM}(Phase-1 note: activation endpoint not live yet; writing a pending config.)${RST}" umask 077 cat > "${HOSTSSH_ETC}/agent.toml" </dev/null; then # shellcheck disable=SC2016 sed -i.bak "s/^role .*/role = \"${HOSTSSH_NODE_ROLE}\"/" "${HOSTSSH_ETC}/agent.toml" 2>/dev/null || true rm -f "${HOSTSSH_ETC}/agent.toml.bak" else printf 'role = "%s"\n' "$HOSTSSH_NODE_ROLE" >> "${HOSTSSH_ETC}/agent.toml" fi ok "Node role: ${HOSTSSH_NODE_ROLE}" fi # ── Service ──────────────────────────────────────────────────────────────── if have systemctl; then step "Installing systemd service" ROLE_ENV="" [ -n "${HOSTSSH_NODE_ROLE:-}" ] && ROLE_ENV="Environment=HOSTSSH_NODE_ROLE=${HOSTSSH_NODE_ROLE}" cat > /etc/systemd/system/hostssh-agent.service </dev/null 2>&1 || \ say "${DIM}(service will start once the binary is live)${RST}" ok "Service enabled" # Nightly capture — a Node must never rely on an out-of-repo cron for its backups. # `capture` is deliberately UNGATED (recovery is always yours), so this keeps running # even if the license lapses. note=nightly tells fleet-capture to skip the `manual` # keep-tag; lean GFS (7d/4w/3m) then prunes older nightlies on shared R2. step "Scheduling nightly backup" cat > /etc/systemd/system/hostssh-capture.service < /etc/systemd/system/hostssh-capture.timer < /etc/systemd/system/hostssh-drill.service < /etc/systemd/system/hostssh-drill.timer </dev/null 2>&1 || \ say "${DIM}(backup timer will arm once the binary is live)${RST}" systemctl enable --now hostssh-drill.timer >/dev/null 2>&1 || \ say "${DIM}(restore-drill timer will arm once the binary is live)${RST}" ok "Nightly backup scheduled (03:20 + jitter); monthly restore-drill (1st 04:40)" else say "${DIM}No systemd — start manually: hostssh agent --config ${HOSTSSH_ETC}/agent.toml${RST}" say "${DIM}Schedule backups manually: add 'hostssh capture --tag clone-\$(hostname)' to cron nightly.${RST}" say "${DIM}Schedule drills manually: monthly 'hostssh drill --mode restore'.${RST}" fi # ── Host hardening (reproducible build step, not a manual afterthought) ────── harden_host # ── Done ─────────────────────────────────────────────────────────────────── say "" ok "${BOLD}HostSSH is installed.${RST}" say "" say " Local admin panel : ${CYN}http://127.0.0.1:${HOSTSSH_PORT}${RST} (tunnel or bind via the panel)" say " CLI : ${CYN}hostssh status${RST} · ${CYN}hostssh capture${RST} · ${CYN}hostssh migrate --new-ip${RST}" say " Fleet dashboard : ${CYN}https://hostssh.com${RST} (this server now reports in)" say "" say "${DIM}Manage everything from the control plane, the CLI, the MCP server, or Web-SSH.${RST}"