#!/bin/sh # hbwatch installer. # # You are about to run a script from the internet as root, so here is what it # does, in order, before you decide: # # 1. works out what this machine is: Unraid, systemd, or neither # 2. downloads one static binary over HTTPS and checks its SHA-256 against a # published list, refusing to install if it does not match # 3. writes a config file, chmod 600 because it holds your token # 4. installs a service so it survives a reboot # 5. runs every check once and prints what it found # # It does not run anything it downloads except the binary it verified, it does # not add a repository, it does not phone anywhere except the address in your # config, and `--uninstall` removes every one of the above. # # Read it first if you like. That is the point of it being one file: # # curl -fsSL https://hbwatch.dev/install.sh -o install.sh # less install.sh # sh install.sh --token hbw_... # set -eu BASE_URL="${HBWATCH_BASE_URL:-https://hbwatch.dev/dl}" API_URL="${HBWATCH_URL:-https://api.hbwatch.dev/v1/beat}" TOKEN="${HBWATCH_TOKEN:-}" DRY_RUN=0 UNINSTALL=0 say() { printf '%s\n' "$*"; } # Stop the agent and wait for it to actually be gone. # # It handles SIGTERM properly: it writes a shutdown beacon so the watcher knows # the silence is deliberate, which takes a moment. The boot script starts # nothing if it sees an hbwatch process already running, so starting one # straight after a kill would find the dying process, decide its work was done, # and leave the machine with no agent at all. That is the worst possible # outcome for an upgrade: quieter than a failure and just as blind. stop_agent() { pkill -x hbwatch 2>/dev/null || true i=0 while pgrep -x hbwatch >/dev/null 2>&1; do [ "$i" -ge 20 ] && { pkill -KILL -x hbwatch 2>/dev/null || true; break; } sleep 1 i=$((i + 1)) done } warn() { printf '%s\n' "$*" >&2; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } run() { if [ "$DRY_RUN" = 1 ]; then printf ' would run: %s\n' "$*"; else "$@"; fi; } usage() { cat <<'EOF' hbwatch installer --token TOKEN the host token from your dashboard (or set HBWATCH_TOKEN) --url URL beat endpoint, defaults to https://api.hbwatch.dev/v1/beat --dry-run print what would happen and change nothing --uninstall stop the agent and remove everything it installed --help Without a token the agent still installs and runs every check locally. It just has nothing offsite to report to, which means nothing can tell you the machine died. Add the token later in the config file and restart it. EOF } while [ $# -gt 0 ]; do case "$1" in --token) TOKEN="${2:-}"; shift 2 ;; --token=*) TOKEN="${1#*=}"; shift ;; --url) API_URL="${2:-}"; shift 2 ;; --url=*) API_URL="${1#*=}"; shift ;; --dry-run) DRY_RUN=1; shift ;; --uninstall) UNINSTALL=1; shift ;; --help | -h) usage; exit 0 ;; *) die "unknown option: $1 (try --help)" ;; esac done # --- what is this machine ---------------------------------------------------- case "$(uname -s)" in Linux) ;; *) die "this installer only handles Linux. The agent builds on anything Go does: go build ./cmd/hbwatch" ;; esac case "$(uname -m)" in x86_64 | amd64) ARCH=amd64 ;; aarch64 | arm64) ARCH=arm64 ;; armv7l | armv6l | armhf | arm) ARCH=arm ;; *) die "unsupported architecture: $(uname -m)" ;; esac # Unraid runs from RAM. Anything written to /usr or /etc is gone at the next # boot, which for a monitoring agent means it silently stops existing, so the # whole install goes to appdata and the boot flash instead. if [ -f /etc/unraid-version ]; then PLATFORM=unraid PREFIX=/mnt/user/appdata/hbwatch BIN="$PREFIX/bin/hbwatch" CONF="$PREFIX/hbwatch.conf" STATE="$PREFIX/state" elif [ -d /run/systemd/system ]; then PLATFORM=systemd BIN=/usr/local/bin/hbwatch CONF=/etc/hbwatch/hbwatch.conf STATE=/var/lib/hbwatch else PLATFORM=manual BIN=/usr/local/bin/hbwatch CONF=/etc/hbwatch/hbwatch.conf STATE=/var/lib/hbwatch fi [ "$(id -u)" = 0 ] || die "run this as root: it installs a binary and a service" # --- uninstall --------------------------------------------------------------- if [ "$UNINSTALL" = 1 ]; then say "removing hbwatch" case "$PLATFORM" in systemd) run systemctl stop hbwatch 2>/dev/null || true run systemctl disable hbwatch 2>/dev/null || true run rm -f /etc/systemd/system/hbwatch.service run systemctl daemon-reload ;; unraid) # Killed by exact name so this cannot match the ssh session that is # running it, which is a mistake you only make once. run stop_agent run rm -f /boot/config/hbwatch-start.sh if [ -f /boot/config/go ] && [ "$DRY_RUN" = 0 ]; then grep -v 'hbwatch-start.sh' /boot/config/go > /boot/config/go.new || true mv /boot/config/go.new /boot/config/go chmod +x /boot/config/go else say " would remove the hbwatch line from /boot/config/go" fi ;; *) run stop_agent ;; esac run rm -f "$BIN" run rm -rf "$STATE" run rm -f "$CONF" [ "$PLATFORM" = unraid ] && run rm -rf "$PREFIX/bin" say "gone. Remove the server from your dashboard too, or it will be reported as down." exit 0 fi # --- fetch and verify -------------------------------------------------------- if command -v curl >/dev/null 2>&1; then fetch() { curl -fsSL "$1" -o "$2"; } elif command -v wget >/dev/null 2>&1; then fetch() { wget -qO "$2" "$1"; } else die "need curl or wget" fi case "$BASE_URL" in https://*) ;; *) die "refusing to download over anything but https" ;; esac if command -v sha256sum >/dev/null 2>&1; then sum() { sha256sum "$1" | cut -d' ' -f1; } elif command -v shasum >/dev/null 2>&1; then sum() { shasum -a 256 "$1" | cut -d' ' -f1; } elif command -v openssl >/dev/null 2>&1; then sum() { openssl dgst -sha256 "$1" | sed 's/.*= *//'; } else die "need sha256sum, shasum or openssl to verify the download" fi ASSET="hbwatch-linux-$ARCH" say "hbwatch installer" say " machine: $(uname -m), $PLATFORM" say " binary: $BIN" say " config: $CONF" say " state: $STATE" say "" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT INT TERM if [ "$DRY_RUN" = 1 ]; then say " would download: $BASE_URL/$ASSET" say " would verify it against $BASE_URL/SHA256SUMS" else say "downloading $ASSET" fetch "$BASE_URL/$ASSET" "$TMP/hbwatch" || die "download failed: $BASE_URL/$ASSET" fetch "$BASE_URL/SHA256SUMS" "$TMP/SHA256SUMS" || die "could not fetch checksums" # Tolerant of the "*name" form sha256sum writes in binary mode, which is # easy to produce by accident on a release machine and would otherwise make # every install fail closed for the wrong reason. want="$(awk -v f="$ASSET" '{ n = $2; sub(/^\*/, "", n); if (n == f) { print $1; exit } }' "$TMP/SHA256SUMS")" got="$(sum "$TMP/hbwatch")" [ -n "$want" ] || die "no checksum published for $ASSET" # Fails closed on purpose. A binary that runs as root and reads your disks # is not something to install on the strength of it having downloaded. [ "$want" = "$got" ] || die "checksum mismatch for $ASSET expected $want got $got Nothing was installed." say " checksum ok" chmod +x "$TMP/hbwatch" fi # --- install ----------------------------------------------------------------- run mkdir -p "$(dirname "$BIN")" "$STATE" "$(dirname "$CONF")" [ "$DRY_RUN" = 1 ] || install -m 0755 "$TMP/hbwatch" "$BIN" if [ -f "$CONF" ]; then say "config exists, keeping it: $CONF" if [ -n "$TOKEN" ] && [ "$DRY_RUN" = 0 ]; then # Rewritten with awk rather than sed so the token, which is arbitrary # base64, cannot be mangled by an unlucky slash. awk -v tok="$TOKEN" ' /^[[:space:]]*token[[:space:]]*=/ { print "token = " tok; found = 1; next } { print } END { if (!found) { print ""; print "[cloud]"; print "token = " tok } } ' "$CONF" > "$CONF.new" mv "$CONF.new" "$CONF" chmod 600 "$CONF" say "token updated" fi elif [ "$DRY_RUN" = 1 ]; then say " would write $CONF" else umask 077 { printf '# hbwatch agent configuration. See %s for everything settable.\n' "https://hbwatch.dev" printf 'host = %s\n' "$(hostname 2>/dev/null || echo unknown)" printf 'interval = 60s\n' printf 'state_dir = %s\n\n' "$STATE" printf '[cloud]\n' printf 'url = %s\n' "$API_URL" if [ -n "$TOKEN" ]; then printf 'token = %s\n' "$TOKEN" else printf '# token = paste the one your dashboard showed you\n' fi } > "$CONF" chmod 600 "$CONF" say "wrote $CONF" fi case "$PLATFORM" in systemd) if [ "$DRY_RUN" = 1 ]; then say " would install /etc/systemd/system/hbwatch.service and start it" else cat > /etc/systemd/system/hbwatch.service </dev/null 2>&1 || true systemctl restart hbwatch say "installed and started the systemd service" fi ;; unraid) if [ "$DRY_RUN" = 1 ]; then say " would write /boot/config/hbwatch-start.sh and add it to /boot/config/go" else cat > /boot/config/hbwatch-start.sh </dev/null && exit 0 waited=0 while [ ! -x "\$DIR/bin/hbwatch" ] || [ ! -f "\$DIR/hbwatch.conf" ]; do [ "\$waited" -ge "\$WAIT" ] && exit 1 sleep 5 waited=\$((waited + 5)) done cd "\$DIR" || exit 1 setsid "\$DIR/bin/hbwatch" run -config "\$DIR/hbwatch.conf" >> "\$DIR/agent.log" 2>&1 < /dev/null & exit 0 EOF # The boot flash is vfat, usually mounted fmask=0177, so nothing on it # can be executable and chmod is a no-op that may also fail. Everything # here therefore invokes the script through bash by name rather than # relying on an execute bit that this filesystem cannot store. chmod +x /boot/config/hbwatch-start.sh 2>/dev/null || true [ -f /boot/config/go ] || printf '#!/bin/bash\n' > /boot/config/go if grep -q 'hbwatch-start.sh' /boot/config/go; then say "boot script already in /boot/config/go" else printf '\n# hbwatch agent (waits for the array, then starts detached)\nbash /boot/config/hbwatch-start.sh &\n' >> /boot/config/go say "added hbwatch to /boot/config/go" fi stop_agent bash /boot/config/hbwatch-start.sh say "started" fi ;; *) say "no service manager found. Start it yourself with:" say " $BIN run -config $CONF" ;; esac # --- prove it works ---------------------------------------------------------- say "" if [ "$DRY_RUN" = 1 ]; then say "dry run, nothing changed." exit 0 fi say "installed $("$BIN" version 2>/dev/null || echo hbwatch)" say "running every check once:" "$BIN" check -config "$CONF" 2>&1 | tail -n 3 || true say "" # Asked of the config rather than of the arguments. Re-running the installer to # upgrade an existing install passes no token, and telling somebody their server # is unwatched when it has been reporting for months would be a bad way to find # out this script cannot read. if grep -Eq '^[[:space:]]*token[[:space:]]*=' "$CONF" 2>/dev/null; then say "Your heartbeat should appear on the dashboard within a minute." else say "No token in $CONF, so this machine is watching itself and nothing is watching it." say "Add one under [cloud], then:" case "$PLATFORM" in systemd) say " systemctl restart hbwatch" ;; unraid) say " pkill -x hbwatch; sleep 3; bash /boot/config/hbwatch-start.sh" ;; *) say " restart the agent" ;; esac fi say "Uninstall with: sh install.sh --uninstall"