install.sh build-2c469a1

This commit is contained in:
2026-09-02 11:34:41 +00:00
parent 1ea36a89da
commit 7bb627863d
+868
View File
@@ -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 <<EOF
Usage: sudo bash install.sh [options]
Installation modes:
--update Update existing installation in $INSTALL_DIR
--from-source Clone + build from the private SOURCE repo (needs --token).
Default is the signed prebuilt release — tokenless.
--release-tag TAG Install a specific release tag (default: newest)
Network:
--https-port PORT HTTPS listening port (default: $HTTPS_PORT)
--http-redirect PORT HTTP port that redirects to HTTPS (default: $HTTP_REDIRECT_PORT, "" to disable)
--http-only Disable TLS, serve plain HTTP on --https-port
--tls-cert PATH Use this TLS cert instead of auto-generated (default: $TLS_CERT)
--tls-key PATH Use this TLS key instead of auto-generated (default: $TLS_KEY)
Admin:
--admin-user NAME Initial admin username (default: admin)
--admin-pass PASS Initial admin password (auto-generated if omitted)
Update source:
--token TOKEN Gitea token with read:repository scope. ONLY needed
together with --from-source (private source repo);
the default prebuilt install is tokenless.
Alternatively set HAPX_TOKEN.
--repo-url URL Private SOURCE repo to build from (default: $REPO_URL).
--update-repo-url URL PUBLIC releases repo the box auto-updates from
(default: $UPDATE_SOURCE_URL). Tokenless, signed.
Other:
--dir PATH Source directory (default: $INSTALL_DIR)
--branch NAME Git branch / tag to install (default: $BRANCH)
--skip-haproxy Don't install / configure HAProxy
-h, --help Show this help
Example:
sudo bash install.sh # tokenlos, signiertes Release
sudo bash install.sh --from-source --token xxxxxxxx # Entwickler: aus dem Quelltext bauen
EOF
}
# ── Argument parsing ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--admin-user) ADMIN_USER="$2"; shift 2 ;;
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
--https-port) HTTPS_PORT="$2"; shift 2 ;;
--http-redirect) HTTP_REDIRECT_PORT="$2"; shift 2 ;;
--http-only) HTTP_ONLY=1; shift ;;
--tls-cert) TLS_CERT="$2"; shift 2 ;;
--tls-key) TLS_KEY="$2"; shift 2 ;;
--dir) INSTALL_DIR="$2"; shift 2 ;;
--branch) BRANCH="$2"; shift 2 ;;
--token) GITEA_TOKEN="$2"; shift 2 ;;
--repo-url) REPO_URL="$2"; shift 2 ;;
--update-repo-url) UPDATE_SOURCE_URL="$2"; shift 2 ;;
--skip-haproxy) SKIP_HAPROXY=1; shift ;;
--update) UPDATE=1; shift ;;
--from-source) FROM_SOURCE=1; shift ;;
--release-tag) RELEASE_TAG="$2"; shift 2 ;;
--port) HTTPS_PORT="$2"; shift 2 ;; # legacy alias
-h|--help) usage; exit 0 ;;
*) warn "Unknown argument: $1"; shift ;;
esac
done
# ── Root check ────────────────────────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
error "Please run as root: sudo bash install.sh"
fi
# ── Update source: token + derived URLs ───────────────────────────────────────
# UPDATE_REPO_URL is the token-free https/http URL (no .git) used both for the
# binary's --update-server flag and for the in-app updater's stored config. It
# points at the PUBLIC releases repo (not the private source), so auto-update is
# keyless. Override with --update-repo-url if you mirror releases elsewhere.
UPDATE_REPO_URL="${UPDATE_SOURCE_URL%.git}"
# Ein Token braucht nur der Quelltext-Weg: die Installation selbst zieht das
# signierte Release aus dem öffentlichen Repo, anonym.
if [[ $FROM_SOURCE -eq 1 && -z "$GITEA_TOKEN" ]]; then
warn "--from-source ohne Token: das Quell-Repo ist privat, der Clone wird vermutlich scheitern.
sudo bash install.sh --from-source --token <TOKEN> (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 <name> <ziel> [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" <<SSHDCONF
Match User certexport
AuthorizedKeysFile none
AuthorizedKeysCommand ${CERTEXPORT_KEYCMD}
AuthorizedKeysCommandUser root
PermitTTY no
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
PermitOpen none
SSHDCONF
chmod 0644 "$CERTEXPORT_SSHD"
if sshd -t 2>/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" <<EOF
[Unit]
Description=HAPX-UI – HAProxy Manager (Go)
Documentation=https://gitea.itm-technologies.de/ITMGmbH/HAPX-UI
After=network.target haproxy.service
Wants=haproxy.service
[Service]
# notify + watchdog: the binary sends sd_notify READY=1 and pings the watchdog,
# so systemd knows when startup really finished and restarts a hung process.
Type=notify
WatchdogSec=90
User=${SERVICE_USER}
Group=${SERVICE_GROUP}
SupplementaryGroups=haproxy
WorkingDirectory=$DATA_DIR
# NoNewPrivileges is OFF so the locked-down sudo reload rule works; the
# bounding set caps what that sudo may ever hold. SystemCallFilter and
# MemoryDenyWriteExecute are omitted because they break sudo/PAM; the rest of
# the sandbox is still strict.
# CAP_NET_RAW stays in the bounding set (not ambient): a traceroute binary with
# cap_net_raw=ep file caps only gets the cap if it's in the bounding set. It is
# deliberately NOT ambient — ping works via net.ipv4.ping_group_range without any
# capability, so there is no reason to force cap_net_raw onto every child process.
NoNewPrivileges=no
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_SETUID CAP_SETGID CAP_AUDIT_WRITE CAP_DAC_OVERRIDE
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
UMask=0027
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
ReadWritePaths=$DATA_DIR $HAPROXY_DIR /run/haproxy
${EXEC_LINE}
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=hapx-ui
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable hapx-ui >/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/<pid>/cmdline, while the environment (/proc/<pid>/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="<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 ""