#!/usr/bin/env bash # Panel one-line installer — controller + agent + Postgres on a single Linux box. # # curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash # # Non-interactive knobs (env vars, all optional): # PANEL_REPO_URL git/https base of the panel source (default: placeholder, see below) # PANEL_PREBUILT_URL tarball with prebuilt bin/{controller,agent,panelctl} + modules/ (skips Go build) # (default: the published v0.9.1 linux/amd64 release — so # curl|bash needs no Go. Set it EMPTY to force a source build.) # PANEL_VERSION version string baked into the binaries (default: git describe || dev) # PANEL_DIR install prefix (default: /opt/panel) # PANEL_DATA_DIR data directory (default: $PANEL_DIR/data) # PANEL_HTTP_PORT dashboard port (default: 8080) # PANEL_GRPC_PORT agent gRPC port (default: 8443) # PANEL_ADMIN_PASSWORD stable first-boot admin password (default: generated by the controller) # PANEL_DB_URL use an EXISTING Postgres instead of the managed container # PANEL_DB_PASSWORD password for the managed Postgres container (default: generated) # PANEL_DB_CONTAINER name of the managed Postgres container (default: panel-postgres) # PANEL_DB_PORT host port to bind Postgres on (127.0.0.1) (default: 5432) # PANEL_DB_VOLUME docker volume for Postgres data (default: panel_pgdata) # PANEL_SKIP_AGENT=1 install controller only (add agents on other boxes later) # # Run TWO independent panels on one host by giving each install distinct # PANEL_DIR, PANEL_HTTP_PORT, PANEL_GRPC_PORT, PANEL_DB_CONTAINER, # PANEL_DB_PORT and PANEL_DB_VOLUME values so nothing collides. # # Idempotent: safe to re-run — existing containers, units, CA, DB and # passwords are kept; binaries and modules are refreshed. set -euo pipefail PANEL_REPO_URL="${PANEL_REPO_URL:-https://git.pdxtechs.com/dbledeez/panel}" # Default to the published prebuilt release so `curl … | sudo bash` works with no # Go toolchain and no env. Explicitly set PANEL_PREBUILT_URL= (empty) to force the # git-clone + Go source-build fallback instead. A local checkout (running # ./install.sh from inside the repo) always wins over this default — see §4. PANEL_PREBUILT_DEFAULT="https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.1/panel-v0.9.1-linux-amd64.tar.gz" # Was the var set in the environment at all (even to empty)? Distinguishes an # operator who explicitly wants prebuilt from the plain default. if [ "${PANEL_PREBUILT_URL+set}" = set ]; then PANEL_PREBUILT_URL_EXPLICIT=1; else PANEL_PREBUILT_URL_EXPLICIT=""; fi PANEL_PREBUILT_URL="${PANEL_PREBUILT_URL-$PANEL_PREBUILT_DEFAULT}" PANEL_DIR="${PANEL_DIR:-/opt/panel}" PANEL_DATA_DIR="${PANEL_DATA_DIR:-$PANEL_DIR/data}" PANEL_HTTP_PORT="${PANEL_HTTP_PORT:-8080}" PANEL_GRPC_PORT="${PANEL_GRPC_PORT:-8443}" PANEL_ADMIN_EMAIL="${PANEL_ADMIN_EMAIL:-}" PANEL_ADMIN_PASSWORD="${PANEL_ADMIN_PASSWORD:-}" # Public HTTPS URL when behind a reverse proxy — required for Steam login to # work through the proxy (e.g. https://panel.example.com). Leave empty for # direct LAN/IP access. PANEL_PUBLIC_URL="${PANEL_PUBLIC_URL:-}" PANEL_DB_URL="${PANEL_DB_URL:-}" PANEL_DB_PASSWORD="${PANEL_DB_PASSWORD:-}" PANEL_DB_CONTAINER="${PANEL_DB_CONTAINER:-panel-postgres}" PANEL_DB_PORT="${PANEL_DB_PORT:-5432}" PANEL_DB_VOLUME="${PANEL_DB_VOLUME:-panel_pgdata}" PANEL_SKIP_AGENT="${PANEL_SKIP_AGENT:-0}" PANEL_USER=panel PG_CONTAINER="$PANEL_DB_CONTAINER" # Legacy escape hatch (kept for compatibility): a release asset tried only when # no prebuilt URL, no local checkout and no Go are available. Now that # PANEL_PREBUILT_URL defaults to the published release above, this is rarely hit — # it only matters if someone explicitly emptied PANEL_PREBUILT_URL but still has # no Go and no checkout. PANEL_RELEASE_TARBALL_URL="${PANEL_RELEASE_TARBALL_URL:-$PANEL_PREBUILT_DEFAULT}" log() { printf '\033[1;32m[panel]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[panel]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[panel]\033[0m ERROR: %s\n' "$*" >&2; exit 1; } [ "$(id -u)" = 0 ] || die "run as root (sudo bash install.sh)" command -v curl >/dev/null || die "curl is required" # ---- 0. Admin account: prompt when interactive --------------------------- # When a real person runs this (a TTY is available), ask them to choose the # admin login + password up front — no digging a generated password out of # the log afterwards. When piped non-interactively (curl | bash with no TTY, # CI, cloud-init) we skip the prompt: any PANEL_ADMIN_* env values are used, # and a missing password is generated by the controller as before. if [ -z "$PANEL_ADMIN_PASSWORD" ] && [ -r /dev/tty ] && { [ -t 0 ] || [ -t 1 ]; }; then printf '\n\033[1;36m── Set up your panel admin login ──\033[0m\n' > /dev/tty if [ -z "$PANEL_ADMIN_EMAIL" ]; then printf 'Admin email [admin@panel.local]: ' > /dev/tty read -r _in_email < /dev/tty || _in_email="" PANEL_ADMIN_EMAIL="${_in_email:-}" fi while [ -z "$PANEL_ADMIN_PASSWORD" ]; do printf 'Admin password (min 8 chars, blank = auto-generate): ' > /dev/tty stty -echo 2>/dev/null < /dev/tty read -r _pw1 < /dev/tty || _pw1="" stty echo 2>/dev/null < /dev/tty; printf '\n' > /dev/tty if [ -z "$_pw1" ]; then warn "no password entered — the controller will generate one and print it." break fi if [ "${#_pw1}" -lt 8 ]; then printf ' password too short (need 8+); try again.\n' > /dev/tty continue fi printf 'Confirm password: ' > /dev/tty stty -echo 2>/dev/null < /dev/tty read -r _pw2 < /dev/tty || _pw2="" stty echo 2>/dev/null < /dev/tty; printf '\n' > /dev/tty if [ "$_pw1" != "$_pw2" ]; then printf ' passwords did not match; try again.\n' > /dev/tty continue fi PANEL_ADMIN_PASSWORD="$_pw1" done unset _in_email _pw1 _pw2 fi # ---- 1. Distro detection ------------------------------------------------- DISTRO=unknown if [ -r /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release DISTRO="${ID:-unknown}" fi log "distro: $DISTRO" # ---- 2. Docker ----------------------------------------------------------- if command -v docker >/dev/null 2>&1; then log "docker present: $(docker --version)" else case "$DISTRO" in ubuntu|debian|fedora|centos|rhel|raspbian) log "installing Docker via get.docker.com" curl -fsSL https://get.docker.com | sh ;; *) die "Docker not found and no auto-install path for '$DISTRO'. Install Docker Engine first: https://docs.docker.com/engine/install/ then re-run." ;; esac fi systemctl enable --now docker >/dev/null 2>&1 || true docker info >/dev/null 2>&1 || die "docker daemon is not running/reachable" # ---- 3. Postgres --------------------------------------------------------- mkdir -p "$PANEL_DIR/bin" "$PANEL_DATA_DIR" ENV_FILE="$PANEL_DIR/panel.env" if [ -n "$PANEL_DB_URL" ]; then log "using external Postgres from \$PANEL_DB_URL" else if docker inspect "$PG_CONTAINER" >/dev/null 2>&1; then log "postgres container '$PG_CONTAINER' already exists — starting" docker start "$PG_CONTAINER" >/dev/null # Recover the password persisted by a previous run. if [ -z "$PANEL_DB_PASSWORD" ] && [ -f "$ENV_FILE" ]; then PANEL_DB_PASSWORD="$(sed -n 's/^PANEL_DB_PASSWORD=//p' "$ENV_FILE" | head -n1)" fi [ -n "$PANEL_DB_PASSWORD" ] || die "existing $PG_CONTAINER but no PANEL_DB_PASSWORD (set it, or remove the container to start fresh)" else if [ -z "$PANEL_DB_PASSWORD" ]; then PANEL_DB_PASSWORD="$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')" fi log "starting postgres container '$PG_CONTAINER' on 127.0.0.1:$PANEL_DB_PORT (volume $PANEL_DB_VOLUME)" docker run -d --name "$PG_CONTAINER" --restart unless-stopped \ -e POSTGRES_USER=panel -e POSTGRES_PASSWORD="$PANEL_DB_PASSWORD" -e POSTGRES_DB=panel \ -p "127.0.0.1:$PANEL_DB_PORT:5432" \ -v "$PANEL_DB_VOLUME:/var/lib/postgresql/data" \ postgres:17-alpine >/dev/null fi PANEL_DB_URL="postgres://panel:${PANEL_DB_PASSWORD}@127.0.0.1:$PANEL_DB_PORT/panel?sslmode=disable" log "waiting for postgres to accept connections" for _ in $(seq 1 30); do if docker exec "$PG_CONTAINER" pg_isready -U panel -d panel >/dev/null 2>&1; then break; fi sleep 1 done docker exec "$PG_CONTAINER" pg_isready -U panel -d panel >/dev/null 2>&1 || die "postgres did not become ready" fi # ---- 4. Source / binaries ------------------------------------------------ SRC="" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" >/dev/null 2>&1 && pwd || true)" if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/go.work" ] && [ -d "$SCRIPT_DIR/controller" ]; then SRC="$SCRIPT_DIR" log "using local checkout at $SRC" # A local checkout means the operator wants to build THIS tree. Don't silently # override it with the prebuilt release default. (An explicit PANEL_PREBUILT_URL # env still forces prebuilt — it survives because it was exported, not defaulted.) if [ -z "${PANEL_PREBUILT_URL_EXPLICIT:-}" ] && [ "$PANEL_PREBUILT_URL" = "$PANEL_PREBUILT_DEFAULT" ]; then PANEL_PREBUILT_URL="" fi fi # If no prebuilt URL, no local checkout and no Go, but a release tarball URL is # known, auto-fall-back to the published release so the one-liner still works. if [ -z "$PANEL_PREBUILT_URL" ] && [ -z "$SRC" ] \ && ! command -v go >/dev/null 2>&1 && [ -n "$PANEL_RELEASE_TARBALL_URL" ]; then log "no Go toolchain — falling back to the published release tarball" PANEL_PREBUILT_URL="$PANEL_RELEASE_TARBALL_URL" fi if [ -n "$PANEL_PREBUILT_URL" ]; then log "downloading prebuilt binaries: $PANEL_PREBUILT_URL" TMP_TGZ="$(mktemp)" curl -fsSL "$PANEL_PREBUILT_URL" -o "$TMP_TGZ" tar -xzf "$TMP_TGZ" -C "$PANEL_DIR" rm -f "$TMP_TGZ" [ -x "$PANEL_DIR/bin/controller" ] || die "prebuilt tarball did not contain bin/controller" else if [ -z "$SRC" ]; then command -v git >/dev/null || die "git is required to fetch sources (or set PANEL_PREBUILT_URL)" SRC="$PANEL_DIR/src" # BUG-4: the src tree is cloned as root then chowned to $PANEL_USER; on a # re-run git would refuse with "detected dubious ownership". Mark it safe # for whatever user runs git (root here) so re-runs are idempotent. git config --global --add safe.directory "$SRC" 2>/dev/null || true if [ -d "$SRC/.git" ]; then log "updating source checkout at $SRC" git -C "$SRC" pull --ff-only else log "cloning $PANEL_REPO_URL" git clone --depth 1 "$PANEL_REPO_URL" "$SRC" fi fi if ! command -v go >/dev/null 2>&1; then die "Go toolchain not found. The one-line installer normally uses prebuilt binaries; falling back to a source build requires Go >= 1.26 (https://go.dev/dl/). Alternatively set PANEL_PREBUILT_URL to a prebuilt tarball, or PANEL_RELEASE_TARBALL_URL to a published release asset." fi VERSION="${PANEL_VERSION:-$(git -C "$SRC" describe --tags --always --dirty 2>/dev/null || echo dev)}" LDFLAGS="-s -w -X github.com/dbledeez/panel/pkg/version.Version=$VERSION" log "building panel $VERSION" (cd "$SRC" && \ CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o "$PANEL_DIR/bin/controller" ./controller/cmd/controller && \ CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o "$PANEL_DIR/bin/agent" ./agent/cmd/agent && \ CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o "$PANEL_DIR/bin/panelctl" ./controller/cmd/panelctl) log "syncing game modules" rm -rf "$PANEL_DIR/modules.new" cp -r "$SRC/modules" "$PANEL_DIR/modules.new" rm -rf "$PANEL_DIR/modules" mv "$PANEL_DIR/modules.new" "$PANEL_DIR/modules" fi # ---- 5. User + env file + systemd units ---------------------------------- if ! id "$PANEL_USER" >/dev/null 2>&1; then useradd --system --home-dir "$PANEL_DIR" --shell /usr/sbin/nologin "$PANEL_USER" fi usermod -aG docker "$PANEL_USER" 2>/dev/null || true umask 077 { echo "PANEL_DB_URL=$PANEL_DB_URL" [ -n "$PANEL_DB_PASSWORD" ] && echo "PANEL_DB_PASSWORD=$PANEL_DB_PASSWORD" [ -n "$PANEL_ADMIN_EMAIL" ] && echo "PANEL_ADMIN_EMAIL=$PANEL_ADMIN_EMAIL" [ -n "$PANEL_ADMIN_PASSWORD" ] && echo "PANEL_ADMIN_PASSWORD=$PANEL_ADMIN_PASSWORD" [ -n "$PANEL_PUBLIC_URL" ] && echo "PANEL_PUBLIC_URL=$PANEL_PUBLIC_URL" echo "PANEL_HTTP_PORT=$PANEL_HTTP_PORT" echo "PANEL_GRPC_PORT=$PANEL_GRPC_PORT" } > "$ENV_FILE" umask 022 chown -R "$PANEL_USER:$PANEL_USER" "$PANEL_DIR" chmod 600 "$ENV_FILE" # BUG-2/3/5: do NOT install deploy/systemd/*.service verbatim — those hardcode # /opt/panel, ProtectHome=true and stale :8080/:8443 in the Description. Instead # GENERATE both units here, substituting the REAL install parameters. # # BUG-3: systemd ProtectHome=true makes any binary under /home unreadable to the # service (203/EXEC). When PANEL_DIR is under /home we emit the unit WITHOUT # ProtectHome so it actually runs (reduced hardening, noted inline). The default # /opt/panel keeps ProtectHome=true. case "$PANEL_DIR/" in /home/*) PROTECT_HOME=""; log "PANEL_DIR is under /home — generating units without ProtectHome (reduced hardening)";; *) PROTECT_HOME="ProtectHome=true";; esac write_unit() { # $1 = destination path cat > "$1" chmod 0644 "$1" } write_unit /etc/systemd/system/panel-controller.service </dev/null systemctl restart panel-controller log "waiting for the controller on :$PANEL_HTTP_PORT" for _ in $(seq 1 60); do if curl -fsS "http://127.0.0.1:$PANEL_HTTP_PORT/login" >/dev/null 2>&1; then break fi sleep 1 done curl -fsS "http://127.0.0.1:$PANEL_HTTP_PORT/login" >/dev/null 2>&1 || die "controller did not come up — check: journalctl -u panel-controller" ADMIN_EMAIL="${PANEL_ADMIN_EMAIL:-admin@panel.local}" # BUG-5b: always give the operator a path to the password. If they supplied/chose # one, say so and point at the env file; otherwise scrape the generated one below. ADMIN_PW_SHOWN="(the password you set at install; also in $ENV_FILE)" if [ -z "$PANEL_ADMIN_PASSWORD" ]; then if [ "$FIRST_BOOT" = 1 ]; then # The controller prints the generated password once in its log. GEN_PW="$(journalctl -u panel-controller --since "$BOOT_MARK" --no-pager 2>/dev/null \ | sed -n 's/.*password: \([a-f0-9]\{16\}\).*/\1/p' | head -n1 || true)" if [ -n "$GEN_PW" ]; then ADMIN_PW_SHOWN="$GEN_PW" PANEL_ADMIN_PASSWORD="$GEN_PW" else ADMIN_PW_SHOWN="(see: journalctl -u panel-controller | grep 'password:')" fi else ADMIN_PW_SHOWN="(unchanged from previous install)" fi fi # ---- 7. Pair token + local agent ------------------------------------------ PAIR_TOKEN="" if [ -n "$PANEL_ADMIN_PASSWORD" ]; then COOKIES="$(mktemp)" if curl -fsS -c "$COOKIES" -H 'Content-Type: application/json' \ -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$PANEL_ADMIN_PASSWORD\"}" \ "http://127.0.0.1:$PANEL_HTTP_PORT/api/login" >/dev/null 2>&1; then PAIR_TOKEN="$(curl -fsS -b "$COOKIES" -H 'Content-Type: application/json' \ -d '{"description":"install.sh local agent","expires_minutes":60}' \ "http://127.0.0.1:$PANEL_HTTP_PORT/api/admin/pair-tokens" 2>/dev/null \ | sed -n 's/.*"token":"\([^"]*\)".*/\1/p' || true)" fi rm -f "$COOKIES" fi PAIR_CMD="$PANEL_DIR/bin/agent --pair-url http://:$PANEL_HTTP_PORT --pair-token --cert-dir ./data/certs" if [ "$PANEL_SKIP_AGENT" != 1 ]; then if [ -f "$PANEL_DATA_DIR/certs/agent.crt" ]; then log "local agent already paired — restarting" systemctl enable --now panel-agent >/dev/null systemctl restart panel-agent elif [ -n "$PAIR_TOKEN" ]; then log "pairing local agent" (cd "$PANEL_DIR" && sudo -u "$PANEL_USER" \ "$PANEL_DIR/bin/agent" --pair-url "http://127.0.0.1:$PANEL_HTTP_PORT" \ --pair-token "$PAIR_TOKEN" --cert-dir ./data/certs) systemctl enable --now panel-agent >/dev/null else warn "could not auto-create a pair token (admin password unknown to this run)." warn "Create one in the dashboard (Admin → Pair tokens) and run:" warn " cd $PANEL_DIR && sudo -u $PANEL_USER $PAIR_CMD" warn "then: systemctl enable --now panel-agent" fi fi # ---- 8. Summary ------------------------------------------------------------ HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || echo 127.0.0.1)" echo log "──────────────────────────────────────────────────────────" log " Panel is installed." log " Dashboard : http://${HOST_IP:-127.0.0.1}:$PANEL_HTTP_PORT" log " Login : $ADMIN_EMAIL" log " Password : $ADMIN_PW_SHOWN" log " (log in once and change the password in the UI)" log "" log " To pair an agent on ANOTHER machine, create a pair token in the" log " dashboard, copy the panel binaries + modules/ there, then run:" log " $PAIR_CMD" log " and start it: ./bin/agent --controller ${HOST_IP:-}:$PANEL_GRPC_PORT --cert-dir ./data/certs" log "──────────────────────────────────────────────────────────"