#!/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
