Files
vpsmgr/scripts/00-check.sh
T
bluebluesodaandCommandCodeBot 4c93ae4be3 feat(haproxy): replace Traefik with a pinned HAProxy domain proxy
The panel now fronts user domains with HAProxy 3.4 LTS (official performance
packages) instead of Traefik: about 100 MB of RSS and 200 MB of disk saved for
a feature set that never used more than Host routing on :80 and SNI passthrough
on :443.

- hpx: publish the proxy configuration as immutable GENERATIONS. Every domain
  mutation renders the whole set from the DB, validates it with the pinned
  `haproxy -c`, and only then repoints the `current` symlink, so a rejected
  candidate leaves the running proxy untouched and a stale domain can never
  survive into a later generation. Routing is carried by two map files
  (Host -> backend, SNI -> backend): per-domain frontend rules cannot work from
  a second file because HAProxy rejects duplicate section names since 3.3 and
  attaches bare directives to the section opened last.
- mgr: republish and reload (SIGUSR2, i.e. `systemctl reload`) after every
  domain change, so a change takes effect without dropping established HTTP or
  TLS connections. Rollback semantics (DB first, then publish) are unchanged.
- cfg,db: `net.traefik` is renamed to `net.haproxy` with identical values. A
  pre-rename config.yaml key and the legacy VPSMGR_TRAEFIK env var are still
  read (new name wins), and schema migration v18 moves the settings mirror from
  `traefik` to `haproxy`, keeping a newer value when both rows exist.
- 30-haproxy.sh: install the pinned branch from the vendor repository and
  migrate a Traefik host in one run — install and validate HAProxy first, then
  remove Traefik's binary, unit, service account and dynamic rules (only its
  config file is kept, inert). No fallback keeps both proxies alive.
- upgrade-haproxy.sh: follow the pinned branch to its newest patch, with a
  configuration check and a graceful reload; `install.sh --update` runs it.
- tests: generation publish/rollback, map rendering, config-key and settings
  migrations, plus real-binary tests (skipped unless
  VPSMGR_TEST_HAPROXY_BIN is set) that run the generated configuration through
  `haproxy -c` and start it in a network namespace.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-16 12:59:13 +00:00

