diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..2144953 --- /dev/null +++ b/install.sh @@ -0,0 +1,868 @@ +#!/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) +[[ $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 +install -d -m 0750 -o "$SERVICE_USER" -g "$CERTEXPORT_USER" "$CERTEXPORT_DATA" + +# 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 ""