891 lines
42 KiB
Bash
891 lines
42 KiB
Bash
#!/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 ""
|