Confirmed live: install_frigate()'s fresh-install path overwrote a working, hand-crafted docker-compose.yml (Frigate + mosquitto + frigate-notify) with zero backup, because that file's shape didn't match what frigate.sh's own "existing install" detection knew how to recognize. Every service's own detection is a judgment call about what counts as "already installed" and can miss a real setup built outside this repo's conventions. lib/common.sh gains backup_if_exists(FILE) — copies FILE to FILE.bak.<timestamp> if it exists, no-ops otherwise (including DRY_RUN). Applied before every service's own `cat > docker-compose.yml`/`cat > .env` write across all 60 services that do one (115 call sites), plus a matching standalone-mode stub added to every service's own bootstrap block, same convention already used for port_in_use/find_free_port. This doesn't replace a service's own update/fresh-reinstall detection — it's the safety net underneath it, so a wrong detection costs a .bak file to restore from instead of the original silently disappearing. Also fixes the actual gap that surfaced this: services/frigate.sh's Authelia offer only checked for Authelia installed locally on Frigate's own box, which is never true for a dedicated NVR box with no local Caddy either (the common shape — Caddy lives elsewhere, snippet-generation mode already handles that). Now offers Authelia protection unconditionally and, when Authelia isn't local, asks whether it lives on the same machine as Caddy (still "import authelia", since that's local to wherever Caddy ends up) or on a genuinely separate third machine (the explicit header-pinned forward_auth form, per CLAUDE.md's "forward_auth to a remote Authelia" note, needed because a bare authelia:9091 shortcut only works one hop).
772 lines
34 KiB
Bash
772 lines
34 KiB
Bash
#!/bin/bash
|
|
# services/beszel.sh — Beszel lightweight server + Docker monitoring (hub + agent).
|
|
# Part of the modular post-install system (sourced by setup.sh).
|
|
#
|
|
# Can also be run standalone on any machine:
|
|
# sudo bash beszel.sh
|
|
# (Docker must already be installed when run standalone)
|
|
#
|
|
# Beszel is a hub+agent monitor: the hub is the web dashboard, the agent runs
|
|
# on each box you want metrics from and reports host resources (CPU, RAM,
|
|
# disk, network) plus per-container Docker stats — read directly off
|
|
# /var/run/docker.sock (mounted read-only), so it picks up whatever
|
|
# containers are currently running automatically. No per-container config;
|
|
# add/remove a service on this box and the agent just reports what it sees
|
|
# on its next poll.
|
|
#
|
|
# This installs both hub and agent on THIS box (the "same-system" layout —
|
|
# see supplemental/docker/same-system/docker-compose.yml in beszel's own
|
|
# repo, which this mirrors). The agent can also be pointed at additional
|
|
# remote boxes later; that's a from-the-hub-UI step, not something this
|
|
# installer sets up.
|
|
#
|
|
# Complements Gatus, doesn't replace it: Gatus is a black-box HTTP check —
|
|
# is the site actually responding, from the outside, like a visitor would
|
|
# see it. Beszel is white-box host/process monitoring — is the box itself
|
|
# under memory/disk pressure, is a specific container actually running vs.
|
|
# crash-looping. A container can be "Up" in Docker (Beszel sees it as
|
|
# healthy) while the app inside it is serving errors or hanging (only
|
|
# Gatus's actual HTTP probe would catch that) — they answer different
|
|
# questions, worth running both.
|
|
#
|
|
# Two-phase install, unavoidably: the hub generates its own SSH keypair and
|
|
# (once you enable it in Settings → Tokens & Fingerprints) a universal
|
|
# token, both only available after you've logged into the hub's web UI at
|
|
# least once — there's no way to generate or guess these ahead of time.
|
|
# This installer starts the hub first, waits for you to grab those two
|
|
# values, then wires up the agent. Re-run this installer later if you skip
|
|
# the agent step now — it'll pick up right where you left off instead of
|
|
# re-asking everything.
|
|
|
|
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
|
|
|
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
_COMMON="$_SELF_DIR/../lib/common.sh"
|
|
|
|
if [[ -f "$_COMMON" ]]; then
|
|
# shellcheck source=../lib/common.sh
|
|
source "$_COMMON"
|
|
else
|
|
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
|
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
|
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
|
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
|
|
|
require_docker() {
|
|
command -v docker &>/dev/null || {
|
|
log_error "Docker not found. Install it first:"
|
|
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
|
return 1
|
|
}
|
|
docker compose version &>/dev/null || {
|
|
log_error "Docker Compose plugin missing:"
|
|
log_error " sudo apt-get install -y docker-compose-plugin"
|
|
return 1
|
|
}
|
|
}
|
|
|
|
ensure_docker_dir_ownership() {
|
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
|
}
|
|
|
|
port_in_use() {
|
|
local _port="$1" _proto="${2:-tcp}"
|
|
local _flag="-tlnH"
|
|
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
|
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
|
}
|
|
|
|
find_free_port() {
|
|
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
|
while port_in_use "$_port" "$_proto"; do
|
|
_port=$((_port + 1))
|
|
done
|
|
eval "$_varname='$_port'"
|
|
}
|
|
|
|
prompt_text() {
|
|
local _q="$1" _def="$2" _var="$3" _r
|
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
|
read -r -p " $_q " _r
|
|
eval "$_var='${_r:-$_def}'"
|
|
}
|
|
|
|
prompt_yn() {
|
|
local _q="$1" _def="$2" _var="$3" _r
|
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
|
read -r -p " $_q " _r
|
|
eval "$_var='${_r:-$_def}'"
|
|
}
|
|
|
|
prompt_reinstall_mode() {
|
|
local _var="$1" _r
|
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='cancel'"; return; }
|
|
echo " 1) Update (refresh image, keep everything else)"
|
|
echo " 2) Fresh reinstall"
|
|
echo " 3) Cancel"
|
|
read -r -p " Choice [1/2/3]: " _r
|
|
case "$_r" in
|
|
2) eval "$_var='fresh'" ;;
|
|
1|"") eval "$_var='update'" ;;
|
|
*) eval "$_var='cancel'" ;;
|
|
esac
|
|
}
|
|
|
|
register_service() { :; }
|
|
write_readme() {
|
|
local _dir="$1"; mkdir -p "$_dir"; cat > "$_dir/README.md"
|
|
local _companion; _companion="$(dirname "${BASH_SOURCE[0]}")/beszel.md"
|
|
[ -f "$_companion" ] && cat "$_companion" >> "$_dir/README.md"
|
|
}
|
|
backup_if_exists() {
|
|
local _file="$1"
|
|
[ -f "$_file" ] || return 0
|
|
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
|
}
|
|
fi
|
|
|
|
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
|
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
|
DRY_RUN="${DRY_RUN:-false}"
|
|
UNATTENDED="${UNATTENDED:-false}"
|
|
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
|
|
|
_RUN_STANDALONE=1
|
|
fi
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
register_service beszel utilities "Lightweight server + Docker monitoring (Beszel) — CPU/RAM/disk/network, auto-discovers running containers" 8090
|
|
|
|
# Self-heals an ALREADY-INSTALLED agent's docker-compose.yml with the
|
|
# systemd/dbus/sensor mounts and apparmor:unconfined — both added to the
|
|
# fresh-install template after plenty of boxes already existed, and
|
|
# "update" mode otherwise never touches docker-compose.yml at all (a plain
|
|
# pull+restart), so those boxes would stay missing both fixes forever
|
|
# without this. Anchors on `network_mode: host` and the docker.sock mount
|
|
# line — both unique to the beszel-agent service and present in either
|
|
# compose shape (combined hub+agent, or agent-only), so one function
|
|
# safely covers both install_beszel()'s and install_beszel-agent()'s
|
|
# update paths. Each of the two fixes is checked and applied independently
|
|
# (idempotent either way), so a box that already picked up one but not the
|
|
# other — e.g. updated between the two commits that added them — still
|
|
# gets exactly the one it's missing, not a duplicate of the one it has.
|
|
_beszel_patch_agent_compose() {
|
|
local compose_file="$1"
|
|
[ -f "$compose_file" ] || return 0
|
|
|
|
if ! grep -q 'apparmor:unconfined' "$compose_file"; then
|
|
awk '
|
|
{ print }
|
|
/^ network_mode: host$/ && !done {
|
|
print " security_opt:"
|
|
print " - apparmor:unconfined"
|
|
done=1
|
|
}
|
|
' "$compose_file" > "$compose_file.tmp" && mv "$compose_file.tmp" "$compose_file"
|
|
log_success "Added security_opt: apparmor:unconfined to $compose_file"
|
|
fi
|
|
|
|
if ! grep -q '/var/run/dbus/system_bus_socket' "$compose_file"; then
|
|
awk '
|
|
{ print }
|
|
/^ - \/var\/run\/docker\.sock:\/var\/run\/docker\.sock:ro$/ && !done {
|
|
print " - /var/run/systemd/private:/var/run/systemd/private:ro"
|
|
print " - /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro"
|
|
print " - /sys/class/hwmon:/sys/class/hwmon:ro"
|
|
print " - /sys/class/thermal:/sys/class/thermal:ro"
|
|
done=1
|
|
}
|
|
' "$compose_file" > "$compose_file.tmp" && mv "$compose_file.tmp" "$compose_file"
|
|
log_success "Added systemd/dbus/sensor mounts to $compose_file"
|
|
fi
|
|
}
|
|
|
|
# Pulls a NAME's value out of a pasted blob regardless of which shape it
|
|
# arrives in — Beszel's own "copy for docker compose" button (the most
|
|
# prominent option next to the key/token fields, confirmed live to be
|
|
# what people actually click) hands you YAML (`KEY: 'value'` or
|
|
# `- KEY=value`), not a bare string — matches "KEY"/"TOKEN" with either
|
|
# `:` or `=`, optional leading `- ` list marker, and strips a single
|
|
# layer of surrounding quotes if present.
|
|
_beszel_extract_field() {
|
|
local field="$1" blob="$2"
|
|
echo "$blob" | grep -iE "^[[:space:]]*-?[[:space:]]*${field}[[:space:]]*[:=]" | head -1 \
|
|
| sed -E "s/^[[:space:]]*-?[[:space:]]*${field}[[:space:]]*[:=][[:space:]]*//I" \
|
|
| sed -E "s/^[\"']//; s/[\"'][[:space:]]*\$//"
|
|
}
|
|
|
|
_beszel_configure_agent() {
|
|
local dir="$1" login_url="$2"
|
|
|
|
echo ""
|
|
echo " To finish connecting the agent, log into the hub first:"
|
|
echo " ${login_url}"
|
|
echo ""
|
|
echo " Create the admin account, then go to Settings → Tokens & Fingerprints:"
|
|
echo " - Enable the universal token"
|
|
echo " - The key/token fields there usually offer a 'copy for docker"
|
|
echo " compose' shortcut — paste whatever that gives you below as-is,"
|
|
echo " whole snippet or just the two lines, doesn't matter which."
|
|
echo ""
|
|
|
|
if [ "${UNATTENDED:-false}" = "true" ]; then
|
|
log_info "Skipping agent setup (unattended) — re-run this installer to finish it."
|
|
return 0
|
|
fi
|
|
|
|
echo " Paste it below, then an empty line to finish (blank first line to skip for now):"
|
|
local PASTE="" LINE
|
|
while IFS= read -r LINE; do
|
|
[ -z "$LINE" ] && break
|
|
PASTE="${PASTE}${LINE}"$'\n'
|
|
done
|
|
|
|
if [ -z "$PASTE" ]; then
|
|
log_info "Skipping agent setup for now — re-run this installer once you have the key and token."
|
|
return 0
|
|
fi
|
|
|
|
local AGENT_KEY AGENT_TOKEN
|
|
AGENT_KEY="$(_beszel_extract_field KEY "$PASTE")"
|
|
AGENT_TOKEN="$(_beszel_extract_field TOKEN "$PASTE")"
|
|
|
|
# Didn't find a labeled KEY/TOKEN line at all — treat the paste as a
|
|
# single bare value and ask for the other one directly, so a UI that
|
|
# really does just show plain strings (no YAML) still works.
|
|
if [ -z "$AGENT_KEY" ] && [ -z "$AGENT_TOKEN" ]; then
|
|
AGENT_KEY="$(echo "$PASTE" | head -1)"
|
|
prompt_text " Paste the universal token:" "" AGENT_TOKEN
|
|
fi
|
|
|
|
if [ -z "$AGENT_KEY" ] || [ -z "$AGENT_TOKEN" ]; then
|
|
log_warning "Couldn't find both a key and a token in that — skipping agent setup. Re-run this installer to try again."
|
|
return 0
|
|
fi
|
|
|
|
{
|
|
echo "AGENT_KEY=$AGENT_KEY"
|
|
echo "AGENT_TOKEN=$AGENT_TOKEN"
|
|
} >> "$dir/.env"
|
|
chmod 600 "$dir/.env"
|
|
|
|
( cd "$dir" && docker compose up -d beszel-agent ) \
|
|
&& log_success "Agent connected — it should appear in the hub within a few seconds." \
|
|
|| log_warning "Agent failed to start — check: docker compose -f $dir/docker-compose.yml logs beszel-agent"
|
|
}
|
|
|
|
# Registers Beszel as an Authelia OIDC client and prints exactly what to
|
|
# paste into the hub's own settings. Checked against beszel.dev's own OAuth
|
|
# docs directly: Beszel is PocketBase-based, and its OAuth2 provider config
|
|
# is a PocketBase admin-UI setting (Settings -> Auth providers), not
|
|
# something exposed by any documented API or env var — so, like
|
|
# Audiobookshelf, this only automates the Authelia half. Beszel's own
|
|
# Authelia integration doc (authelia.com) requires PKCE.
|
|
#
|
|
# Args: DIR (the .env holding DISABLE_PASSWORD_AUTH/USER_CREATION lives there)
|
|
_beszel_offer_authelia_oidc() {
|
|
local dir="$1"
|
|
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
|
|
[ -d "$DOCKER_DIR/authelia" ] || return 0
|
|
|
|
echo ""
|
|
local USE_SSO=""
|
|
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Beszel? (y/n):" "n" USE_SSO
|
|
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
|
|
|
|
local APP_DOMAIN
|
|
APP_DOMAIN="$(_authelia_pick_domain "Domain Beszel is reachable at (number or domain)")"
|
|
if [ -z "$APP_DOMAIN" ]; then
|
|
log_warning "No domain entered — skipping SSO setup."
|
|
return 0
|
|
fi
|
|
|
|
local _2fa="" AUTH_POLICY="two_factor"
|
|
prompt_yn " Require two-factor for Beszel logins via Authelia too? (y/n):" "y" _2fa
|
|
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
|
|
|
|
if ! _authelia_provision_oidc_client "Beszel" "beszel" "$AUTH_POLICY" "y" "y" \
|
|
"https://${APP_DOMAIN}/api/oauth2-redirect"; then
|
|
log_warning "Couldn't register Beszel as an OIDC client in Authelia — skipping SSO setup."
|
|
return 0
|
|
fi
|
|
|
|
echo ""
|
|
echo " This lives in PocketBase's own admin panel underneath the hub, not the"
|
|
echo " hub's own Settings page — checked against beszel.dev's OAuth guide"
|
|
echo " directly, exact steps:"
|
|
echo " 1) Go to https://<your-beszel-domain>/_/#/settings and toggle OFF"
|
|
echo " \"Hide collection create and edit controls\""
|
|
echo " 2) Go to Collections, edit the \"users\" collection"
|
|
echo " 3) Options tab -> enable OAuth2 -> Add provider, fill in:"
|
|
echo " Client ID: beszel"
|
|
echo " Client Secret: $OIDC_CLIENT_SECRET_PLAIN"
|
|
echo " Auth URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/authorization"
|
|
echo " Token URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/token"
|
|
echo " User Info URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/userinfo"
|
|
echo " 4) Save, then toggle \"Hide collection create and edit controls\" back ON"
|
|
echo " at /_/#/settings — leaving it off is its own exposure once you're done"
|
|
echo ""
|
|
log_warning "The Client Secret above is shown once — save it now."
|
|
|
|
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "beszel" "$APP_DOMAIN"
|
|
|
|
echo ""
|
|
log_info "Paste those values into Beszel's Settings -> Auth providers -> OpenID"
|
|
log_info "Connect page now, then log out and click through the Authelia login"
|
|
log_info "button to confirm it actually works — BEFORE going any further here."
|
|
echo ""
|
|
log_warning "The next step can disable Beszel's password login entirely. Confirmed"
|
|
log_warning "live: saying yes here before actually testing the button leaves NEITHER"
|
|
log_warning "login path working — the password form is gone, and the OAuth provider"
|
|
log_warning "was never actually finished on Beszel's side, so its button never"
|
|
log_warning "appears either. Re-run 'sudo ./setup.sh beszel' (choose update) any time"
|
|
log_warning "later to come back to this once you've verified the button works."
|
|
}
|
|
|
|
# Split out from _beszel_offer_authelia_oidc so it can also be re-reached on
|
|
# its own via a later "update" rerun, once the admin has actually gone and
|
|
# tested the Authelia login button — see that function's own warning for
|
|
# why this can't be offered in the same breath as printing the paste-in
|
|
# values. DISABLE_PASSWORD_AUTH/USER_CREATION are real, documented env vars
|
|
# (beszel.dev's own OAuth guide).
|
|
_beszel_offer_disable_password_auth() {
|
|
local dir="$1"
|
|
[ -f "$dir/.env" ] || return 0
|
|
grep -qF "client_id: 'beszel'" "$DOCKER_DIR/authelia/config/configuration.yml" 2>/dev/null || return 0
|
|
grep -q '^DISABLE_PASSWORD_AUTH=true' "$dir/.env" 2>/dev/null && return 0
|
|
|
|
echo ""
|
|
local _tested=""
|
|
prompt_yn " Have you ALREADY logged into Beszel successfully using the Authelia button (not just pasted the values)? (y/n):" "n" _tested
|
|
if [[ ! "$_tested" =~ ^[Yy]$ ]]; then
|
|
log_info "Skipped. Test the Authelia login button first, then re-run 'sudo ./setup.sh beszel' (choose update) to come back to this."
|
|
return 0
|
|
fi
|
|
|
|
local _disable_local=""
|
|
prompt_yn " Disable Beszel's own password login now, so Authelia is the only way in? (y/n):" "n" _disable_local
|
|
[[ "$_disable_local" =~ ^[Yy]$ ]] || return 0
|
|
|
|
local _auto_register=""
|
|
prompt_yn " Auto-create Beszel accounts for new Authelia logins? (y/n):" "n" _auto_register
|
|
sed -i '/^DISABLE_PASSWORD_AUTH=/d; /^USER_CREATION=/d' "$dir/.env"
|
|
{
|
|
echo "DISABLE_PASSWORD_AUTH=true"
|
|
[[ "$_auto_register" =~ ^[Yy]$ ]] && echo "USER_CREATION=true"
|
|
} >> "$dir/.env"
|
|
chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/.env" 2>/dev/null || true
|
|
( cd "$dir" && docker compose up -d beszel ) \
|
|
&& log_success "Beszel's own password login is now disabled — Authelia is the only way in." \
|
|
|| log_warning "Restart failed — check: docker compose -f $dir/docker-compose.yml logs beszel"
|
|
}
|
|
|
|
install_beszel() {
|
|
require_docker || return 1
|
|
log_info "Installing Beszel..."
|
|
|
|
local DIR="$DOCKER_DIR/beszel"
|
|
local WEB_PORT="8090"
|
|
|
|
if [ "$DRY_RUN" = true ]; then
|
|
echo "[DRY-RUN] Would create $DIR (beszel_data/, beszel_socket/, beszel_agent_data/)"
|
|
echo "[DRY-RUN] Would deploy henrygd/beszel (hub) and henrygd/beszel-agent (agent, network_mode: host)"
|
|
echo "[DRY-RUN] Port 8090 published for the hub (auto-scanned for a free host port)"
|
|
echo "[DRY-RUN] Agent connects to the hub over a shared unix socket, not a TCP port"
|
|
echo "[DRY-RUN] Would mount host systemd/dbus sockets + sensor paths read-only (Services/Temp columns)"
|
|
echo "[DRY-RUN] Would set security_opt: apparmor:unconfined on the agent (needed for dbus to work at all on an AppArmor host)"
|
|
echo "[DRY-RUN] Would pause for you to log into the hub and provide its key + universal token to finish the agent"
|
|
return 0
|
|
fi
|
|
|
|
# ── Existing install? Offer update/fresh/cancel ─────────────────────────
|
|
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
|
|
local MODE=""
|
|
prompt_reinstall_mode MODE
|
|
case "$MODE" in
|
|
update)
|
|
log_info "Refreshing images only — existing config, port, and Caddy setup are left as-is."
|
|
_beszel_patch_agent_compose "$DIR/docker-compose.yml"
|
|
( cd "$DIR" && docker compose pull && docker compose up -d ) \
|
|
&& log_success "Beszel images refreshed" \
|
|
|| log_warning "Refresh failed — check: docker compose -f $DIR/docker-compose.yml logs"
|
|
if ! grep -q '^AGENT_KEY=' "$DIR/.env" 2>/dev/null; then
|
|
local FINISH_AGENT="" _WP
|
|
_WP="$(grep '^BESZEL_WEB_PORT=' "$DIR/.env" 2>/dev/null | cut -d= -f2-)"
|
|
prompt_yn " The agent was never connected — set it up now? (y/n):" "y" FINISH_AGENT
|
|
[[ "$FINISH_AGENT" =~ ^[Yy]$ ]] && _beszel_configure_agent "$DIR" "http://localhost:${_WP} (or its Caddy domain, once configured)"
|
|
fi
|
|
_beszel_offer_authelia_oidc "$DIR"
|
|
_beszel_offer_disable_password_auth "$DIR"
|
|
return 0
|
|
;;
|
|
cancel)
|
|
log_info "Leaving the existing install as-is."
|
|
return 0
|
|
;;
|
|
fresh) ;; # fall through to the full install flow below
|
|
esac
|
|
fi
|
|
|
|
# A second hub on this same box wouldn't mean anything — additional
|
|
# boxes get their own agent pointed at this one hub, not a second hub
|
|
# instance. Skip the multi-instance pattern entirely (same reasoning
|
|
# CLAUDE.md gives for caddy/crowdsec).
|
|
find_free_port WEB_PORT "$WEB_PORT"
|
|
|
|
mkdir -p "$DIR/beszel_data" "$DIR/beszel_socket" "$DIR/beszel_agent_data"
|
|
ensure_docker_dir_ownership "$DIR"
|
|
cd "$DIR" || return 1
|
|
|
|
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh).
|
|
local _CADDY_MODE="${CADDY_MODE:-none}"
|
|
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
|
|
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
|
|
|
|
local _CADDY_NET_BLOCK=""
|
|
local _CADDY_NET_SECTION=""
|
|
if [ "$_CADDY_MODE" = "local" ]; then
|
|
_CADDY_NET_BLOCK=" networks:
|
|
- caddy_net
|
|
"
|
|
_CADDY_NET_SECTION="
|
|
networks:
|
|
caddy_net:
|
|
external: true
|
|
name: ${SITE_CADDY_NET:-caddy_net}
|
|
"
|
|
fi
|
|
|
|
# Agent uses network_mode: host (for accurate network-interface stats,
|
|
# matching beszel's own reference same-system compose) — that's mutually
|
|
# exclusive with a `networks:` block in Compose, so it never joins
|
|
# caddy_net regardless of Caddy mode; it doesn't need to, since it talks
|
|
# to the hub over the shared beszel_socket volume, not a published port.
|
|
#
|
|
# The systemd/dbus/sensor mounts below aren't optional extras — without
|
|
# them the agent silently reports empty Services and Temp columns for
|
|
# this box, with no error anywhere pointing at why. A container is
|
|
# isolated from the host's systemd/dbus and most of /sys by default;
|
|
# docker.sock alone (already needed for the Docker-container-stats
|
|
# feature) doesn't grant any of that. Confirmed live: a native
|
|
# (non-Docker) agent install gets both for free just by virtue of
|
|
# running as a normal host process, which is what first surfaced this
|
|
# gap — a Docker-deployed agent sitting right next to it showed nothing
|
|
# in either column until these were added.
|
|
#
|
|
# The mounts alone aren't enough on an AppArmor-enabled host (Ubuntu/
|
|
# Debian by default): the dbus connection attempt fails with "An
|
|
# AppArmor policy prevents this sender from sending this message to
|
|
# this recipient" (visible only at LOG_LEVEL=debug — silent otherwise),
|
|
# since the container has no AppArmor label the host's dbus-daemon
|
|
# profile recognizes. security_opt: apparmor:unconfined below is
|
|
# Beszel's own documented fix (beszel.dev/guide/systemd#apparmor-error)
|
|
# — confirmed live, this exact error on a real box.
|
|
backup_if_exists docker-compose.yml
|
|
cat > docker-compose.yml << BESZEL_COMPOSE
|
|
name: beszel
|
|
|
|
services:
|
|
beszel:
|
|
image: henrygd/beszel:latest
|
|
container_name: beszel
|
|
hostname: beszel
|
|
restart: unless-stopped
|
|
env_file: .env
|
|
ports:
|
|
- "${WEB_PORT}:8090"
|
|
volumes:
|
|
- ./beszel_data:/beszel_data
|
|
- ./beszel_socket:/beszel_socket
|
|
${_CADDY_NET_BLOCK}
|
|
beszel-agent:
|
|
image: henrygd/beszel-agent:latest
|
|
container_name: beszel-agent
|
|
restart: unless-stopped
|
|
network_mode: host
|
|
security_opt:
|
|
- apparmor:unconfined
|
|
env_file: .env
|
|
environment:
|
|
- LISTEN=/beszel_socket/beszel.sock
|
|
- HUB_URL=http://localhost:${WEB_PORT}
|
|
- TOKEN=\${AGENT_TOKEN:-}
|
|
- KEY=\${AGENT_KEY:-}
|
|
volumes:
|
|
- ./beszel_agent_data:/var/lib/beszel-agent
|
|
- ./beszel_socket:/beszel_socket
|
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
- /var/run/systemd/private:/var/run/systemd/private:ro
|
|
- /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro
|
|
- /sys/class/hwmon:/sys/class/hwmon:ro
|
|
- /sys/class/thermal:/sys/class/thermal:ro
|
|
${_CADDY_NET_SECTION}
|
|
BESZEL_COMPOSE
|
|
|
|
# APP_URL only matters for the hub's own generated links and origin
|
|
# checks — http://localhost is fine until you front this with Caddy,
|
|
# at which point update it to the real https:// domain and restart (see
|
|
# README). Not threaded through automatically here because Caddy setup
|
|
# (below) happens after this file is written, same ordering every
|
|
# other service in this repo uses for its own Caddy prompt.
|
|
backup_if_exists .env
|
|
cat > .env << BESZEL_ENV
|
|
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
|
|
CADDY_NET=$SITE_CADDY_NET
|
|
BESZEL_WEB_PORT=$WEB_PORT
|
|
APP_URL=http://localhost:${WEB_PORT}
|
|
BESZEL_ENV
|
|
chmod 600 .env
|
|
|
|
log_info "Starting the hub (agent connects after you grab its key/token from the hub UI)..."
|
|
if docker compose up -d beszel; then
|
|
log_success "Hub started"
|
|
declare -F check_container_health >/dev/null 2>&1 && check_container_health beszel 8
|
|
else
|
|
log_warning "Hub failed to start — check: docker compose logs"
|
|
return 1
|
|
fi
|
|
|
|
configure_caddy_for_service "Beszel" "beszel:8090" "beszel"
|
|
if [ "${CADDY_SERVICE_CONFIGURED:-false}" != "true" ] || [ "${CADDY_SERVICE_MODE:-}" = "remote" ]; then
|
|
if command -v ufw &>/dev/null; then
|
|
ufw allow "${WEB_PORT}/tcp" comment "Beszel" >/dev/null 2>&1
|
|
declare -F ensure_ufw_enabled >/dev/null 2>&1 && ensure_ufw_enabled
|
|
fi
|
|
fi
|
|
|
|
_beszel_configure_agent "$DIR" "http://localhost:${WEB_PORT} (or its Caddy domain, once configured)"
|
|
|
|
_beszel_offer_authelia_oidc "$DIR"
|
|
|
|
write_readme "$DIR" << 'BESZEL_README'
|
|
# Beszel — lightweight server + Docker monitoring
|
|
|
|
Hub (web dashboard) + agent (reports host and container stats), both on
|
|
this box. The agent reads `/var/run/docker.sock` (mounted read-only) to
|
|
report every currently-running container automatically — nothing to
|
|
configure per-service; install or remove a container on this box and the
|
|
agent's next poll just reflects it.
|
|
|
|
Also mounts the host's systemd/dbus sockets and sensor paths (read-only),
|
|
plus `security_opt: apparmor:unconfined` (required on Ubuntu/Debian for the
|
|
dbus connection to work at all — otherwise AppArmor silently blocks it), so
|
|
the hub's **Services** (systemd units) and **Temp** (hardware sensors)
|
|
columns work for this box — without both, a Docker-deployed agent silently
|
|
shows both empty, no error anywhere pointing at why (visible only at
|
|
`LOG_LEVEL=debug`).
|
|
|
|
## First login
|
|
|
|
No default account — open the hub and register the first user, which
|
|
becomes admin:
|
|
|
|
- URL: printed at the end of the install (Caddy domain if configured,
|
|
otherwise `http://localhost:<port>`)
|
|
|
|
## Connecting the agent (if you skipped it during install)
|
|
|
|
The agent needs the hub's public key and a universal token, both only
|
|
available after logging in:
|
|
|
|
1. Settings → Tokens & Fingerprints → enable the universal token, copy it
|
|
2. Copy the public key shown on the same page
|
|
3. Re-run `sudo ./setup.sh beszel` — it'll detect the agent isn't
|
|
connected yet and prompt for both values
|
|
|
|
## Monitoring additional boxes
|
|
|
|
Add another agent from the hub's UI ("Add System") to generate its
|
|
connection details, then on the OTHER box (a homelab machine, not this
|
|
VPS) run `sudo ./setup.sh beszel-agent` — a separate, agent-only install
|
|
(no hub, no web UI) built for exactly this. It connects OUTBOUND to this
|
|
hub over HTTPS, so no VPN, port-forwarding, or FQDN is needed for that box
|
|
— only this hub needs to be reachable, which it already is. See
|
|
`services/beszel.sh`'s `install_beszel-agent()`.
|
|
|
|
## Fronting this with Caddy
|
|
|
|
If you configured Caddy for Beszel, also update `APP_URL` in `.env` to
|
|
the real `https://` domain (it defaults to `http://localhost:<port>`,
|
|
which is what the hub uses for its own generated links and origin
|
|
checks) and restart:
|
|
|
|
```bash
|
|
docker compose restart beszel
|
|
```
|
|
|
|
## Manage
|
|
|
|
```bash
|
|
docker compose up -d
|
|
docker compose logs -f
|
|
docker compose pull && docker compose up -d
|
|
docker compose down
|
|
```
|
|
BESZEL_README
|
|
|
|
echo ""
|
|
log_success "Beszel configured at $DIR"
|
|
}
|
|
|
|
# ── Agent-only install, for a box that ISN'T the hub ────────────────────────
|
|
# A second register_service in this same file — precedented by base.sh's
|
|
# base+glow — rather than a separate services/beszel-agent.sh, since this
|
|
# shares _beszel_extract_field/_beszel_configure_agent with install_beszel()
|
|
# above and would otherwise duplicate that paste/parse logic across two
|
|
# files. Run this on a homelab/remote box (a plain VPN peer isn't needed —
|
|
# see the README this writes for why) pointed at a hub running on THIS repo's
|
|
# main box (install_beszel() above, or any other Beszel hub).
|
|
register_service beszel-agent utilities "Beszel monitoring AGENT only, for a remote/homelab box reporting to a hub running elsewhere"
|
|
|
|
install_beszel-agent() {
|
|
require_docker || return 1
|
|
log_info "Installing the Beszel agent (reports to a hub running on another machine)..."
|
|
|
|
local DIR="$DOCKER_DIR/beszel-agent"
|
|
|
|
if [ "$DRY_RUN" = true ]; then
|
|
echo "[DRY-RUN] Would create $DIR"
|
|
echo "[DRY-RUN] Would deploy henrygd/beszel-agent only (no hub, no web UI on this box)"
|
|
echo "[DRY-RUN] network_mode: host, /var/run/docker.sock mounted read-only"
|
|
echo "[DRY-RUN] plus host systemd/dbus sockets + sensor paths read-only (Services/Temp columns)"
|
|
echo "[DRY-RUN] plus security_opt: apparmor:unconfined (needed for dbus to work at all on an AppArmor host)"
|
|
echo "[DRY-RUN] Would prompt for the hub's public URL, then its key + universal token"
|
|
echo "[DRY-RUN] (same paste flow as the hub-side installer)"
|
|
echo "[DRY-RUN] No inbound port opened — the agent connects OUTBOUND to the hub, so no"
|
|
echo "[DRY-RUN] VPN, port-forwarding, or FQDN is needed for THIS box"
|
|
return 0
|
|
fi
|
|
|
|
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
|
|
local MODE=""
|
|
prompt_reinstall_mode MODE
|
|
case "$MODE" in
|
|
update)
|
|
log_info "Refreshing the image only — existing hub URL/key/token are left as-is."
|
|
_beszel_patch_agent_compose "$DIR/docker-compose.yml"
|
|
( cd "$DIR" && docker compose pull && docker compose up -d ) \
|
|
&& log_success "Beszel agent image refreshed" \
|
|
|| log_warning "Refresh failed — check: docker compose -f $DIR/docker-compose.yml logs"
|
|
if ! grep -q '^AGENT_KEY=' "$DIR/.env" 2>/dev/null; then
|
|
local FINISH_AGENT="" _HUB_URL
|
|
_HUB_URL="$(grep '^HUB_URL=' "$DIR/.env" 2>/dev/null | cut -d= -f2-)"
|
|
prompt_yn " The agent was never connected — set it up now? (y/n):" "y" FINISH_AGENT
|
|
[[ "$FINISH_AGENT" =~ ^[Yy]$ ]] && _beszel_configure_agent "$DIR" "$_HUB_URL"
|
|
fi
|
|
return 0
|
|
;;
|
|
cancel)
|
|
log_info "Leaving the existing install as-is."
|
|
return 0
|
|
;;
|
|
fresh) ;; # fall through to the full install flow below
|
|
esac
|
|
fi
|
|
|
|
mkdir -p "$DIR/beszel_agent_data"
|
|
ensure_docker_dir_ownership "$DIR"
|
|
cd "$DIR" || return 1
|
|
|
|
echo ""
|
|
echo " This box's agent connects OUTBOUND to a Beszel hub running elsewhere"
|
|
echo " (e.g. your VPS) — no VPN, no router port-forwarding, and no FQDN needed"
|
|
echo " for THIS box. Only the hub itself needs to already be reachable."
|
|
echo ""
|
|
local HUB_URL=""
|
|
while [ -z "$HUB_URL" ]; do
|
|
prompt_text " Hub URL (e.g. https://beszel.yourdomain.com, or http://vps-ip:port):" "" HUB_URL
|
|
[ -z "$HUB_URL" ] && log_warning " A hub URL is required."
|
|
done
|
|
|
|
# No network_mode: host caveat here beyond what install_beszel() already
|
|
# documents — same reasoning (accurate host network-interface stats),
|
|
# and there's no caddy_net to conditionally join since this box never
|
|
# runs a web UI of its own. See that function's own comment for why the
|
|
# systemd/dbus/sensor mounts below matter (Services/Temp columns).
|
|
backup_if_exists docker-compose.yml
|
|
cat > docker-compose.yml << AGENT_COMPOSE
|
|
name: beszel-agent
|
|
|
|
services:
|
|
beszel-agent:
|
|
image: henrygd/beszel-agent:latest
|
|
container_name: beszel-agent
|
|
restart: unless-stopped
|
|
network_mode: host
|
|
security_opt:
|
|
- apparmor:unconfined
|
|
env_file: .env
|
|
environment:
|
|
- HUB_URL=\${HUB_URL}
|
|
- TOKEN=\${AGENT_TOKEN:-}
|
|
- KEY=\${AGENT_KEY:-}
|
|
volumes:
|
|
- ./beszel_agent_data:/var/lib/beszel-agent
|
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
- /var/run/systemd/private:/var/run/systemd/private:ro
|
|
- /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro
|
|
- /sys/class/hwmon:/sys/class/hwmon:ro
|
|
- /sys/class/thermal:/sys/class/thermal:ro
|
|
AGENT_COMPOSE
|
|
|
|
backup_if_exists .env
|
|
cat > .env << AGENT_ENV
|
|
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
|
|
HUB_URL=$HUB_URL
|
|
AGENT_ENV
|
|
chmod 600 .env
|
|
|
|
_beszel_configure_agent "$DIR" "$HUB_URL"
|
|
|
|
write_readme "$DIR" << AGENT_README_MD
|
|
# Beszel agent (remote/homelab box)
|
|
|
|
Reports this box's host resources and Docker container stats to a Beszel
|
|
HUB running elsewhere — no hub, no web UI, nothing web-facing on this box
|
|
at all.
|
|
|
|
Also mounts the host's systemd/dbus sockets and sensor paths (read-only),
|
|
plus \`security_opt: apparmor:unconfined\` (required on Ubuntu/Debian for the
|
|
dbus connection to work at all — otherwise AppArmor silently blocks it), so
|
|
the hub's **Services** (systemd units) and **Temp** (hardware sensors)
|
|
columns work for this box too — without both, a Docker-deployed agent
|
|
silently shows both empty, no error anywhere pointing at why (visible only
|
|
at \`LOG_LEVEL=debug\`).
|
|
|
|
## Connecting (if you skipped it during install)
|
|
|
|
Same flow as the hub side, just on a different machine: log into the hub at
|
|
\`$HUB_URL\`, go to Settings → Tokens & Fingerprints, enable the universal
|
|
token, and paste whatever it gives you (the "copy for docker compose"
|
|
shortcut works fine) by re-running:
|
|
|
|
\`\`\`bash
|
|
sudo ./setup.sh beszel-agent
|
|
\`\`\`
|
|
|
|
## Why no VPN or port-forwarding
|
|
|
|
This agent connects OUTBOUND to the hub over HTTPS/WebSocket using the key
|
|
and token above — nothing on this box ever needs to be reached FROM the
|
|
hub, so there's no inbound port to open, no router port-forward, no VPN
|
|
tunnel, and no FQDN needed for this box itself. Only the hub needs to
|
|
already be reachable at the URL you gave it.
|
|
|
|
## Manage
|
|
|
|
\`\`\`bash
|
|
docker compose up -d
|
|
docker compose logs -f
|
|
docker compose pull && docker compose up -d
|
|
docker compose down
|
|
\`\`\`
|
|
AGENT_README_MD
|
|
|
|
echo ""
|
|
log_success "Beszel agent configured at $DIR — connecting to $HUB_URL"
|
|
}
|
|
|
|
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_beszel
|