Files
hapx-ui-releases/install.sh
T
2026-09-14 09:39:47 +00:00

891 lines
42 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 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 <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
# Speicher- und Protokollvorgaben. Die Voreinstellungen einer frischen
# Ubuntu/Debian-Installation sind fuer einen Reverse Proxy falsch gewaehlt:
# vm.swappiness 60 schiebt den Arbeitsspeicher von HAProxy auf die Platte,
# sobald der Dateizwischenspeicher waechst, und das Journal hat keinen Deckel,
# obwohl auf einem Proxy fast jeder Eintrag eine HAProxy-Anfragezeile ist, die
# ohnehin in /var/log/haproxy.log landet. Nur setzen, wenn noch nichts da ist.
if [[ -x /usr/local/sbin/hapx-ui-netcfg && ! -f /etc/sysctl.d/90-hapx-haproxy.conf ]]; then
if /usr/local/sbin/hapx-ui-netcfg tune apply >/dev/null 2>&1; then
success "Kernvorgaben: BBR + fq, groessere Puffer, vm.swappiness=10, Journal auf 500M gedeckelt"
else
warn "Speicher-/Protokollvorgaben konnten nicht gesetzt werden"
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" <<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 ""