#!/usr/bin/env bash # ============================================================ # HAPX-UI – All-in-One Installer # https://gitea.itm-technologies.de/ITMGmbH/HAPX-UI # # Usage (tokenlos — zieht das signierte Release aus dem öffentlichen Repo): # curl -fsSL https://gitea.itm-technologies.de/nepomuk.gail/hapx-ui-releases/raw/branch/main/install.sh | sudo bash # or: # sudo bash install.sh [options] # ============================================================ set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────────────── # Two DISTINCT sources: # REPO_URL – the PRIVATE source repo, only used to clone+build from # source (needs --token). Most installs never touch it. # UPDATE_SOURCE_URL – the PUBLIC, anonymously-readable repo holding the SIGNED # prebuilt release assets. This is what auto-update pulls # from — tokenless. The ITMGmbH org is "limited" visibility # (anonymous blocked), so releases live under a personal # public account instead. REPO_URL="https://gitea.itm-technologies.de/ITMGmbH/HAPX-UI.git" UPDATE_SOURCE_URL="https://gitea.itm-technologies.de/nepomuk.gail/hapx-ui-releases" GITEA_TOKEN="${HAPX_TOKEN:-}" BRANCH="main" INSTALL_DIR="/opt/hapx-ui" BINARY_DEST="/usr/local/bin/hapx-ui" DATA_DIR="/var/lib/hapx-ui" HAPROXY_DIR="/etc/haproxy" CERT_DIR="/etc/haproxy/certs" SERVICE_FILE="/etc/systemd/system/hapx-ui.service" SUDOERS_FILE="/etc/sudoers.d/hapx-ui" SERVICE_USER="hapx-ui" SERVICE_GROUP="hapx-ui" # Building from source needs Go >= 1.25 (see go.mod). Resolved to the latest # stable release at install time; this is only the fallback if the lookup fails. GO_VERSION="1.25.4" GO_ARCH="linux/amd64" # HTTPS by default, HTTP fallback on 8080 redirects to HTTPS. HTTPS_PORT=8443 HTTP_REDIRECT_PORT=8080 HTTP_ONLY=0 TLS_CERT="${CERT_DIR}/hapx-ui.crt" TLS_KEY="${CERT_DIR}/hapx-ui.key" ADMIN_USER="admin" ADMIN_PASS="" SKIP_HAPROXY=0 UPDATE=0 # Default: das signierte, vorgebaute Release aus dem ÖFFENTLICHEN Repo — kein # Token, kein Go, kein Clone. --from-source ist der Entwickler-Weg von früher. FROM_SOURCE=0 RELEASE_TAG="" # ── Colors ──────────────────────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m' info() { echo -e "${BLUE}[INFO]${NC} $*"; } success() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } step() { echo -e "\n${BOLD}▶ $*${NC}"; } usage() { cat < (oder HAPX_TOKEN=…)" fi # git_c runs git with the access token attached as an HTTP header, so the secret # never lands in the remote URL, in .git/config, or in any logged git output. git_c() { if [[ -n "$GITEA_TOKEN" ]]; then git -c "http.extraHeader=Authorization: token ${GITEA_TOKEN}" "$@" else git "$@" fi } # ── Banner ──────────────────────────────────────────────────────────────────── echo -e "${BOLD}" echo "╔══════════════════════════════════════════════════════╗" echo "║ HAPX-UI – HAProxy Manager ║" echo "║ Go Edition • AIO Installer ║" echo "╚══════════════════════════════════════════════════════╝" echo -e "${NC}" [[ $UPDATE -eq 1 ]] && info "Mode: UPDATE" || info "Mode: FRESH INSTALL" if [[ $HTTP_ONLY -eq 1 ]]; then info "Listen: http://*:${HTTPS_PORT}" else info "Listen: https://*:${HTTPS_PORT}" [[ -n "$HTTP_REDIRECT_PORT" ]] && info "HTTP→HTTPS: *:${HTTP_REDIRECT_PORT}" info "TLS cert: ${TLS_CERT}" fi info "Install dir: $INSTALL_DIR" info "Data dir: $DATA_DIR" if [[ $FROM_SOURCE -eq 1 ]]; then info "Quelle: Quelltext-Build ($BRANCH)" else info "Quelle: signiertes Release aus ${UPDATE_REPO_URL} (${RELEASE_TAG:-neuestes})" fi echo "" need() { command -v "$1" &>/dev/null; } # ── Step 1: System packages ─────────────────────────────────────────────────── step "Installing system dependencies" apt-get update -qq PACKAGES=(git curl openssl ca-certificates acl) [[ $SKIP_HAPROXY -eq 0 ]] && PACKAGES+=(haproxy) apt-get install -y -qq "${PACKAGES[@]}" success "System packages installed" # ── Step 2: Go toolchain ────────────────────────────────────────────────────── if [[ $FROM_SOURCE -eq 1 ]]; then step "Checking Go toolchain" GO_BIN="/usr/local/go/bin/go" INSTALL_GO=0 if [[ -x "$GO_BIN" ]]; then CURRENT_GO=$("$GO_BIN" version | grep -oP 'go\K[0-9]+\.[0-9]+\.[0-9]+' || echo "0") REQUIRED="1.25.0" # see go.mod — the project does not build with older Go if [[ "$(printf '%s\n' "$REQUIRED" "$CURRENT_GO" | sort -V | head -1)" == "$REQUIRED" ]]; then success "Go $CURRENT_GO already installed" else warn "Go $CURRENT_GO is too old (need >= $REQUIRED), upgrading..." INSTALL_GO=1 fi else INSTALL_GO=1 fi if [[ $INSTALL_GO -eq 1 ]]; then GOARCH="${GO_ARCH#*/}" GOOS="${GO_ARCH%%/*}" # Resolve the latest stable Go (>= 1.25) so a hard-coded patch never goes # stale; fall back to the pinned GO_VERSION if the lookup is unavailable. LATEST_GO=$(curl -fsSL "https://go.dev/VERSION?m=text" 2>/dev/null | head -1 | sed 's/^go//') [[ -n "$LATEST_GO" ]] && GO_VERSION="$LATEST_GO" TARBALL="go${GO_VERSION}.${GOOS}-${GOARCH}.tar.gz" info "Downloading Go $GO_VERSION..." curl -fsSL "https://go.dev/dl/${TARBALL}" -o "/tmp/${TARBALL}" rm -rf /usr/local/go tar -C /usr/local -xzf "/tmp/${TARBALL}" rm "/tmp/${TARBALL}" success "Go $GO_VERSION installed to /usr/local/go" fi export PATH="$PATH:/usr/local/go/bin" # ── Step 3: Clone / update repo ─────────────────────────────────────────────── step "Fetching HAPX-UI source ($BRANCH)" if [[ -d "$INSTALL_DIR/.git" ]]; then info "Updating existing checkout..." git_c -C "$INSTALL_DIR" remote set-url origin "$REPO_URL" git_c -C "$INSTALL_DIR" fetch --depth=1 origin "$BRANCH" git_c -C "$INSTALL_DIR" reset --hard "origin/$BRANCH" success "Repo updated" else info "Cloning $REPO_URL..." git_c clone --depth=1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR" success "Repo cloned to $INSTALL_DIR" fi # ── Step 4: Build ───────────────────────────────────────────────────────────── step "Building binary" cd "$INSTALL_DIR" VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev") COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") go build \ -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \ -o bin/hapx-ui ./cmd/hapxui/ install -m 755 bin/hapx-ui "$BINARY_DEST" success "Binary installed: $BINARY_DEST ($(du -sh "$BINARY_DEST" | cut -f1))" else # ── Step 2 (prebuilt): signiertes Release holen — tokenlos ─────────────────── # Derselbe Vertrag wie beim Auto-Update: erst die ed25519-Signatur der # SHA256SUMS prüfen, dann jede Datei gegen die Summen — Binary UND Helfer. # Der öffentliche Schlüssel ist derselbe wie in hapx-ui-update.sh; der private # ist das CI-Secret RELEASE_SIGNING_KEY. step "Fetching signed release (tokenless)" RELEASE_PUBKEY='-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAnbrMEw7Akn0JF5f+x8UlUnphkS+0JzFSMNzvo9W7mPE= -----END PUBLIC KEY-----' case "$(uname -m)" in x86_64|amd64) REL_ARCH="amd64" ;; aarch64|arm64) REL_ARCH="arm64" ;; *) error "Keine vorgebaute Binary für $(uname -m) — bitte --from-source nutzen." ;; esac # scheme://host und owner/repo aus der Release-URL ableiten REL_HOST="${UPDATE_REPO_URL%/*/*}" REL_OWNERREPO="${UPDATE_REPO_URL#"${REL_HOST}"/}" REL_API="${REL_HOST}/api/v1/repos/${REL_OWNERREPO}" if [[ -z "$RELEASE_TAG" ]]; then RELEASE_TAG=$(curl -fsSL --retry 2 -m 30 "${REL_API}/releases?limit=1" \ | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['tag_name'] if d else '')" 2>/dev/null || true) [[ -n "$RELEASE_TAG" ]] || error "Kein Release unter ${UPDATE_REPO_URL} gefunden — Server erreichbar?" fi REL_DL="${UPDATE_REPO_URL}/releases/download/${RELEASE_TAG}" info "Release: ${RELEASE_TAG} (${REL_ARCH})" mkdir -p "$INSTALL_DIR/scripts" "$INSTALL_DIR/deploy" "$INSTALL_DIR/bin" REL_TMP=$(mktemp -d /tmp/hapx-install.XXXXXX) trap 'rm -rf "$REL_TMP"' EXIT curl -fsSL --retry 3 -m 60 -o "$REL_TMP/SHA256SUMS" "$REL_DL/SHA256SUMS" || error "SHA256SUMS nicht ladbar: $REL_DL" curl -fsSL --retry 3 -m 60 -o "$REL_TMP/SHA256SUMS.sig" "$REL_DL/SHA256SUMS.sig" || error "SHA256SUMS.sig nicht ladbar — unsignierte Releases werden nicht installiert." printf '%s\n' "$RELEASE_PUBKEY" > "$REL_TMP/release.pub" SIGOUT=$(openssl pkeyutl -verify -pubin -inkey "$REL_TMP/release.pub" -rawin \ -in "$REL_TMP/SHA256SUMS" -sigfile "$REL_TMP/SHA256SUMS.sig" 2>&1) || true case "$SIGOUT" in *"Signature Verified"*) success "ed25519-Signatur der SHA256SUMS geprüft" ;; *rawin*|*"nknown option"*|*"nrecognized"*) error "openssl zu alt für ed25519 (-rawin) — ohne Signaturprüfung wird nichts installiert. Debian 12+/Ubuntu 22.04+ nötig." ;; *) error "SIGNATUR UNGÜLTIG — Abbruch. (${SIGOUT})" ;; esac # fetch_asset [pflicht] # Lädt ein Asset und prüft es gegen die signierten Summen. Eine Datei, die in # den Summen fehlt, wird NICHT installiert — die Signatur wäre sonst Deko. # Optional fehlen darf ein Asset nur, wenn das dritte Argument leer ist # (ältere Releases kennen die neueren Helfer noch nicht). fetch_asset() { local name="$1" dest="$2" required="${3:-}" if ! curl -fsSL --retry 3 -m 300 -o "$REL_TMP/$name" "$REL_DL/$name"; then if [[ -n "$required" ]]; then error "Asset $name fehlt im Release $RELEASE_TAG." fi warn "Asset $name fehlt im Release (älterer Stand) — übersprungen." return 1 fi local want have # || true: grep ohne Treffer würde unter pipefail die Zuweisung scheitern # lassen — die verständliche Fehlermeldung darunter käme nie zu Wort. want=$(grep -E " ${name}\$" "$REL_TMP/SHA256SUMS" | awk '{print $1}' | head -1 || true) [[ -n "$want" ]] || error "$name steht nicht in den signierten SHA256SUMS — Abbruch." have=$(sha256sum "$REL_TMP/$name" | awk '{print $1}') [[ "$want" == "$have" ]] || error "Prüfsumme von $name stimmt nicht (erwartet $want, ist $have)." install -m 0644 "$REL_TMP/$name" "$dest" return 0 } fetch_asset "hapx-ui-linux-${REL_ARCH}" "$INSTALL_DIR/bin/hapx-ui" required install -m 0755 "$INSTALL_DIR/bin/hapx-ui" "$BINARY_DEST" success "Binary installiert: $BINARY_DEST ($(du -sh "$BINARY_DEST" | cut -f1), ${RELEASE_TAG})" # Helfer und Units in die Ablage, aus der die folgenden Schritte sie erwarten — # dieselben Pfade wie ein Quelltext-Checkout, damit der Rest des Skripts für # beide Wege identisch bleibt. fetch_asset "hapx-ui-update.sh" "$INSTALL_DIR/scripts/hapx-ui-update.sh" || true fetch_asset "hapx-ui-crowdsec.sh" "$INSTALL_DIR/scripts/hapx-ui-crowdsec.sh" || true fetch_asset "hapx-ui-netcfg.sh" "$INSTALL_DIR/scripts/hapx-ui-netcfg.sh" || true for f in hapx-ui.sudoers hapx-ui-update.service hapx-ui-update.sudoers hapx-ui-activate.service \ hapx-ui-crowdsec-setup.service hapx-ui-crowdsec.sudoers hapx-ui-netcfg.sudoers \ hapx-ui-autoupdate.service hapx-ui-autoupdate.timer; do fetch_asset "$f" "$INSTALL_DIR/deploy/$f" || true done # Sich selbst ablegen, damit „install.sh --update" später aus derselben Quelle geht. fetch_asset "install.sh" "$INSTALL_DIR/install.sh" || true chmod 0755 "$INSTALL_DIR/install.sh" 2>/dev/null || true fi # ── Step 5: Service user, directories & permissions ────────────────────────── step "Creating service user and preparing directories" # Dedicated, unprivileged system user — no login, no shell. HAPX-UI no longer # runs as root; it gets exactly the access it needs via group membership. if ! id -u "$SERVICE_USER" &>/dev/null; then useradd --system --no-create-home --home-dir "$DATA_DIR" \ --shell /usr/sbin/nologin "$SERVICE_USER" success "Created system user '$SERVICE_USER'" else info "System user '$SERVICE_USER' already exists" fi # The haproxy group exists once the haproxy package is installed. Add the # service user to it so it can read/write the HAProxy config and certs. if getent group haproxy &>/dev/null; then usermod -aG haproxy "$SERVICE_USER" else warn "Group 'haproxy' not found (HAProxy not installed?) — config writes may fail" fi mkdir -p "$DATA_DIR" "$CERT_DIR" "$HAPROXY_DIR/backups" # Data dir: owned by the service user, private. chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "$DATA_DIR" chmod 750 "$DATA_DIR" # Root-only update staging dir. The self-updater produces the staged binary as # root (download+verify or gated source build) and installs it as root, so it # must NOT be tamperable by the unprivileged service user. It is a SIBLING of # DATA_DIR (parent /var/lib is root-owned) so the service user can neither write # into it nor rename/substitute it — unlike a subdir of the service-owned # DATA_DIR. See scripts/hapx-ui-update.sh (STAGING_DIR). STAGING_DIR="/var/lib/hapx-ui-staging" mkdir -p "$STAGING_DIR" chown root:root "$STAGING_DIR" chmod 0700 "$STAGING_DIR" # Root-owned rollback-snapshot dir for the network helper (same reasoning as # STAGING_DIR: a sibling of the service data dir, so the service user cannot # plant a snapshot that iface-rollback would install as root). The helper also # self-heals this on every mutating call. NETCFG_BK_DIR="/var/lib/hapx-ui-netcfg-backup" install -d -m 0700 -o root -g root "$NETCFG_BK_DIR" # Retire the old service-writable location if an earlier version created it. rm -rf "${DATA_DIR}/netcfg-backup" 2>/dev/null || true # Root-owned CrowdSec coordination dir (root:hapx-ui 0750). The root setup helper # writes progress/log/bouncer-key HERE, never in the service-writable DATA_DIR, # so the unprivileged service user cannot pre-plant a symlink to hijack root's # writes (CWE-59 link-following LPE). The service only reads these files. install -d -m 0750 -o root -g "$SERVICE_GROUP" "${DATA_DIR}/cs-state" 2>/dev/null || { mkdir -p "${DATA_DIR}/cs-state"; chgrp "$SERVICE_GROUP" "${DATA_DIR}/cs-state" 2>/dev/null || true; chmod 0750 "${DATA_DIR}/cs-state"; } # Remove any pre-hardening staged artifact from the service-writable data dir. rm -f "${DATA_DIR}/hapx-ui.staged" "${DATA_DIR}/hapx-ui.staged.meta" 2>/dev/null || true # Seed the in-app updater's config: the repo URL and the access token. Both are # read by the service (web UI) AND by the root update helper, so they must be # owned by the service user (0600 for the secret). Writing them here means the # "Jetzt aktualisieren" button works right after install, without the operator # having to paste the token into the web UI first. printf '%s\n' "$UPDATE_REPO_URL" > "${DATA_DIR}/update-server" chown "${SERVICE_USER}:${SERVICE_GROUP}" "${DATA_DIR}/update-server" chmod 0644 "${DATA_DIR}/update-server" if [[ -n "$GITEA_TOKEN" ]]; then printf '%s\n' "$GITEA_TOKEN" > "${DATA_DIR}/update-token" chown "${SERVICE_USER}:${SERVICE_GROUP}" "${DATA_DIR}/update-token" chmod 0600 "${DATA_DIR}/update-token" success "Update-Quelle hinterlegt (${DATA_DIR}/update-server + update-token, 0600)" else success "Update-Quelle hinterlegt (${DATA_DIR}/update-server) — tokenlos (signierte, öffentliche Releases)" fi # HAProxy dir + certs + config-backup archive: group 'haproxy', group-writable, # setgid so files the service user creates inherit the haproxy group (HAProxy # can then read them, and the service can write the rolling config archive). if getent group haproxy &>/dev/null; then chgrp haproxy "$HAPROXY_DIR" "$CERT_DIR" "$HAPROXY_DIR/backups" 2>/dev/null || true chmod 2775 "$HAPROXY_DIR" "$CERT_DIR" "$HAPROXY_DIR/backups" # Managed files HAPX-UI rewrites in place must be owned by the service user # (on a migration from the old root setup these are still root-owned). for f in haproxy.cfg haproxy.cfg.bak crt-list.txt crt-list.txt.bak client-ca.pem client-ca.crl; do p="$HAPROXY_DIR/$f" [ -e "$p" ] && chown "${SERVICE_USER}:haproxy" "$p" && chmod 0640 "$p" done # HAProxy certificate bundles: readable by the haproxy group (so the service # user can parse expiry) — but the UI's own TLS cert/key belong to the user. find "$CERT_DIR" -maxdepth 1 -type f -name '*.pem' -exec chgrp haproxy {} \; -exec chmod 0640 {} \; 2>/dev/null || true if [ -e "$CERT_DIR/hapx-ui.crt" ]; then chown "${SERVICE_USER}:${SERVICE_GROUP}" "$CERT_DIR/hapx-ui.crt" "$CERT_DIR/hapx-ui.key" 2>/dev/null || true chmod 0644 "$CERT_DIR/hapx-ui.crt" 2>/dev/null || true chmod 0600 "$CERT_DIR/hapx-ui.key" 2>/dev/null || true fi fi success "Directories ready: $DATA_DIR (private), $HAPROXY_DIR (group haproxy)" # ── Step 5b: sudoers — single locked-down reload command ───────────────────── step "Installing minimal sudoers rule (haproxy reload only)" if [[ -f "$INSTALL_DIR/deploy/hapx-ui.sudoers" ]]; then install -m 0440 -o root -g root "$INSTALL_DIR/deploy/hapx-ui.sudoers" "$SUDOERS_FILE" else cat > "$SUDOERS_FILE" <<'SUDOERS' Cmnd_Alias HAPX_RELOAD = /usr/bin/systemctl reload haproxy, /bin/systemctl reload haproxy hapx-ui ALL=(root) NOPASSWD: HAPX_RELOAD SUDOERS chmod 0440 "$SUDOERS_FILE" fi if visudo -cf "$SUDOERS_FILE" >/dev/null 2>&1; then success "sudoers rule installed and validated: $SUDOERS_FILE" else rm -f "$SUDOERS_FILE" error "sudoers rule failed validation — removed to avoid breaking sudo" fi # ── Step 5c: in-app (web UI) updater path ──────────────────────────────────── # Install the root helper, the decoupled oneshot unit the web UI triggers, and # the sudoers grant that lets the service trigger ONLY that unit. The oneshot # runs the binary swap + restart in its own cgroup so it survives the hapx-ui # restart it performs. Without these the "Jetzt aktualisieren" button can't work. step "Installing in-app updater (helper + oneshot unit + sudoers)" if [[ -f "$INSTALL_DIR/scripts/hapx-ui-update.sh" ]]; then install -m 0755 -o root -g root "$INSTALL_DIR/scripts/hapx-ui-update.sh" /usr/local/sbin/hapx-ui-update success "Update helper: /usr/local/sbin/hapx-ui-update" fi if [[ -f "$INSTALL_DIR/deploy/hapx-ui-update.service" ]]; then install -m 0644 -o root -g root "$INSTALL_DIR/deploy/hapx-ui-update.service" /etc/systemd/system/hapx-ui-update.service success "Update unit: hapx-ui-update.service (STEP 1: download/verify)" fi if [[ -f "$INSTALL_DIR/deploy/hapx-ui-activate.service" ]]; then install -m 0644 -o root -g root "$INSTALL_DIR/deploy/hapx-ui-activate.service" /etc/systemd/system/hapx-ui-activate.service success "Activate unit: hapx-ui-activate.service (STEP 2: activate/restart)" fi systemctl daemon-reload if [[ -f "$INSTALL_DIR/deploy/hapx-ui-update.sudoers" ]]; then install -m 0440 -o root -g root "$INSTALL_DIR/deploy/hapx-ui-update.sudoers" /etc/sudoers.d/hapx-ui-update if visudo -cf /etc/sudoers.d/hapx-ui-update >/dev/null 2>&1; then success "Update sudoers: /etc/sudoers.d/hapx-ui-update" else rm -f /etc/sudoers.d/hapx-ui-update warn "Update sudoers failed validation — removed (web update disabled)" fi fi # ── Step 5c2: CrowdSec setup wizard (helper + oneshot unit + sudoers) ───────── # Same decoupled pattern as the updater: the unprivileged service triggers ONLY # the fixed oneshot, which installs + configures CrowdSec as root on demand from # the Security → CrowdSec wizard. CrowdSec itself is NOT installed here — the # helper does it when the operator picks Local or Account in the UI. step "Installing CrowdSec setup helper (helper + oneshot unit + sudoers)" if [[ -f "$INSTALL_DIR/scripts/hapx-ui-crowdsec.sh" ]]; then install -m 0755 -o root -g root "$INSTALL_DIR/scripts/hapx-ui-crowdsec.sh" /usr/local/sbin/hapx-ui-crowdsec success "CrowdSec helper: /usr/local/sbin/hapx-ui-crowdsec" fi if [[ -f "$INSTALL_DIR/deploy/hapx-ui-crowdsec-setup.service" ]]; then install -m 0644 -o root -g root "$INSTALL_DIR/deploy/hapx-ui-crowdsec-setup.service" /etc/systemd/system/hapx-ui-crowdsec-setup.service systemctl daemon-reload success "CrowdSec unit: hapx-ui-crowdsec-setup.service" fi if [[ -f "$INSTALL_DIR/deploy/hapx-ui-crowdsec.sudoers" ]]; then install -m 0440 -o root -g root "$INSTALL_DIR/deploy/hapx-ui-crowdsec.sudoers" /etc/sudoers.d/hapx-ui-crowdsec if visudo -cf /etc/sudoers.d/hapx-ui-crowdsec >/dev/null 2>&1; then success "CrowdSec sudoers: /etc/sudoers.d/hapx-ui-crowdsec" else rm -f /etc/sudoers.d/hapx-ui-crowdsec warn "CrowdSec sudoers failed validation — removed (wizard disabled)" fi fi # Network helper (Settings → Netzwerk: IPv6 / DNS / flush). if [[ -f "$INSTALL_DIR/scripts/hapx-ui-netcfg.sh" ]]; then install -m 0755 -o root -g root "$INSTALL_DIR/scripts/hapx-ui-netcfg.sh" /usr/local/sbin/hapx-ui-netcfg success "Network helper: /usr/local/sbin/hapx-ui-netcfg" fi if [[ -f "$INSTALL_DIR/deploy/hapx-ui-netcfg.sudoers" ]]; then install -m 0440 -o root -g root "$INSTALL_DIR/deploy/hapx-ui-netcfg.sudoers" /etc/sudoers.d/hapx-ui-netcfg if visudo -cf /etc/sudoers.d/hapx-ui-netcfg >/dev/null 2>&1; then success "Network sudoers: /etc/sudoers.d/hapx-ui-netcfg" else rm -f /etc/sudoers.d/hapx-ui-netcfg warn "Network sudoers failed validation — removed (Netzwerk-Seite disabled)" fi fi # ── Step 5d: certexport system user (SSH cert-export feature) ──────────────── # Idempotent — runs on both fresh install and --update. Creates the unprivileged # certexport system user, the directory layout, the sshd Match block, and the # systemd drop-in that allows the hapx-ui service to write authorized_keys. step "Setting up certexport system user (SSH cert-distribution)" CERTEXPORT_USER="certexport" CERTEXPORT_HOME="/home/certexport" CERTEXPORT_DATA="${DATA_DIR}/certexport" CERTEXPORT_LOG="/var/log/hapx-ui" CERTEXPORT_SSHD="/etc/ssh/sshd_config.d/60-certexport.conf" CERTEXPORT_DROPIN="/etc/systemd/system/hapx-ui.service.d/20-certexport.conf" if ! id -u "$CERTEXPORT_USER" &>/dev/null; then useradd --system --create-home --home-dir "$CERTEXPORT_HOME" \ --shell /usr/sbin/nologin "$CERTEXPORT_USER" success "Created system user '$CERTEXPORT_USER'" else info "System user '$CERTEXPORT_USER' already exists" fi # certexport reads cert PEM files from /etc/haproxy/certs (group haproxy, 0640) if getent group haproxy &>/dev/null; then usermod -aG haproxy "$CERTEXPORT_USER" 2>/dev/null || true fi # grants.json lives here: SERVICE_USER writes it, certexport group can read it. # setgid (2750): Dateien im Verzeichnis erben die Gruppe certexport, damit der # als certexport laufende SSH-Forced-Command sie lesen kann. install -d -m 2750 -o "$SERVICE_USER" -g "$CERTEXPORT_USER" "$CERTEXPORT_DATA" # certexport muss DATA_DIR nur durchqueren (x), nicht lesen — execute-only-ACL. if command -v setfacl >/dev/null 2>&1; then setfacl -m u:${CERTEXPORT_USER}:--x "$DATA_DIR" 2>/dev/null || echo " ! setfacl auf $DATA_DIR fehlgeschlagen — certexport-Traverse prüfen" else echo " ! setfacl fehlt (Paket 'acl'?) — certexport kann $DATA_DIR evtl. nicht durchqueren" fi # audit log dir: certexport user writes here (runs outside systemd as certexport) install -d -m 0750 -o "$CERTEXPORT_USER" -g "$CERTEXPORT_USER" "$CERTEXPORT_LOG" # sshd config for certexport. # # The keys are read through AuthorizedKeysCommand, not AuthorizedKeysFile. # StrictModes — which used to be switched OFF here — checks that the key file # and every directory above it belongs to root or to the logging-in user and is # not group-writable. authorized_keys lives under the service's own data # directory, owned by the service user, so it can never satisfy that. And # StrictModes is not valid inside a Match block, so turning it off disabled that # check for EVERY account on the machine, not just this one. It only had to be # off because of how the file is reached; reaching it another way removes the # need entirely. # # The helper is a fixed cat of one fixed path, owned by root and not writable by # anyone else — which is what sshd demands of the command itself. It runs as # root because the data directory (0750, owned by the service user) cannot be # traversed by the certexport account. CERTEXPORT_KEYCMD_DIR="/usr/lib/hapx-ui" CERTEXPORT_KEYCMD="${CERTEXPORT_KEYCMD_DIR}/certexport-authorized-keys" install -d -m 0755 -o root -g root "$CERTEXPORT_KEYCMD_DIR" cat > "$CERTEXPORT_KEYCMD" <<'KEYCMD' #!/bin/sh # Prints the authorised keys for the certexport account. Called by sshd on every # login attempt for that user; no arguments are used, so nothing an SSH client # sends reaches this script. KEYS=/var/lib/hapx-ui/certexport/authorized_keys [ -r "$KEYS" ] || exit 0 exec /bin/cat "$KEYS" KEYCMD chown root:root "$CERTEXPORT_KEYCMD" chmod 0755 "$CERTEXPORT_KEYCMD" mkdir -p "$(dirname "$CERTEXPORT_SSHD")" CERTEXPORT_SSHD_PREV="" if [ -f "$CERTEXPORT_SSHD" ]; then CERTEXPORT_SSHD_PREV="$(cat "$CERTEXPORT_SSHD")" fi cat > "$CERTEXPORT_SSHD" </dev/null; then systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true success "sshd config for '$CERTEXPORT_USER' installed and reloaded" else # Never leave a configuration sshd rejects behind: the daemon keeps running on # the old one, but the next restart — a reboot, a package upgrade — would fail # and take remote access with it. if [ -n "$CERTEXPORT_SSHD_PREV" ]; then printf '%s\n' "$CERTEXPORT_SSHD_PREV" > "$CERTEXPORT_SSHD" else rm -f "$CERTEXPORT_SSHD" fi warn "sshd config test failed — the previous state was restored, certexport over SSH is not set up" fi # authorized_keys lives in $CERTEXPORT_DATA which is already covered by the # service unit's ReadWritePaths — no extra drop-in needed. Remove any old one. if [ -f "$CERTEXPORT_DROPIN" ]; then rm -f "$CERTEXPORT_DROPIN" fi success "certexport setup complete" # ── Step 6: HAProxy config (only on fresh install) ─────────────────────────── if [[ $SKIP_HAPROXY -eq 0 ]]; then step "Configuring HAProxy" HAPX_CFG="/etc/haproxy/haproxy.cfg" if [[ ! -f "$HAPX_CFG" ]]; then cat > "$HAPX_CFG" <<'HAPCFG' global log /dev/log local0 stats socket /run/haproxy/admin.sock group haproxy mode 660 level admin expose-fd listeners stats timeout 2m user haproxy group haproxy daemon # Process-wide connection ceiling (nbthread auto-detected = CPU count). maxconn 8000 # Larger TLS session cache = fewer full handshakes under reconnect load. tune.ssl.cachesize 100000 ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384 ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384 # TLS session tickets left ON (HAProxy default): under TLS 1.3 they are the # only resumption mechanism, so disabling them forces a full, CPU-heavy # handshake on every connection. Toggle via Settings → Performance if a # stricter forward-secrecy posture is required. ssl-default-bind-options ssl-min-ver TLSv1.2 defaults log global mode http option httplog option dontlognull timeout connect 5s timeout client 30m timeout server 30m timeout tunnel 1h timeout http-request 10s timeout http-keep-alive 2m HAPCFG info "Default HAProxy config written" else info "Existing $HAPX_CFG preserved (managed block will be inserted on first save)" # Make sure the stats socket directive exists — HAPX-UI cannot work without it. if ! grep -q 'stats socket /run/haproxy/admin.sock' "$HAPX_CFG"; then warn "Stats socket not configured in haproxy.cfg — HAPX-UI needs it" warn "Add to the 'global' section:" warn " stats socket /run/haproxy/admin.sock group haproxy mode 660 level admin expose-fd listeners" fi fi systemctl enable haproxy 2>/dev/null || true systemctl start haproxy 2>/dev/null || systemctl restart haproxy success "HAProxy running" fi # ── Step 7: Admin password ──────────────────────────────────────────────────── step "Setting up admin credentials" PASS_GENERATED=0 if [[ -z "$ADMIN_PASS" ]]; then ADMIN_PASS=$(openssl rand -base64 16 | tr -d '=/+' | head -c 20) PASS_GENERATED=1 fi # ── Step 8: systemd service ─────────────────────────────────────────────────── step "Installing systemd service" # Build the ExecStart command line based on TLS mode EXEC_FLAGS=( "--addr :${HTTPS_PORT}" "--db ${DATA_DIR}/hapx.db" "--haproxy-config /etc/haproxy/haproxy.cfg" "--haproxy-socket /run/haproxy/admin.sock" "--cert-dir ${CERT_DIR}" "--source-dir ${INSTALL_DIR}" "--update-server ${UPDATE_REPO_URL}" "--stats-interval 5s" ) if [[ $HTTP_ONLY -eq 0 ]]; then EXEC_FLAGS+=("--tls-cert ${TLS_CERT}" "--tls-key ${TLS_KEY}") [[ -n "$HTTP_REDIRECT_PORT" ]] && EXEC_FLAGS+=("--http-redirect :${HTTP_REDIRECT_PORT}") fi # Write the service file with backslash-newline continuations EXEC_LINE="ExecStart=${BINARY_DEST}" for flag in "${EXEC_FLAGS[@]}"; do EXEC_LINE="${EXEC_LINE} \\ ${flag}" done cat > "$SERVICE_FILE" </dev/null 2>&1 || true success "Service installed: hapx-ui.service" # ── Auto-update timer (on by default) ──────────────────────────────────────── # Unattended, keyless, webhook-free self-update: check → verified backup → apply # → health-gate → auto-rollback (see scripts/hapx-ui-update.sh 'auto'). if [[ -f "$INSTALL_DIR/deploy/hapx-ui-autoupdate.service" ]]; then install -m 0644 -o root -g root "$INSTALL_DIR/deploy/hapx-ui-autoupdate.service" /etc/systemd/system/hapx-ui-autoupdate.service install -m 0644 -o root -g root "$INSTALL_DIR/deploy/hapx-ui-autoupdate.timer" /etc/systemd/system/hapx-ui-autoupdate.timer if [[ ! -f "${DATA_DIR}/autoupdate.json" ]]; then printf '{"enabled": true}\n' > "${DATA_DIR}/autoupdate.json" chown "${SERVICE_USER}:${SERVICE_GROUP}" "${DATA_DIR}/autoupdate.json" chmod 0644 "${DATA_DIR}/autoupdate.json" fi systemctl daemon-reload systemctl enable --now hapx-ui-autoupdate.timer >/dev/null 2>&1 || true success "Auto-Update aktiviert (hapx-ui-autoupdate.timer — alle ~15 min, mit Backup + Rollback)" fi # ── Step 9: Bootstrap DB (only on fresh install) ───────────────────────────── if [[ ! -f "${DATA_DIR}/hapx.db" ]]; then step "Initializing database" # Run the bootstrap as the unprivileged service user so the DB file is owned # correctly from the start (not root). # Pass the initial password via the environment (read by main.go as # HAPX_ADMIN_PASS), NOT as --admin-pass on the argv: argv is world-readable via # /proc//cmdline, while the environment (/proc//environ) is 0400. export HAPX_ADMIN_PASS="$ADMIN_PASS" runuser -w HAPX_ADMIN_PASS -u "$SERVICE_USER" -- "$BINARY_DEST" \ --db "${DATA_DIR}/hapx.db" \ --admin-user "$ADMIN_USER" \ --addr 127.0.0.1:0 & BGPID=$! unset HAPX_ADMIN_PASS # child already inherited it; don't leave it in the installer env sleep 1 kill $BGPID 2>/dev/null || true wait $BGPID 2>/dev/null || true # Belt-and-suspenders: make sure everything under the data dir is owned by the # service user regardless of how it was created. chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "$DATA_DIR" success "Database initialized with admin user '$ADMIN_USER'" elif [[ $UPDATE -eq 0 ]]; then info "Existing database found — skipping admin bootstrap" PASS_GENERATED=0 fi # ── Step 10: (Re)start service ──────────────────────────────────────────────── step "Starting HAPX-UI" systemctl restart hapx-ui sleep 2 if systemctl is-active --quiet hapx-ui; then success "hapx-ui.service is running" else error "Service failed to start. Check: journalctl -u hapx-ui -n 50" fi # ── Done ────────────────────────────────────────────────────────────────────── SERVER_IP=$(hostname -I 2>/dev/null | awk '{print $1}') [[ -z "$SERVER_IP" ]] && SERVER_IP="" SCHEME="https" [[ $HTTP_ONLY -eq 1 ]] && SCHEME="http" echo "" echo -e "${GREEN}${BOLD}╔══════════════════════════════════════════════════════╗" echo -e "║ HAPX-UI installed successfully! ║" echo -e "╚══════════════════════════════════════════════════════╝${NC}" echo "" echo -e " ${BOLD}URL:${NC} ${SCHEME}://${SERVER_IP}:${HTTPS_PORT}" [[ $HTTP_ONLY -eq 0 && -n "$HTTP_REDIRECT_PORT" ]] && \ echo -e " ${BOLD}HTTP:${NC} http://${SERVER_IP}:${HTTP_REDIRECT_PORT} (redirects to HTTPS)" echo -e " ${BOLD}Username:${NC} ${ADMIN_USER}" if [[ $PASS_GENERATED -eq 1 ]]; then echo -e " ${BOLD}Password:${NC} ${YELLOW}${ADMIN_PASS}${NC} ← change this after first login!" else echo -e " ${BOLD}Password:${NC} (existing — unchanged)" fi [[ $HTTP_ONLY -eq 0 ]] && \ echo -e " ${YELLOW}Note:${NC} Self-signed TLS cert auto-generated — browsers show a warning on first visit." echo "" echo -e " ${BOLD}Erste Schritte:${NC} Beim ersten Login startet der Einrichtungs-Assistent —" echo -e " Passwort, 2FA, Let's Encrypt, Admin-Zugang, E-Mail-Versand, Fail2ban." echo -e " Danach: Personen & Gerätezertifikate (inkl. LDAP-Anbindung ans AD" echo -e " per PowerShell-Skript) unter ${BOLD}Personen → Einstellungen${NC}." echo "" echo -e " ${BOLD}Logs:${NC} journalctl -u hapx-ui -f" echo -e " ${BOLD}Update:${NC} sudo bash $INSTALL_DIR/install.sh --update" echo -e " ${BOLD}Source:${NC} $INSTALL_DIR" echo ""