install.sh
Rama main · réplica de lectura del repositorio original.
#!/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=<code> MARBOR_SERVER=<url> (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=<token> (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=<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 "<html" "$CHECKSUMS_TMP" 2>/dev/null || grep -qi "<!doctype" "$CHECKSUMS_TMP" 2>/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 <url> <dest>: 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 <file> <release-asset-name>: 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 <unpack>/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=<token> or MARBOR_ENROLL=<code> MARBOR_SERVER=<url>."
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=<code> requires MARBOR_SERVER=<url> (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