#!/usr/bin/env sh # marbor installer # Downloads the latest release binary from GitHub for your OS and architecture. # Usage: curl -fsSL https://raw.githubusercontent.com/Anirudhx7/marbor/main/install.sh | sh # # There is no config.yaml anymore - marbor is DB-first (marbor.db). When # starting the daemon for the first time, this installer scans the local # subnet for running GPU nodes (Ollama, vLLM, TGI, llama.cpp) and lets you # pick which ones to seed into marbor.db interactively. Everything else (API # keys, ports, routing, HA, webhooks, ...) is configured from the dashboard # after boot - log in with admin/admin and set a new password. # # Modes (opt in via env vars): # START=1 start marbor in the background (nohup) after install # SERVICE=1 install+enable a proper OS service instead of nohup (implies # START=1); persists across reboots and restarts on failure. # Currently implemented via systemd on Linux (root/sudo # required); on macOS or any host without systemd it falls back # to a plain background process rather than failing the install. # Recommended for production. Example: curl ... | SERVICE=1 sh # FORCE_PROBE=1 re-run the network discovery wizard even if marbor.db # already exists (by default it only runs on a fresh DB). # # ROLE=agent install the marbor agent: downloads the dedicated # marbor-agent binary (a separate artifact from the # control-plane marbor binary - a GPU host running this # role never has a control-plane-capable executable on disk), # then registers+starts it as a persistent OS service (systemd # on Linux, launchd on macOS - see install.ps1 for Windows) via # "marbor-agent service install", the binary's own # self-registration subcommand (internal/marboragent/service - no # separate service-file logic duplicated in this script for the # agent role). Credentials: # MARBOR_ENROLL= MARBOR_SERVER= (default, shown by the # marbor admin UI's "marbor agent" panel) - a short-lived, # single-use code the binary itself exchanges for the real # token by calling back to MARBOR_SERVER, so the real # permanent bearer token never appears in this command / your # shell history. It is read from the MARBOR_AGENT_SECRET # environment variable at agent startup (no legacy TOKEN env). # MARBOR_AGENT_SECRET= (manual path) - the real permanent # token directly, no exchange, no MARBOR_SERVER needed. # One of MARBOR_AGENT_SECRET or MARBOR_ENROLL+MARBOR_SERVER is # required - there is no existing-installation upgrade path (no # prior Marbor deployments exist to preserve). # PORT= optionally overrides the default (9200). # Example: curl ... | ROLE=agent MARBOR_SERVER=http://marbor-host:8080 MARBOR_ENROLL=xxxxx PORT=9200 sh # # Uninstall: https://raw.githubusercontent.com/Anirudhx7/marbor/main/uninstall.sh # Uninstall a marbor agent: marbor-agent service uninstall (on the node, not this script) set -e REPO="Anirudhx7/marbor" INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" ROLE="${ROLE:-marbor}" if [ "$ROLE" = "agent" ]; then BIN_NAME="marbor-agent" else BIN_NAME="marbor" fi START_DAEMON=false if [ "$START" = "1" ]; then START_DAEMON=true fi for arg in "$@"; do if [ "$arg" = "--start" ] || [ "$arg" = "-s" ]; then START_DAEMON=true fi done FORCE_PROBE=false if [ "$FORCE_PROBE" = "1" ]; then FORCE_PROBE=true fi for arg in "$@"; do if [ "$arg" = "--force-probe" ]; then FORCE_PROBE=true fi done SERVICE_MODE=false if [ "$SERVICE" = "1" ]; then SERVICE_MODE=true fi for arg in "$@"; do if [ "$arg" = "--service" ]; then SERVICE_MODE=true fi done # A persistent service implies it should actually be running. if [ "$SERVICE_MODE" = true ]; then START_DAEMON=true fi # Detect OS OS="$(uname -s)" case "$OS" in Linux) OS="linux" ;; Darwin) OS="darwin" ;; *) echo "Unsupported OS: $OS" echo "Download manually from: https://github.com/$REPO/releases/latest" exit 1 ;; esac # Detect architecture ARCH="$(uname -m)" case "$ARCH" in x86_64|amd64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; *) echo "Unsupported architecture: $ARCH" echo "Download manually from: https://github.com/$REPO/releases/latest" exit 1 ;; esac # Service-mode abstraction: SERVICE=1 means "run as a proper OS service", # not "use systemd" specifically. This picks the right backend per OS so # the same env var stays meaningful as more platforms are supported. # linux -> systemd (implemented) # darwin -> launchd (planned, not yet implemented) # other -> no known service manager # Anything other than "systemd" falls back to a plain background process. detect_service_manager() { case "$OS" in linux) if command -v systemctl >/dev/null 2>&1; then echo "systemd" return fi ;; darwin) : # launchd support not implemented yet; falls through to "none" ;; esac echo "none" } BINARY="${BIN_NAME}-${OS}-${ARCH}" URL="https://github.com/${REPO}/releases/latest/download/${BINARY}" BIN_PATH="$INSTALL_DIR/$BIN_NAME" # Capture the currently-installed version (if any) before we touch anything, # so upgrades can report old -> new instead of installing silently over it. OLD_VERSION="" if [ -x "$BIN_PATH" ]; then OLD_VERSION=$("$BIN_PATH" -version 2>/dev/null | awk '{print $2}') fi echo "Downloading marbor for ${OS}/${ARCH}..." echo " $URL" # Download to temp file TMP="$(mktemp)" # Every exit path already does its own "rm -f $TMP" before exiting - this # trap is a safety net for the ones that don't (e.g. an interrupt mid- # download), so a failed/aborted install never leaves the downloaded binary # behind in /tmp. A no-op once $TMP has already been moved to $BIN_PATH. trap 'rm -f "$TMP"' EXIT INT TERM DOWNLOAD_OK=true if command -v curl > /dev/null 2>&1; then curl -fsSL "$URL" -o "$TMP" || DOWNLOAD_OK=false elif command -v wget > /dev/null 2>&1; then wget -qO "$TMP" "$URL" || DOWNLOAD_OK=false else echo "Error: curl or wget required" rm -f "$TMP" exit 1 fi if [ "$DOWNLOAD_OK" = false ] || [ ! -s "$TMP" ]; then echo "" echo "Error: failed to download $URL" echo " This usually means one of:" echo " - no internet connection, or a proxy/firewall is blocking github.com" echo " - no release exists yet for ${OS}/${ARCH}" echo " Check releases manually: https://github.com/$REPO/releases/latest" rm -f "$TMP" exit 1 fi # Verify the downloaded binary against goreleaser's published checksums.txt # before chmod+x/execution (this is the primary Linux/macOS install path, run # via `curl | sh`). checksums.txt is fetched from the same release tag as the # binary itself, so this also catches a release published mid-install. CHECKSUMS_URL="https://github.com/${REPO}/releases/latest/download/checksums.txt" CHECKSUMS_TMP="$(mktemp)" CHECKSUMS_OK=true if command -v curl > /dev/null 2>&1; then curl -fsSL "$CHECKSUMS_URL" -o "$CHECKSUMS_TMP" || CHECKSUMS_OK=false elif command -v wget > /dev/null 2>&1; then wget -qO "$CHECKSUMS_TMP" "$CHECKSUMS_URL" || CHECKSUMS_OK=false else echo "Error: curl or wget required" CHECKSUMS_OK=false fi if [ "$CHECKSUMS_OK" = false ] || [ ! -s "$CHECKSUMS_TMP" ]; then echo "Error: failed to download checksums.txt for verification" rm -f "$TMP" "$CHECKSUMS_TMP" exit 1 fi # Detect HTML error page masquerading as checksums.txt (rate limit, 404 with # 200, CDN error). Goreleaser never emits HTML - this gives a clear message # instead of the generic "no entry" error. if grep -qi "/dev/null || grep -qi "/dev/null; then echo "Error: checksums.txt download returned HTML instead of checksums (GitHub rate limit or release not found)." echo " Check https://github.com/$REPO/releases/latest and try again in a minute." rm -f "$TMP" "$CHECKSUMS_TMP" exit 1 fi # Normalize line endings and BOM before parsing - be defensive: # - CRLF (Windows checkout or proxy) -> LF via tr # - UTF-8 BOM (EF BB BF) at start of file -> stripped via tail # Both are invisible but would break hash/filename matching. if command -v tr >/dev/null 2>&1; then tr -d '\r' < "$CHECKSUMS_TMP" > "${CHECKSUMS_TMP}.clean" 2>/dev/null && mv "${CHECKSUMS_TMP}.clean" "$CHECKSUMS_TMP" fi # Strip UTF-8 BOM if present - use od+tail for portability (sed hex escapes vary # between GNU/BSD/busybox). Only runs if file starts with EF BB BF bytes. if command -v od >/dev/null 2>&1 && command -v head >/dev/null 2>&1 && command -v tail >/dev/null 2>&1; then BOM_CHECK="$(head -c 3 "$CHECKSUMS_TMP" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n' 2>/dev/null)" if [ "$BOM_CHECK" = "efbbbf" ]; then tail -c +4 "$CHECKSUMS_TMP" > "${CHECKSUMS_TMP}.clean" 2>/dev/null && mv "${CHECKSUMS_TMP}.clean" "$CHECKSUMS_TMP" fi fi # Exact filename match via awk - handles both " filename" and " *filename" # (binary mode) and any amount of whitespace. Using awk variable avoids regex # injection and the previous grep -E fragility with special chars / CRLF / BOM. # We use awk's string equality (not regex) so hyphens/dots in BINARY are literal. EXPECTED_HASH="$(awk -v bin="$BINARY" '$2 == bin || $2 == "*" bin {print $1; exit}' "$CHECKSUMS_TMP" 2>/dev/null)" if [ -z "$EXPECTED_HASH" ]; then echo "Error: checksums.txt has no entry for $BINARY - refusing to run an unverified binary" echo " Downloaded checksums.txt preview (first 500 chars):" head -c 500 "$CHECKSUMS_TMP" 2>/dev/null | tr -d '\r' | head -n 20 rm -f "$TMP" "$CHECKSUMS_TMP" exit 1 fi # Validate expected hash looks like a SHA256 hex string before comparing - catches # a truncated or HTML-corrupted checksums.txt that slipped past the HTML check. case "$EXPECTED_HASH" in *[!a-fA-F0-9]*) echo "Error: checksums.txt entry for $BINARY is malformed (hash='$EXPECTED_HASH') - refusing to run this binary." rm -f "$TMP" "$CHECKSUMS_TMP" exit 1 ;; esac # POSIX sh: ${#var} is portable - 64 hex chars for SHA256 if [ "${#EXPECTED_HASH}" -ne 64 ]; then echo "Error: checksums.txt entry for $BINARY is malformed (hash length ${#EXPECTED_HASH} != 64) - refusing to run this binary." rm -f "$TMP" "$CHECKSUMS_TMP" exit 1 fi if command -v sha256sum > /dev/null 2>&1; then ACTUAL_HASH="$(sha256sum "$TMP" | awk '{print $1}')" elif command -v shasum > /dev/null 2>&1; then ACTUAL_HASH="$(shasum -a 256 "$TMP" | awk '{print $1}')" else echo "Error: neither sha256sum nor shasum is available to verify the download" rm -f "$TMP" "$CHECKSUMS_TMP" exit 1 fi rm -f "$CHECKSUMS_TMP" # Normalize case for comparison (both should be lowercase hex, but be safe) EXPECTED_HASH_LOWER="$(printf '%s' "$EXPECTED_HASH" | tr 'A-F' 'a-f')" ACTUAL_HASH_LOWER="$(printf '%s' "$ACTUAL_HASH" | tr 'A-F' 'a-f')" if [ "$ACTUAL_HASH_LOWER" != "$EXPECTED_HASH_LOWER" ]; then echo "Error: checksum mismatch for $BINARY" echo " expected: $EXPECTED_HASH" echo " actual: $ACTUAL_HASH" echo "Refusing to run this binary." rm -f "$TMP" exit 1 fi # install -m 0755 sets the standard binary permission mode explicitly # (rather than inheriting umask via chmod+mv) and installs atomically, in one # step - a system-wide binary shouldn't end up with a non-standard mode # depending on the installing user's umask. if [ -w "$INSTALL_DIR" ]; then install -m 0755 "$TMP" "$BIN_PATH" else echo "No write permission to $INSTALL_DIR. Trying with sudo..." sudo install -m 0755 "$TMP" "$BIN_PATH" fi NEW_VERSION=$("$BIN_PATH" -version 2>/dev/null | awk '{print $2}') if [ -n "$OLD_VERSION" ] && [ "$OLD_VERSION" != "$NEW_VERSION" ]; then echo "Upgraded marbor: $OLD_VERSION -> $NEW_VERSION ($BIN_PATH)" elif [ -n "$OLD_VERSION" ]; then echo "Reinstalled marbor $NEW_VERSION ($BIN_PATH, already up to date)" else echo "Installed marbor $NEW_VERSION to $BIN_PATH" fi # Best-effort: install man pages from the SAME release the binary above was # just downloaded from. This script has no source checkout to copy docs/man # from, so it downloads marbor-man-pages.tar.gz - the single archive every # release ships, with the pages under man1/ (see .goreleaser.yaml) - checks it # against checksums.txt the way the binary was checked, and copies the pages # into MAN_DIR. Never fails the overall install: every step is guarded, and # the whole function runs in a subshell with "|| true" on top so a `set -e` # failure inside it can't abort the rest of the install. # fetch_quiet : silent download with curl or wget; false on any # failure or an empty result. fetch_quiet() { if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2" >/dev/null 2>&1 && [ -s "$2" ] elif command -v wget >/dev/null 2>&1; then wget -q "$1" -O "$2" >/dev/null 2>&1 && [ -s "$2" ] else return 1 fi } # man_archive_verified : true only when the file's # sha256 equals checksums.txt's entry for that asset. Fails closed - a missing # tool, download or entry all mean "not verified". man_archive_verified() { MV_SUMS="$MAN_TMP_DIR/checksums.txt" fetch_quiet "$MAN_BASE_URL/checksums.txt" "$MV_SUMS" || return 1 MV_EXPECTED="$(tr -d '\r' < "$MV_SUMS" | awk -v f="$2" '$2 == f || $2 == "*" f {print $1; exit}' | tr 'A-F' 'a-f')" case "$MV_EXPECTED" in ""|*[!0-9a-f]*) return 1 ;; esac [ "${#MV_EXPECTED}" -eq 64 ] || return 1 if command -v sha256sum >/dev/null 2>&1; then MV_ACTUAL="$(sha256sum "$1" | awk '{print $1}')" elif command -v shasum >/dev/null 2>&1; then MV_ACTUAL="$(shasum -a 256 "$1" | awk '{print $1}')" else return 1 fi [ "$MV_EXPECTED" = "$MV_ACTUAL" ] } install_man_pages() { MAN_DIR="/usr/local/share/man/man1" MAN_MARKER="$MAN_DIR/.marbor-installed-manifest" MAN_BASE_URL="https://github.com/${REPO}/releases/latest/download" MAN_ARCHIVE="marbor-man-pages.tar.gz" MAN_TMP_DIR="$(mktemp -d)" || return 0 # Remove the temp dir on any exit; an interrupt must also stop the install # of man pages rather than resume after the handler runs. trap 'rm -rf "$MAN_TMP_DIR"' EXIT trap 'exit 1' INT TERM MAN_STAGE="$MAN_TMP_DIR/man1" mkdir -p "$MAN_STAGE" || return 0 MAN_SKIPPED=false if fetch_quiet "$MAN_BASE_URL/$MAN_ARCHIVE" "$MAN_TMP_DIR/$MAN_ARCHIVE"; then # The release has the archive. Unpack it whole into its own directory # (no reliance on how a given tar treats member arguments), and only once # it verifies. Its pages are then read from /man1 alone. MAN_UNPACK="$MAN_TMP_DIR/unpack" if ! command -v tar >/dev/null 2>&1; then MAN_SKIPPED=true; echo " [!] Skipped man pages: tar is not available." elif ! man_archive_verified "$MAN_TMP_DIR/$MAN_ARCHIVE" "$MAN_ARCHIVE"; then MAN_SKIPPED=true; echo " [!] Skipped man pages: could not verify $MAN_ARCHIVE against checksums.txt." elif ! mkdir -p "$MAN_UNPACK" || ! tar -xzf "$MAN_TMP_DIR/$MAN_ARCHIVE" -C "$MAN_UNPACK" >/dev/null 2>&1; then MAN_SKIPPED=true; echo " [!] Skipped man pages: could not unpack $MAN_ARCHIVE." elif [ -h "$MAN_UNPACK/man1" ]; then MAN_SKIPPED=true; echo " [!] Skipped man pages: $MAN_ARCHIVE has an unexpected layout." else MAN_STAGE="$MAN_UNPACK/man1" fi else MAN_SKIPPED=true; echo " [!] Skipped man pages: $MAN_ARCHIVE is not published with this release." fi # Keep only regular files with a plain marbor*.1 name. A symlink or an odd # name in the archive is dropped here, so cp never follows a link and the # uninstall marker below only ever lists names uninstall.sh accepts. INSTALLED_PAGES="" for page in "$MAN_STAGE"/*.1; do name="$(basename "$page")" if [ -h "$page" ] || [ ! -f "$page" ]; then rm -f "$page" continue fi case "$name" in *[!A-Za-z0-9._-]*|-*) rm -f "$page"; continue ;; marbor*.1) ;; *) rm -f "$page"; continue ;; esac INSTALLED_PAGES="$INSTALLED_PAGES $name" done if [ -z "$INSTALLED_PAGES" ]; then [ "$MAN_SKIPPED" = true ] || echo " [!] Skipped man pages: none could be downloaded from this release." else # Record exactly which filenames installs placed in MAN_DIR, so uninstall.sh # can remove exactly those files later instead of a broad wildcard - this is # the only place that knows the real list; uninstall.sh has no source tree # and no manifest of its own. Merged with the previous marker so a page an # older install placed (and a newer release no longer ships) stays listed. MAN_ALL="$( { cat "$MAN_MARKER" 2>/dev/null; printf '%s\n' $INSTALLED_PAGES; } | grep -E '^[A-Za-z0-9._][A-Za-z0-9._-]*$' | sort -u)" if [ -w "$MAN_DIR" ] || { [ ! -d "$MAN_DIR" ] && mkdir -p "$MAN_DIR" 2>/dev/null; }; then if cp "$MAN_STAGE"/*.1 "$MAN_DIR/" 2>/dev/null; then echo "Installed man pages to $MAN_DIR (try: man marbor)" printf '%s\n' "$MAN_ALL" > "$MAN_MARKER" 2>/dev/null || true fi elif command -v sudo >/dev/null 2>&1; then sudo mkdir -p "$MAN_DIR" 2>/dev/null if sudo cp "$MAN_STAGE"/*.1 "$MAN_DIR/" 2>/dev/null; then echo "Installed man pages to $MAN_DIR (try: man marbor)" printf '%s\n' "$MAN_ALL" | sudo tee "$MAN_MARKER" >/dev/null 2>&1 || true fi fi if command -v mandb >/dev/null 2>&1; then mandb >/dev/null 2>&1 || sudo mandb >/dev/null 2>&1 || true fi fi return 0 } if [ "$ROLE" != "agent" ]; then ( install_man_pages ) || echo " [!] Skipped man page install (non-fatal - 'man marbor' won't work, everything else does)." fi # marbor agent role: register+start as a persistent OS service and stop here - # none of the control-plane logic below (node discovery wizard, marbor's own # systemd unit, dashboard) applies to a node running only the agent. The # binary's own "service install" subcommand owns the actual # systemd/launchd registration (internal/marboragent/service) - this script's # job for this role is just "download the right binary, then hand off to it." if [ "$ROLE" = "agent" ]; then if [ -z "$MARBOR_AGENT_SECRET" ] && [ -z "$MARBOR_ENROLL" ]; then echo "" echo "Error: ROLE=agent requires MARBOR_AGENT_SECRET= or MARBOR_ENROLL= MARBOR_SERVER=." echo " Generate one from the marbor admin UI: GPU Nodes -> (a node) -> marbor agent -> Enable Agent." exit 1 fi if [ -n "$MARBOR_ENROLL" ] && [ -z "$MARBOR_AGENT_SECRET" ] && [ -z "$MARBOR_SERVER" ]; then echo "" echo "Error: MARBOR_ENROLL= requires MARBOR_SERVER= (the marbor admin dashboard's address)." exit 1 fi AGENT_PORT="${PORT:-9200}" echo "" echo "Installing marbor agent as a persistent service (port $AGENT_PORT)..." # set -- preserves per-arg quoting (unlike an unquoted variable expansion, # which would word-split/glob a code, token, or URL containing whitespace # or shell metacharacters) - "$@" below expands each positional param as # its own word. MARBOR_AGENT_SECRET is deliberately NOT passed as a CLI # argument here (that would put the real bearer token in this process's # argv, visible via `ps`/Task Manager for the life of the install) - it's # already in this shell's environment (MARBOR_AGENT_SECRET=... sh), and the # binary's own "service install" subcommand already reads MARBOR_AGENT_SECRET # from its environment, so it's just inherited below. if [ -n "$MARBOR_AGENT_SECRET" ]; then set -- service install --port="$AGENT_PORT" else set -- service install --port="$AGENT_PORT" --enroll="$MARBOR_ENROLL" --server="$MARBOR_SERVER" fi if [ "$(id -u)" = "0" ]; then "$BIN_PATH" "$@" elif command -v sudo >/dev/null 2>&1; then # -E forwards this shell's environment (incl. MARBOR_AGENT_SECRET) to the # sudo'd process instead of resetting it - required for the # MARBOR_AGENT_SECRET env-var value above to actually reach the binary # under sudo. # A bare `if ! sudo ...; then SUDO_EXIT=$?` would both (a) capture the # negated status of the `!` test itself (always 0 on this branch, not # sudo's real exit code - silently turning a real failure into a # reported success) and, worse, (b) never even reach here at all if # written as a plain statement instead of an `if` condition, since # set -e aborts the whole script on the first nonzero exit outside an # if/while/until test. The un-negated `if cmd; then ... else $?; fi` # form avoids both problems: the test position is exempt from set -e, # and $? in the else branch reflects sudo's actual exit status. if sudo -E "$BIN_PATH" "$@"; then : else SUDO_EXIT=$? echo "" echo "Error: 'sudo -E' failed (exit $SUDO_EXIT)." echo " Some hardened sudoers configurations use 'env_reset' without a matching" echo " setenv rule, which silently drops -E's environment forwarding - including" echo " MARBOR_AGENT_SECRET/MARBOR_ENROLL/MARBOR_SERVER above." echo " Try re-running with an explicit allowlist instead:" echo " sudo --preserve-env=MARBOR_AGENT_SECRET,MARBOR_ENROLL,MARBOR_SERVER \"$BIN_PATH\" \"\$@\"" exit "$SUDO_EXIT" fi else echo "Error: installing the marbor agent service requires root, and sudo is not available." exit 1 fi echo "" echo "marbor agent installed and running - the marbor server will start polling it on its next poll cycle." echo " Status: marbor-agent service status" echo " Uninstall: marbor-agent service uninstall" exit 0 fi if [ "$START_DAEMON" = false ]; then echo "" echo "marbor successfully installed to $BIN_PATH" echo "Run: marbor" echo "Once it's running, check it with: marbor status (or marbor --help for all commands)" echo "Docs: https://github.com/$REPO" echo "Uninstall: https://raw.githubusercontent.com/$REPO/main/uninstall.sh" exit 0 fi get_primary_ip() { if command -v ip >/dev/null 2>&1; then ip route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | cut -d' ' -f2 elif command -v route >/dev/null 2>&1; then IFACE=$(route -n get default 2>/dev/null | grep interface | awk '{print $2}') if [ -n "$IFACE" ] && command -v ipconfig >/dev/null 2>&1; then ipconfig getifaddr "$IFACE" fi fi } get_local_subnets() { if command -v ip >/dev/null 2>&1; then ip -o -4 addr list | awk '{print $4}' | cut -d/ -f1 | grep -v '^127\.' elif command -v ifconfig >/dev/null 2>&1; then ifconfig | grep -Eo 'inet (addr:)?([0-9]*\.){3}[0-9]*' | grep -v '127.0.0.1' | awk '{print $2}' | sed 's/addr://' else hostname -I 2>/dev/null fi } verify_endpoint() { IP=$1 PORT=$2 if [ "$PORT" = "11434" ]; then if curl -fs -m 0.5 "http://$IP:11434/api/tags" >/dev/null 2>&1; then echo "$IP:11434:ollama" elif wget -T 0.5 -t 1 -qO- "http://$IP:11434/api/tags" >/dev/null 2>&1; then echo "$IP:11434:ollama" fi elif [ "$PORT" = "8000" ]; then if curl -fs -m 0.5 "http://$IP:8000/v1/models" >/dev/null 2>&1; then echo "$IP:8000:vllm" elif wget -T 0.5 -t 1 -qO- "http://$IP:8000/v1/models" >/dev/null 2>&1; then echo "$IP:8000:vllm" fi elif [ "$PORT" = "8080" ]; then HEALTH_BODY="" if command -v curl >/dev/null 2>&1; then HEALTH_BODY=$(curl -fs -m 0.5 "http://$IP:8080/health" 2>/dev/null || true) else HEALTH_BODY=$(wget -T 0.5 -t 1 -qO- "http://$IP:8080/health" 2>/dev/null || true) fi # marbor's own /health returns {"proxy_port":...}; a real TGI or # llama.cpp server never does. Rule this out FIRST, before the /info # probe below - the marbor's embedded dashboard SPA answers 200 on any # unmatched path (including /info), so checking status code alone would # misidentify a marbor instance (possibly this one) as a TGI node. case "$HEALTH_BODY" in *proxy_port*) return ;; esac # TGI: verify by content, not just HTTP status. A real TGI server's # /info response is JSON containing both "model_id" and # "max_concurrent_requests" - fields no SPA catch-all route would emit. INFO_BODY="" if command -v curl >/dev/null 2>&1; then INFO_BODY=$(curl -fs -m 0.5 "http://$IP:8080/info" 2>/dev/null || true) else INFO_BODY=$(wget -T 0.5 -t 1 -qO- "http://$IP:8080/info" 2>/dev/null || true) fi case "$INFO_BODY" in *model_id*max_concurrent_requests*|*max_concurrent_requests*model_id*) echo "$IP:8080:tgi" return ;; esac # llama.cpp server: /health responds with a JSON "status" field (already # ruled out being marbor above), not just any non-empty body. case "$HEALTH_BODY" in *'"status"'*) echo "$IP:8080:llamacpp" ;; esac fi } # Scan local subnets for running LLM nodes (Ollama :11434, vLLM :8000, # TGI/llama.cpp :8080) and print each hit as it's found. Results land in # $TEMP_FOUND (one "ip:port:runtime" line per hit, deduped by the caller). probe_network() { # Informational only - must go to stderr, not stdout: the caller captures # this function's stdout wholesale (FOUND_IPS=$(probe_network)) and # word-splits/cuts it expecting only "ip:port:runtime" data lines. A banner # on stdout becomes bogus numbered entries in the node picker, corrupting # the real discovered-node list and its indices. echo "Scanning local subnet for active GPU nodes (Ollama, vLLM, TGI, llama.cpp)..." >&2 PRIMARY_IP=$(get_primary_ip) IP_LIST="" if [ -n "$PRIMARY_IP" ]; then IP_LIST="$PRIMARY_IP" else IP_LIST=$(get_local_subnets) fi # Own addresses are probed separately via the "localhost" check below; # skipping them here avoids discovering this same host twice under two # different names (once by LAN IP, once as "localhost"). get_local_subnets # emits one IP per line (any host with >1 interface, e.g. Docker bridges, # returns several lines) - join with spaces so the " $TARGET_IP " match # below actually matches each entry instead of only the last one. SELF_IPS=" $(get_local_subnets | tr '\n' ' ') " TEMP_FOUND="$(mktemp)" for ip in $IP_LIST; do case "$ip" in 127.*|172.17.*|172.18.*|172.19.*) continue ;; esac PREFIX=$(echo "$ip" | cut -d. -f1-3)"." i=1 while [ $i -le 254 ]; do TARGET_IP="${PREFIX}${i}" case "$SELF_IPS" in *" $TARGET_IP "*) i=$((i+1)); continue ;; esac verify_endpoint "$TARGET_IP" "11434" >> "$TEMP_FOUND" & verify_endpoint "$TARGET_IP" "8000" >> "$TEMP_FOUND" & verify_endpoint "$TARGET_IP" "8080" >> "$TEMP_FOUND" & if [ $((i % 15)) -eq 0 ]; then wait fi i=$((i+1)) done wait done wait # Also check localhost specifically ( verify_endpoint "localhost" "11434" >> "$TEMP_FOUND" & verify_endpoint "localhost" "8000" >> "$TEMP_FOUND" & verify_endpoint "localhost" "8080" >> "$TEMP_FOUND" & wait ) & wait sort -u "$TEMP_FOUND" 2>/dev/null || cat "$TEMP_FOUND" | sort -u rm -f "$TEMP_FOUND" } # Install a systemd unit for marbor so it persists across restarts/ # reboots, instead of the plain `nohup` background process used otherwise. # Requires systemd + root (or sudo). Falls back to the caller starting a # plain background process if systemd isn't available. setup_systemd_service() { if ! command -v systemctl >/dev/null 2>&1; then echo " [!] systemd (systemctl) not found; SERVICE=1 requires systemd." return 1 fi # A pre-existing nohup-managed instance (tracked via pidfile) would fight # the new systemd unit for the same ports. Since we're switching this host # to service-managed mode, stop it rather than leaving the port conflict # for the operator to debug. if [ -f marbor.pid ]; then OLD_PID=$(cat marbor.pid 2>/dev/null || true) if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then echo " Stopping existing background (nohup) instance (PID $OLD_PID)..." kill "$OLD_PID" 2>/dev/null || true sleep 1 fi rm -f marbor.pid fi # Anything else already bound to the admin port (unmanaged process, another # tool) would still make the new unit fail to bind on restart - warn. if curl -fs -m 0.5 "http://localhost:8080/health" >/dev/null 2>&1; then echo " [!] Something is already listening on :8080 (possibly an old" echo " hand-started marbor process). Stop it first, or the" echo " systemd service may fail to bind." fi UNIT_PATH="/etc/systemd/system/marbor.service" WORKDIR="$(pwd)" RUN_USER="${SERVICE_USER:-$(id -un)}" BIN_PATH="$INSTALL_DIR/$BIN_NAME" UNIT_CONTENT="[Unit] Description=marbor After=network-online.target Wants=network-online.target [Service] Type=simple User=${RUN_USER} WorkingDirectory="${WORKDIR}" ExecStart="${BIN_PATH}" --db "${DB_PATH}" Restart=on-failure RestartSec=2 StandardOutput=append:"${WORKDIR}/marbor.log" StandardError=append:"${WORKDIR}/marbor.log" [Install] WantedBy=multi-user.target " if [ "$(id -u)" = "0" ]; then printf '%s' "$UNIT_CONTENT" > "$UNIT_PATH" if [ "$RUN_USER" != "root" ]; then chown "$RUN_USER" "$DB_PATH" 2>/dev/null || true fi # Guarded with `|| true` (not left to abort under set -e): a # daemon-reload failure caused by some unrelated broken unit already on # the host, or a transient enable/restart hiccup, must let the function's # own "is-active" check below decide success/failure and drive the # nohup-fallback path - not silently kill the whole installer here. systemctl daemon-reload || true systemctl enable marbor >/dev/null 2>&1 || true systemctl restart marbor || true elif command -v sudo >/dev/null 2>&1; then printf '%s' "$UNIT_CONTENT" | sudo tee "$UNIT_PATH" >/dev/null sudo systemctl daemon-reload || true sudo systemctl enable marbor >/dev/null 2>&1 || true sudo systemctl restart marbor || true else echo " [!] Writing $UNIT_PATH requires root, and sudo is not available." return 1 fi sleep 1 if systemctl is-active --quiet marbor 2>/dev/null; then echo "marbor installed as a systemd service and running!" echo "--------------------------------------------------------" echo " Proxy Endpoint: http://localhost:11434" echo " Admin Dashboard: http://localhost:8080 (login: admin / admin)" echo " Metrics: http://localhost:9090/metrics" echo " Unit file: $UNIT_PATH" echo " Logs: journalctl -u marbor -f (also ${WORKDIR}/marbor.log)" echo " Database: ${DB_PATH}" echo "--------------------------------------------------------" echo "Enabled - will restart on failure and on reboot." echo "Check status any time with: marbor status (or marbor --help for all commands)" echo "Uninstall: https://raw.githubusercontent.com/$REPO/main/uninstall.sh" return 0 else echo " [!] marbor.service failed to start. Check: journalctl -u marbor -n 50" # A crash-looping unit reports "activating," not "active," one second # after restart - the check above alone doesn't distinguish that from a # real failure, so this branch is also reached with the unit installed # and enabled but still retrying in the background. Without disabling it # here, the caller's nohup fallback below would start a second instance # of the same binary against the same ports/DB while the enabled unit # keeps fighting it for those resources. if [ "$(id -u)" = "0" ]; then systemctl disable --now marbor >/dev/null 2>&1 || true elif command -v sudo >/dev/null 2>&1; then sudo systemctl disable --now marbor >/dev/null 2>&1 || true fi return 1 fi } # Poll a URL until it responds (any HTTP status counts as "up" - we're # checking the port is bound and serving, not asserting a particular route). # Non-fatal to the caller: prints [ok]/[FAIL] and returns 0/1. wait_for_http() { URL="$1" NAME="$2" TRIES=8 i=0 while [ "$i" -lt "$TRIES" ]; do CODE="000" if command -v curl >/dev/null 2>&1; then # curl itself prints "000" on connection failure and exits non-zero; # under `set -e` that non-zero status on a bare assignment would abort # the whole installer, so it's neutralized with `|| true`. Don't also # append a fallback "000" on top of curl's own output, or a real # failure reads as two values concatenated (e.g. "000000"), which no # longer matches "000" and would be misreported as reachable. CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 2 "$URL" 2>/dev/null) || true CODE="${CODE:-000}" elif command -v wget >/dev/null 2>&1; then wget -T 2 -t 1 -qO- "$URL" >/dev/null 2>&1 && CODE="200" fi if [ "$CODE" != "000" ]; then echo " [ok] $NAME responding ($URL -> HTTP $CODE)" return 0 fi i=$((i + 1)) sleep 1 done echo " [FAIL] $NAME not responding at $URL" return 1 } # Real post-install verification: the three listeners the binary starts. # Never fails the install - this is diagnostics for the operator. Ports # reflect marbor's built-in defaults (11434/8080/9090); if you changed # them from the dashboard after a previous run, check there instead. run_health_checks() { LOG_HINT="$1" HEALTH_OK=true echo "" echo "Verifying installation..." wait_for_http "http://localhost:11434/" "Proxy" || HEALTH_OK=false wait_for_http "http://localhost:8080/health" "Admin dashboard" || HEALTH_OK=false wait_for_http "http://localhost:9090/metrics" "Metrics" || HEALTH_OK=false if [ "$HEALTH_OK" = false ]; then echo "" echo " [!] One or more core health checks failed." echo " Troubleshooting:" echo " - Logs: $LOG_HINT" echo " - Port conflicts: confirm nothing else is bound to 11434, 8080, or 9090" fi } DB_PATH="${MARBOR_DB_PATH:-$(pwd)/marbor.db}" # Node discovery + seeding wizard: only runs when we're about to start the # daemon against a fresh database (marbor.db doesn't exist yet), so re-running # the installer against an already-configured host is a no-op here. Nodes # added another way (dashboard, previous install) are left alone. if [ ! -f "$DB_PATH" ] || [ "$FORCE_PROBE" = true ]; then FOUND_IPS=$(probe_network) if [ -z "$FOUND_IPS" ]; then echo " [!] No reachable backends found from this host/network mode on the local subnet." echo " Add one later from the dashboard (Nodes tab) or re-run with FORCE_PROBE=1." else echo "" echo "Found:" N=0 # POSIX sh has no arrays; index the matches with a parallel-numbered # temp file so the selection step below can map "2" back to its line # without relying on bash-only constructs. LIST_FILE="$(mktemp)" for entry in $FOUND_IPS; do N=$((N+1)) echo "$entry" >> "$LIST_FILE" IP=$(echo "$entry" | cut -d: -f1) PORT=$(echo "$entry" | cut -d: -f2) RUNTIME=$(echo "$entry" | cut -d: -f3) echo " $N) $RUNTIME at $IP:$PORT" done SELECTION="" if [ -r /dev/tty ]; then printf "Which do you want to add? (comma-separated numbers, 'all', or 'skip'): " read -r SELECTION < /dev/tty || SELECTION="skip" else echo " [!] No interactive terminal available (piped install) - skipping node selection." echo " Re-run with FORCE_PROBE=1 from an interactive shell to pick nodes, or add them from the dashboard." SELECTION="skip" fi case "$SELECTION" in ""|skip|SKIP|Skip) : ;; all|ALL|All) SEED_ARGS="" n=0 while IFS= read -r entry; do n=$((n+1)) IP=$(echo "$entry" | cut -d: -f1) PORT=$(echo "$entry" | cut -d: -f2) RUNTIME=$(echo "$entry" | cut -d: -f3) SEED_ARGS="$SEED_ARGS --seed-node name=discovered-${RUNTIME}-${n},url=http://${IP}:${PORT},runtime=${RUNTIME}" done < "$LIST_FILE" if [ -n "$SEED_ARGS" ]; then # shellcheck disable=SC2086 # intentional word-splitting of repeatable flags "$BIN_PATH" --db "$DB_PATH" $SEED_ARGS fi ;; *) SEED_ARGS="" OLDIFS="$IFS" IFS=',' for idx in $SELECTION; do idx=$(echo "$idx" | tr -d ' ') [ -z "$idx" ] && continue case "$idx" in ''|*[!0-9]*) echo " [!] Skipping invalid selection: $idx"; continue ;; esac entry=$(sed -n "${idx}p" "$LIST_FILE") [ -z "$entry" ] && { echo " [!] Skipping invalid selection: $idx"; continue; } IP=$(echo "$entry" | cut -d: -f1) PORT=$(echo "$entry" | cut -d: -f2) RUNTIME=$(echo "$entry" | cut -d: -f3) SEED_ARGS="$SEED_ARGS --seed-node name=discovered-${RUNTIME}-${idx},url=http://${IP}:${PORT},runtime=${RUNTIME}" done IFS="$OLDIFS" if [ -n "$SEED_ARGS" ]; then # shellcheck disable=SC2086 # intentional word-splitting of repeatable flags "$BIN_PATH" --db "$DB_PATH" $SEED_ARGS else echo " [!] No valid selections - no nodes added." fi ;; esac rm -f "$LIST_FILE" fi else echo "marbor.db already exists at $DB_PATH - skipping node discovery wizard (re-run with FORCE_PROBE=1 to force it, or add nodes from the dashboard)." fi PIDFILE="marbor.pid" if [ "$SERVICE_MODE" = true ]; then echo "" SVC_MANAGER=$(detect_service_manager) case "$SVC_MANAGER" in systemd) echo "Setting up marbor as a systemd service..." if setup_systemd_service; then run_health_checks "journalctl -u marbor -f (also $(pwd)/marbor.log)" exit 0 fi ;; *) if [ "$OS" = "darwin" ]; then echo " [!] launchd service support isn't implemented yet on macOS (SERVICE=1 currently supports systemd on Linux)." else echo " [!] No supported service manager found for $OS." fi ;; esac echo " Falling back to a plain background start (not persistent across reboots)." fi # Idempotency: if a previous nohup-managed instance is still running, don't # start a second one competing for the same ports - report it and verify # health instead. A stale pidfile (process no longer running) is cleaned up # and a fresh instance is started normally. if [ -f "$PIDFILE" ]; then EXISTING_PID=$(cat "$PIDFILE" 2>/dev/null || true) if [ -n "$EXISTING_PID" ] && kill -0 "$EXISTING_PID" 2>/dev/null; then echo "" echo "marbor is already running in the background (PID $EXISTING_PID)." echo "Skipping duplicate start. To restart: kill $EXISTING_PID and re-run this installer," echo "or re-run with SERVICE=1 to switch to a managed systemd service." echo "Check status any time with: marbor status (or marbor --help for all commands)" if [ -n "$OLD_VERSION" ] && [ "$OLD_VERSION" != "$NEW_VERSION" ]; then echo "Note: the binary on disk was just upgraded to $NEW_VERSION, but the running" echo "process (PID $EXISTING_PID) is still $OLD_VERSION until it's restarted." fi run_health_checks "$(pwd)/marbor.log" exit 0 fi rm -f "$PIDFILE" fi echo "" echo "Starting marbor in the background..." nohup "$INSTALL_DIR/$BIN_NAME" --db "$DB_PATH" > marbor.log 2>&1 & PID=$! echo "$PID" > "$PIDFILE" sleep 2 if kill -0 $PID >/dev/null 2>&1; then echo "marbor successfully started (PID: $PID)!" echo "--------------------------------------------------------" echo " Proxy Endpoint: http://localhost:11434" echo " Admin Dashboard: http://localhost:8080 (login: admin / admin)" echo " Metrics: http://localhost:9090/metrics" echo " Logs: marbor.log" echo " Database: ${DB_PATH}" echo "--------------------------------------------------------" run_health_checks "$(pwd)/marbor.log" echo "" echo "Check status any time with: marbor status (or marbor --help for all commands)" echo "Uninstall: https://raw.githubusercontent.com/$REPO/main/uninstall.sh" else rm -f "$PIDFILE" echo "Error: marbor failed to start. Check marbor.log for details." if [ -f marbor.log ]; then cat marbor.log fi exit 1 fi