376 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
# 00-check.sh — environment sanity checks.
set -uo pipefail
log(){ echo "[00] $*"; }
die(){ echo "[00] error: $*" >&2; exit 1; }
if [[ $EUID -ne 0 ]]; then die "must run as root"; fi
# --- distro ---
if [[ ! -f /etc/os-release ]]; then die "cannot find /etc/os-release"; fi
. /etc/os-release
case "${ID:-}:${VERSION_ID:-}" in
debian:12|debian:13) ;;
ubuntu:22.04|ubuntu:24.04|ubuntu:26.04) ;;
*) die "this installer targets Debian 12/13 or Ubuntu 22.04/24.04/26.04 (got ${PRETTY_NAME:-unknown})" ;;
esac
# --- virtualization (require physical or KVM) ---
if command -v systemd-detect-virt >/dev/null 2>&1; then
VIRT=$(systemd-detect-virt)
case "$VIRT" in
openvz|lxc|lxc-libvirt|wsl|container|vm-other|podman|docker)
die "unsupported environment: '$VIRT'. Need a physical machine or KVM VM."
;;
esac
log "virtualization: ${VIRT:-none (physical)}"
fi
# --- architecture ---
ARCH=$(uname -m)
case "$ARCH" in
x86_64) GOARCH=amd64 ;;
aarch64) GOARCH=arm64 ;;
*) die "unsupported architecture: $ARCH (only amd64 / arm64 are supported)" ;;
esac
log "architecture: $ARCH ($GOARCH)"
# --- hardware (warn only) ---
CPUS=$(nproc 2>/dev/null || echo 0)
MEM_KB=$(awk '/MemTotal/{print $2}' /proc/meminfo)
MEM_GB=$(( MEM_KB / 1024 / 1024 ))
log "cpu: ${CPUS} cores, mem: ${MEM_GB} GiB"
[[ $CPUS -lt 2 ]] && log " warn: < 2 CPU may be slow"
[[ $MEM_GB -lt 2 ]] && log " warn: < 2 GiB RAM is tight"
# --- disk ---
FREE_KB=$(df -k --output=avail / | tail -1 | tr -d ' ')
log "free disk on /: $(( FREE_KB / 1024 )) MiB"
if [[ $FREE_KB -lt 5*1024*1024 ]]; then
log "warn: need at least 5 GiB free on /, only $(( FREE_KB / 1024 / 1024 )) GiB available"
read -r -p "force continue anyway? [y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || die "aborted: not enough free disk on /"
fi
# --- swap ---
# Swap is enforced up front in install.sh (before the IPv6 prompts), so it is
# not handled here. An existing swap is left alone; a missing swap aborts the
# install there.
# --- storage backend ---
# zfs (default), btrfs, or dir, chosen by install.sh / VPSMGR_STORAGE. zfs and
# btrfs both provide quotas/snapshots/clone-on-create; dir has none and is
# meant for throwaway test boxes only.
STORAGE="${VPSMGR_STORAGE:-zfs}"
case "$STORAGE" in
zfs|btrfs|dir) ;;
*) die "VPSMGR_STORAGE must be zfs, btrfs or dir (got '$STORAGE')" ;;
esac
log "storage backend: $STORAGE"
# --- packages ---
# Debian minimal images are notoriously bare (sometimes even curl is missing),
# so every tool the installer needs is ensured here, before any later step
# depends on it:
# - zfsutils-linux: Incus does NOT bundle the ZFS userspace tools; the
# storage pool is ZFS-only by default, so zpool/zfs must be present or
# pool creation fails (skipped in dir mode — no kernel module needed)
# - matching kernel headers (plus build-essential): Debian only. The
# linux-headers-amd64 meta package is for the normal amd64 kernel; cloud
# kernels need linux-headers-cloud-amd64 so DKMS can build for the kernel
# that is actually running. Ubuntu ships the ZFS module PREBUILT inside
# the kernel (linux-modules), so it needs none of the DKMS toolchain — no
# compile time at all.
# - ca-certificates: without it every curl HTTPS call (Zabbly key, the
# HAProxy repository, image pulls) fails with a certificate error
# - python3: used by 00-ip-ask.sh (prefix/subnet validation) and the
# check-ipv6-support.sh probe
# - tar/xz-utils: archive extraction; curl: downloads; gpg: Zabbly key and
# HAProxy repository key verification; nftables/zstd: firewall + Incus image
# compression
# - sudo: hard requirement — the panel runs unprivileged and escalates ONLY
# the sudoers whitelist commands via `sudo -n`. Without it every
# privileged operation (nft reload, haproxy/systemctl, IPv6 wiring, ndppd)
# fails at runtime. visudo ships with the sudo package.
KERNEL_REL=$(uname -r)
BASE_DEPS="sudo ca-certificates python3 tar xz-utils nftables zstd curl gpg"
if [[ "$STORAGE" == "zfs" ]]; then
BASE_DEPS="$BASE_DEPS zfsutils-linux"
if [[ "$ID" == "debian" ]]; then
KERNEL_HEADERS_PKG=linux-headers-amd64
case "$KERNEL_REL" in
*-cloud-*) KERNEL_HEADERS_PKG=linux-headers-cloud-amd64 ;;
esac
BASE_DEPS="$BASE_DEPS $KERNEL_HEADERS_PKG build-essential"
log "Debian detected — ZFS module will be DKMS-compiled (headers: $KERNEL_HEADERS_PKG)"
else
log "Ubuntu detected — ZFS module is prebuilt in the kernel, no compilation"
# Ubuntu minimal/cloud images often ship with only the 'main' component; the
# installer needs packages from universe (ndppd for IPv6 pass-through, among
# others). Enable it idempotently.
if ! grep -rhE "^[^#].*universe" /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null | grep -q .; then
log "enabling the Ubuntu universe repository (needed for ndppd etc.)"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq software-properties-common >/dev/null 2>&1 || true
add-apt-repository -y universe >/dev/null 2>&1 || die "could not enable the universe repository"
apt-get update -qq
fi
fi
elif [[ "$STORAGE" == "btrfs" ]]; then
# The btrfs driver needs the userspace tools (btrfs / mkfs.btrfs): Incus uses
# mkfs.btrfs when it creates a loop-backed pool and the btrfs tool to manage
# subvolumes/snapshots/quotas for the native-subvolume case alike.
BASE_DEPS="$BASE_DEPS btrfs-progs"
log "btrfs backend selected — installing btrfs-progs; no ZFS module/kernel headers needed"
else
log "dir backend — no ZFS/btrfs tools needed; WARNING: disk quotas, snapshots and clone-on-create are NOT available"
fi
for p in $BASE_DEPS; do
if ! dpkg -s "$p" >/dev/null 2>&1; then
log "installing $p"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$p" || log " warn: could not install $p"
fi
done
# A headers meta-package may track a newer kernel than the one currently
# running. Try the exact package once, then fail with an actionable message
# instead of letting DKMS silently build for another installed kernel.
if [[ "$STORAGE" == "zfs" && "$ID" == "debian" && ! -e "/lib/modules/$KERNEL_REL/build" ]]; then
EXACT_HEADERS_PKG="linux-headers-$KERNEL_REL"
if apt-cache show "$EXACT_HEADERS_PKG" >/dev/null 2>&1; then
log "matching headers are missing for $KERNEL_REL — installing $EXACT_HEADERS_PKG"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$EXACT_HEADERS_PKG" || true
fi
fi
if [[ "$STORAGE" == "zfs" && "$ID" == "debian" && ! -e "/lib/modules/$KERNEL_REL/build" ]]; then
die "matching kernel headers are missing for $KERNEL_REL — install linux-headers-$KERNEL_REL or reboot into a kernel with installed headers"
fi
# ZFS module present and loadable for the RUNNING kernel? On Debian a kernel
# upgrade can leave the DKMS module missing (headers were unavailable at
# postinst time); rebuild it for the running kernel rather than failing later
# at pool creation. On Ubuntu the module ships with the kernel, so this is a
# no-op. Skipped entirely in dir mode.
if [[ "$STORAGE" == "zfs" ]]; then
if ! modprobe zfs 2>/dev/null; then
log "ZFS module missing for $KERNEL_REL — rebuilding via dkms (takes a minute)..."
DKMS_VERSIONS=$(dkms status 2>/dev/null | awk -F'[,/ ]+' '/^zfs\//{print $2}' | sort -u)
[[ -n "$DKMS_VERSIONS" ]] || die "no ZFS DKMS version found — check 'dkms status'"
for v in $DKMS_VERSIONS; do
if ! DKMS_OUT=$(dkms build "zfs/$v" -k "$KERNEL_REL" 2>&1); then
printf '%s\n' "$DKMS_OUT" >&2
die "DKMS failed to build zfs/$v for $KERNEL_REL — check the matching kernel headers"
fi
if ! DKMS_OUT=$(dkms install "zfs/$v" -k "$KERNEL_REL" 2>&1); then
printf '%s\n' "$DKMS_OUT" >&2
die "DKMS failed to install zfs/$v for $KERNEL_REL"
fi
done
modprobe zfs || die "ZFS kernel module failed to load for $KERNEL_REL — check 'dkms status' and 'dmesg | grep zfs'"
fi
log "zfs: $(zpool version 2>/dev/null | head -1)"
# Make ZFS boot-safe: the module must be loaded and the pool imported on every
# boot, or Incus's storage pool is unavailable after a reboot. zfsutils-linux
# normally enables these in its postinst; ensure them explicitly so a kernel
# update + manual reboot "just works": module loads, pool imports, datasets
# mount, then Incus and the panel come up on top.
for unit in zfs-import-cache.service zfs-mount.service zfs-zed.service zfs.target; do
systemctl enable "$unit" >/dev/null 2>&1 || log " warn: could not enable $unit"
done
log "zfs boot units enabled (import + mount on every boot)"
fi
# --- Go toolchain (only needed for local build; installed lazily by 40-panel.sh) ---
if command -v go >/dev/null 2>&1; then
log "go: $(go version 2>/dev/null | awk '{print $3}')"
fi
# --- Incus ---
if ! command -v incus >/dev/null 2>&1; then
log "incus not installed yet (installed by 10-incus.sh)"
fi
# --- UFW conflict ---
# Incus manages its own `table inet incus` nftables rules (DHCP/DNS/forwarding)
# on incusbr0, but UFW's `table ip filter` runs at the same priority with a DROP
# policy, so it drops container DHCP/forwarding traffic before Incus's rules
# ever see it. Result: containers get no IPv4 (no DHCP, no DNS, no NAT) and
# the image build fails. See:
# https://linuxcontainers.org/incus/docs/main/howto/network_bridge_firewalld/
# vpsmgr manages its own firewall via `table inet vpsmgr` nftables, so the
# cleanest fix is to disable UFW during install (idempotent: skipped when it
# is already inactive).
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
# Already handled from a previous install? Never disable again silently —
# the user may have re-enabled it on purpose.
V4_NET="${VPSMGR_IPV4_SUBNET:-10.115.0.0/24}"
V4_MATCH="$(printf '%s' "${V4_NET%/*}" | sed 's/\./\\./g')"
if ufw status verbose 2>/dev/null | grep -qE "incusbr0|$V4_MATCH"; then
log "ufw active but already has Incus/incusbr0 allow rules — leaving it as-is"
else
log "ufw is ACTIVE with default-DROP policy — this breaks Incus container IPv4"
log " (Incus's own nftables rules are shadowed by ufw's DROP; known issue:"
log " https://linuxcontainers.org/incus/docs/main/howto/network_bridge_firewalld/)"
log " vpsmgr manages its firewall via nftables; disabling ufw."
ufw disable >/dev/null 2>&1 && log " ufw disabled" || die "failed to disable ufw"
# Keep it off across reboots. Snap/systemd enable it on boot otherwise.
if command -v systemctl >/dev/null 2>&1; then
systemctl disable ufw.service >/dev/null 2>&1 || true
systemctl stop ufw.service >/dev/null 2>&1 || true
fi
fi
else
log "ufw: not active (or not installed) — no conflict"
fi
# --- port occupancy ---
# Ports reserved for vpsmgr must be free on a fresh install. On adoption the
# panel and the domain proxy are already running and owned by vpsmgr — those
# listeners
# are excluded by process name. Checks TCP and UDP (the user port block is
# DNAT-ed for both).
#
# 80/443 are handled specially: if either is already taken, the install does
# NOT fail — it proceeds with HAProxy installed but DISABLED (net.haproxy
# false, not started/autostarted), so a host that already serves 80/443 is
# still usable; the operator can re-enable later via `vps config set
# net.haproxy true`. The user-port ranges (from net.user_ports, exported by
# 00-ip-ask.sh as VPSMGR_USER_PORTS; default 10000-29999) stay hard failures.
#
# SSH ports (30000-31999) are deliberately NOT checked here: they are allocated
# per-container at `vps add` time with a "probe the port, reassign on conflict"
# loop (mgr.allocSSHPort binds the candidate to detect a live listener), so an
# occupied port no longer blocks the install — the host simply skips it. This
# keeps the install usable on machines that happen to run something in that
# range.
#
# 00-check runs as a separate bash process, so its env cannot reach the rest of
# the installer. The decision therefore goes through a root-only marker file
# that install.sh turns into an env var for 30-haproxy.sh and the panel.
#
# The marker keeps its historical name (.install-traefik-off) even though the
# proxy is HAProxy now: an operator may run an older installer checkout that
# reads and writes exactly this path, and the new script honors it as well.
# Clear any stale marker from a previous run so a reinstall re-detects.
rm -f /etc/vpsmgr/.install-traefik-off 2>/dev/null || true
if [[ "${VPSMGR_DISABLE_V4FORWARD:-0}" == "1" ]]; then
log "v4 forwarding disabled by installer — skipping reserved port checks"
else
# Only the ports the configured user-port ranges actually reserve must be
# free — checking the whole 10000-29999 regardless of the ranges would block a
# small-footprint install that never uses the upper ports. VPSMGR_USER_PORTS is
# the comma-separated (already whole-hundred aligned) ranges exported by
# 00-ip-ask.sh; default 10000-29999. SSH ports (30000-31999) are NOT checked
# here — they are probed per-container at add time (see allocSSHPort).
USER_PORTS="${VPSMGR_USER_PORTS:-10000-29999}"
port_in_user_ranges(){
local p="$1"
local r lo hi
while IFS=',' read -ra RS; do
for r in "${RS[@]}"; do
r="${r// /}"
[[ -z "$r" ]] && continue
lo="${r%%-*}"; hi="${r##*-}"
if (( p >= lo && p <= hi )); then
return 0
fi
done
done <<<"$USER_PORTS"
return 1
}
port_hard_reserved(){
local p="$1"
port_in_user_ranges "$p"
return $?
}
log "checking reserved ports (80/443, $USER_PORTS)..."
CONFLICTS=""
PORT_80_443_TAKEN=0
PORT_80_443_PROC=""
while IFS= read -r line; do
[[ -z "$line" ]] && continue
ADDR=$(awk '{print $4}' <<<"$line")
[[ -z "$ADDR" ]] && continue
PORT="${ADDR##*:}"
[[ "$PORT" =~ ^[0-9]+$ ]] || continue
PROC=$(sed -n 's/.*users:(("\([^"]*\)".*/\1/p' <<<"$line")
case "${PROC##*/}" in
# A listener we own is never a conflict: on adoption/upgrade the domain
# proxy is already running. 'haproxy' is the current proxy, 'traefik' the
# pre-HAProxy one (a host that has not been upgraded yet), 'vpsmgr' the
# panel itself.
vpsmgr|traefik|haproxy) continue ;;
esac
if [[ "$PORT" == "80" || "$PORT" == "443" ]]; then
# 80/443 occupied by a non-vpsmgr process: not a hard failure — force the
# domain proxy OFF instead (still installed, kept stopped).
PORT_80_443_TAKEN=1
PORT_80_443_PROC="${PROC:-unknown process}"
continue
fi
if port_hard_reserved "$PORT"; then
CONFLICTS+=" $PORT (${ADDR}) — bound by ${PROC:-unknown process}"$'\n'
fi
done < <(ss -H -tlnp 2>/dev/null; ss -H -ulnp 2>/dev/null)
if [[ -n "$CONFLICTS" ]]; then
rm -f /etc/vpsmgr/.install-traefik-off 2>/dev/null || true
die "ports reserved for vpsmgr are already in use:
$CONFLICTS
Free these ports (or remove the programs above) and re-run install."
fi
if [[ "$PORT_80_443_TAKEN" == "1" ]]; then
# Belt-and-suspenders for update/adoption: the scan already skips our proxy's
# own listeners by process name, but if `ss` could not attribute the listener
# (PROC empty) while OUR proxy is already active, that active proxy is what
# holds 80/443 — do not misread it as a third-party conflict and turn our own
# domain proxy off. Both service names are checked: 'haproxy' now, 'traefik'
# on a host that has not run the upgraded installer yet.
if systemctl is-active --quiet haproxy.service 2>/dev/null \
|| systemctl is-active --quiet traefik.service 2>/dev/null; then
log "80/443 held by the running vpsmgr domain proxy — keeping it enabled (no force-off)"
else
mkdir -p /etc/vpsmgr
# The marker keeps its historical name: an older installer checkout still
# writes and reads this exact path, and the new one honors it too.
touch /etc/vpsmgr/.install-traefik-off
log "port 80 and/or 443 is in use (${PORT_80_443_PROC}) — will NOT fail;"
log "the domain proxy will be installed but DISABLED (net.haproxy false, not started/autostarted)."
log "re-enable later with: vps config set net.haproxy true"
fi
else
log "reserved ports are free"
fi
fi
# --- detect public ip / ext iface ---
# ext iface: prefer the IPv4 default route, then the IPv6 default route (covers
# IPv6-only hosts and policy-routed boxes whose v4 default lives in a non-main
# table), then the first non-virtual UP interface.
EXT_IF=$(ip route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')
if [[ -z "$EXT_IF" ]]; then
EXT_IF=$(ip -6 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')
fi
if [[ -z "$EXT_IF" ]]; then
EXT_IF=$(ip -o link show up 2>/dev/null | grep -v -E 'lo|incusbr|virbr|docker|veth|warp|wg' | awk -F': ' '{print $2; exit}')
fi
PUB_IP=""
if [[ -n "$EXT_IF" ]]; then
PUB_IP=$(ip -4 -o addr show dev "$EXT_IF" scope global | awk '{print $4}' | cut -d/ -f1 | head -1)
fi
if [[ -z "$PUB_IP" ]]; then
PUB_IP=$(hostname -I | awk '{print $1}')
log " warn: no public IPv4 detected on $EXT_IF, using $PUB_IP (private/other) as fallback"
fi
log "public/panel IP: $PUB_IP (ext iface: ${EXT_IF:-auto})"
# --- network reachability (warn only) ---
if ! curl -sI --max-time 8 https://images.linuxcontainers.org >/dev/null 2>&1; then
log " warn: cannot reach images.linuxcontainers.org — Incus image pull will fail unless cached"
fi
echo "[00] checks passed"