#!/bin/sh # Ghost-Net sensor installer. # # curl -fsSL https://install.leukquant.com | sudo sh -s -- --sec --id # # Options: # --sec 64-character sensor secret from the LeukQuant platform (or env GHOSTNET_SEC; # prompted from the terminal when neither is given) # --id honeypot ID, integer >= 1 (or env GHOSTNET_HONEYPOT_ID; default 1) # --port SSH honeypot port (default 2222; see the README before using 22) # --api-url platform ingest URL (default https://apix.leukquant.com) # --auto-upgrade install the daily upgrade timer # --set KEY=VALUE extra sensor setting, repeatable (passed to configure.py) # --archive --manifest # offline install from a downloaded release (still signature-checked) # # What it does: checks the SEC with the platform, downloads the current release, verifies its # SHA-256 and Ed25519 signature against the key below, installs it under /opt/ghostnet with its own # virtualenv, writes the encrypted config to /var/lib/ghostnet, and starts the `ghostnet` systemd # service. Afterwards manage the sensor with `ghostnet-cli` (alias `honeypot-cli`). set -eu # Replaced by release/build.py with the base64 raw Ed25519 public key. An installer that still holds # the placeholder refuses to run: there is no unsigned install path. RELEASE_SIGNING_PUBLIC_KEY="cDwAx/f0LhzZXBBjjw9A6Z8MChum/zONk7U1xXUKrZg=" API_URL="${GHOSTNET_API_URL:-https://apix.leukquant.com}" SEC="${GHOSTNET_SEC:-}" HONEYPOT_ID="${GHOSTNET_HONEYPOT_ID:-1}" PORT="2222" AUTO_UPGRADE="" ARCHIVE="" MANIFEST="" EXTRA_SETTINGS="" # Overridable for tests only; ghostnet-cli reads the same variable. INSTALL_ROOT="${GHOSTNET_INSTALL_ROOT:-/opt/ghostnet}" usage() { cat <<'USAGE' Ghost-Net sensor installer curl -fsSL https://install.leukquant.com | sudo sh -s -- --sec --id --sec 64-character sensor secret (or env GHOSTNET_SEC; prompted if absent) --id honeypot ID >= 1 (or env GHOSTNET_HONEYPOT_ID; default 1) --port SSH honeypot port (default 2222) --api-url platform ingest URL (default https://apix.leukquant.com) --auto-upgrade install the daily upgrade timer --set KEY=VALUE extra sensor setting, repeatable --archive --manifest offline install (still signature-checked) USAGE } info() { printf ' [*] %s\n' "$*"; } die() { printf ' [!] %s\n' "$*" >&2; exit 1; } while [ $# -gt 0 ]; do case "$1" in --sec) SEC="${2:-}"; shift 2 ;; --id) HONEYPOT_ID="${2:-}"; shift 2 ;; --port) PORT="${2:-}"; shift 2 ;; --api-url) API_URL="${2:-}"; shift 2 ;; --auto-upgrade) AUTO_UPGRADE="--auto-upgrade"; shift ;; --set) EXTRA_SETTINGS="$EXTRA_SETTINGS ${2:-}"; shift 2 ;; --archive) ARCHIVE="${2:-}"; shift 2 ;; --manifest) MANIFEST="${2:-}"; shift 2 ;; -h|--help) usage; exit 0 ;; *) die "unknown option: $1 (see --help)" ;; esac done case "$RELEASE_SIGNING_PUBLIC_KEY" in __*) die "this installer was not built by release/build.py (no signing key embedded)" ;; esac # --- preflight ------------------------------------------------------------------------------- [ "$(uname -s)" = "Linux" ] || die "Ghost-Net installs on Linux only" [ "$(id -u)" -eq 0 ] || die "run as root: curl -fsSL https://install.leukquant.com | sudo sh -s -- --sec " command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] || die "systemd is required" case "$API_URL" in https://*) ;; *) die "--api-url must be https://" ;; esac API_URL="${API_URL%/}" if [ -z "$SEC" ] && [ -r /dev/tty ]; then printf ' Sensor secret (SEC): ' >/dev/tty stty -echo /dev/null || true read -r SEC /dev/null || true printf '\n' >/dev/tty fi SEC="$(printf '%s' "$SEC" | tr 'A-F' 'a-f' | tr -d '[:space:]')" printf '%s' "$SEC" | grep -Eq '^[0-9a-f]{64}$' || die "SEC must be the 64-character secret from the platform" printf '%s' "$HONEYPOT_ID" | grep -Eq '^[1-9][0-9]{0,17}$' || die "--id must be an integer >= 1" printf '%s' "$PORT" | grep -Eq '^[1-9][0-9]{0,4}$' && [ "$PORT" -le 65535 ] || die "--port must be 1-65535" install_packages() { if command -v apt-get >/dev/null 2>&1; then DEBIAN_FRONTEND=noninteractive apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-venv curl tar openssl openssh-client >/dev/null elif command -v dnf >/dev/null 2>&1; then dnf install -y -q python3 curl tar openssl openssh-clients >/dev/null elif command -v yum >/dev/null 2>&1; then yum install -y -q python3 curl tar openssl openssh-clients >/dev/null else die "install python3 (with venv), curl, tar, openssl and ssh-keygen, then re-run" fi } need="" for tool in python3 curl tar openssl ssh-keygen sha256sum; do command -v "$tool" >/dev/null 2>&1 || need="$need $tool" done python3 -c 'import venv, ensurepip' >/dev/null 2>&1 || need="$need python3-venv" if [ -n "$need" ]; then info "Installing prerequisites:$need" install_packages fi python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' \ || die "Python 3.10 or newer is required (found $(python3 -V 2>&1))" openssl version | grep -Eq '^OpenSSL (3|[4-9])\.' \ || die "OpenSSL 3 is required to verify the release signature (found: $(openssl version))" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT INT TERM json_field() { # json_field : prints one top-level field of a JSON object python3 -c 'import json,sys; v=json.load(open(sys.argv[1]))[sys.argv[2]]; print(v)' "$1" "$2" } api_post() { # api_post ; prints the HTTP status # The body is written to a file so the SEC never appears in the process list. printf '%s' "$2" >"$WORK/body.json" curl -sS --proto '=https' --max-redirs 0 --retry 2 --connect-timeout 15 --max-time 300 \ -H 'Content-Type: application/json' --data-binary "@$WORK/body.json" \ -o "$3" -w '%{http_code}' "$API_URL$1" || echo "000" rm -f "$WORK/body.json" } # --- validate the SEC -------------------------------------------------------------------------- info "Checking the SEC with $API_URL ..." status="$(api_post /collect/validate "{\"sec\":\"$SEC\",\"honeypot_id\":$HONEYPOT_ID}" "$WORK/validate.json")" case "$status" in 200) ;; 403) die "this SEC is not registered with the platform" ;; 000) die "cannot reach $API_URL" ;; *) die "SEC validation failed (HTTP $status): $(cat "$WORK/validate.json" 2>/dev/null)" ;; esac # --- fetch the release --------------------------------------------------------------------------- if [ -n "$ARCHIVE" ] || [ -n "$MANIFEST" ]; then [ -f "$ARCHIVE" ] && [ -f "$MANIFEST" ] || die "--archive and --manifest must both name existing files" cp "$MANIFEST" "$WORK/manifest.json" else info "Looking up the current release ..." status="$(api_post /collect/release/latest "{\"sec\":\"$SEC\",\"honeypot_id\":$HONEYPOT_ID}" "$WORK/manifest.json")" [ "$status" = "200" ] || die "release lookup failed (HTTP $status): $(cat "$WORK/manifest.json" 2>/dev/null)" fi VERSION="$(json_field "$WORK/manifest.json" version)" FILE="$(json_field "$WORK/manifest.json" file)" SHA256="$(json_field "$WORK/manifest.json" sha256)" SIZE="$(json_field "$WORK/manifest.json" size)" SIGNATURE="$(json_field "$WORK/manifest.json" signature)" printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$' || die "manifest has an invalid version" [ "$FILE" = "ghostnet-$VERSION.tar.gz" ] || die "manifest names an unexpected file" printf '%s' "$SHA256" | grep -Eq '^[0-9a-f]{64}$' || die "manifest has an invalid sha256" printf '%s' "$SIZE" | grep -Eq '^[1-9][0-9]{0,9}$' && [ "$SIZE" -le 104857600 ] || die "manifest has an invalid size" # Signature first: nothing from an unverified manifest is downloaded or unpacked. printf 'ghostnet-release\n%s\n%s\n%s\n%s' "$VERSION" "$FILE" "$SHA256" "$SIZE" >"$WORK/message" printf '%s\n' "$SIGNATURE" | openssl base64 -d -A >"$WORK/signature.bin" { echo "-----BEGIN PUBLIC KEY-----" # X.509 SubjectPublicKeyInfo prefix for Ed25519 (30 2a 30 05 06 03 2b 65 70 03 21 00) + raw key. { printf '\060\052\060\005\006\003\053\145\160\003\041\000' printf '%s\n' "$RELEASE_SIGNING_PUBLIC_KEY" | openssl base64 -d -A; } | openssl base64 -A echo echo "-----END PUBLIC KEY-----" } >"$WORK/release_signing.pem" openssl pkeyutl -verify -pubin -inkey "$WORK/release_signing.pem" -rawin \ -in "$WORK/message" -sigfile "$WORK/signature.bin" >/dev/null 2>&1 \ || die "release signature is INVALID; refusing to install" info "Release $VERSION signature verified." if [ -n "$ARCHIVE" ]; then cp "$ARCHIVE" "$WORK/$FILE" else info "Downloading $FILE ..." status="$(api_post /collect/release/download "{\"sec\":\"$SEC\",\"version\":\"$VERSION\"}" "$WORK/$FILE")" [ "$status" = "200" ] || die "download failed (HTTP $status)" fi [ "$(wc -c <"$WORK/$FILE" | tr -d ' ')" = "$SIZE" ] || die "downloaded size does not match the manifest" echo "$SHA256 $WORK/$FILE" | sha256sum -c --status - || die "SHA-256 of the download does not match the manifest" info "SHA-256 verified." # --- unpack and build -------------------------------------------------------------------------- if tar -tzf "$WORK/$FILE" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then die "archive contains unsafe paths" fi RELEASE="$INSTALL_ROOT/releases/$VERSION" mkdir -p "$INSTALL_ROOT/releases" chmod 755 "$INSTALL_ROOT" "$INSTALL_ROOT/releases" if [ ! -x "$RELEASE/venv/bin/python" ]; then rm -rf "$RELEASE" mkdir -p "$RELEASE" tar -xzf "$WORK/$FILE" -C "$RELEASE" --no-same-owner --no-same-permissions [ "$(cat "$RELEASE/VERSION")" = "$VERSION" ] || die "archive VERSION does not match the manifest" info "Creating virtual environment and installing locked dependencies ..." python3 -m venv "$RELEASE/venv" "$RELEASE/venv/bin/python" -m pip install --quiet --disable-pip-version-check \ --require-hashes --no-deps -r "$RELEASE/requirements.lock" || die "dependency install failed" fi # --- hand over to the CLI of that release --------------------------------------------------------- set -- bootstrap --id "$HONEYPOT_ID" --port "$PORT" --api-url "$API_URL" [ -n "$AUTO_UPGRADE" ] && set -- "$@" --auto-upgrade OLD_IFS="$IFS"; IFS=' ' for kv in $EXTRA_SETTINGS; do [ -n "$kv" ] && set -- "$@" --set "$kv" done IFS="$OLD_IFS" rm -rf "$WORK" trap - EXIT INT TERM GHOSTNET_SEC="$SEC" exec "$RELEASE/venv/bin/python" "$RELEASE/cli/ghostnet_cli.py" "$@"