Files
vpsmgr/scripts/30-haproxy.sh
T
bluebluesoda b7ebdb5374 fix(install): bound stalled downloads and tighten prebuild checksums
- dl_content no longer runs with no timeout at all: every attempt carries
  --connect-timeout, and a content transfer that connects then crawls below
  1 KiB/s for 30s is aborted so the next try can take over (a slow but moving
  download is still never cut off by a total cap)
- verify the release binary by looking up its SHA256SUMS entry explicitly
  instead of `sha256sum -c --ignore-missing`, which quietly succeeded when the
  manifest held no line for the arch
- copy the local vps-prebuild instead of moving it, so a later failure cannot
  destroy the only snapshot along with the temp dir
- fail loudly when lib-download.sh is missing instead of degrading silently
- drop the unused dl_handshake helper, accept binary-mode `*asset` lines, and
  lower-case v4_forward in 30-haproxy so True/TRUE match like 00-ip-ask
2026-09-27 12:16:30 +00:00

379 lines
18 KiB
Bash

#!/usr/bin/env bash
# 30-haproxy.sh — install the HAProxy domain proxy, migrate away from Traefik.
#
# WHAT THIS SCRIPT OWNS
# 1. The HAProxy binary, pinned to the release BRANCH below (official HAProxy
# performance packages; the branch is the only thing a future major
# upgrade has to change).
# 2. The static entry configuration /etc/haproxy/haproxy.cfg. The panel
# publishes per-generation configs and repoints /etc/haproxy/current at
# them; this file only includes the current one.
# 3. The systemd unit (overriding any unit the distribution package ships).
# 4. The one-way migration OFF Traefik (see "TRAEFIK REMOVAL" below).
#
# ---------------------------------------------------------------------------
# UPGRADING FROM A TRAEFIK-ERA INSTALL (read before touching this script)
# ---------------------------------------------------------------------------
# vpsmgr used to front user domains with Traefik. An operator may run an old
# version for a long time before upgrading, so this script must converge a
# Traefik host onto HAProxy in ONE run, and never leave both proxies on the box:
#
# * net.traefik (the config key) was RENAMED to net.haproxy. Values are
# unchanged. The Go side reads BOTH (see cfg.Load / cfg.FillAuto) so an old
# config.yaml or an old installer exporting VPSMGR_TRAEFIK still works.
# * Traefik's program, unit and service account are REMOVED here. Only its
# config FILE /etc/traefik/traefik.yaml is kept (harmless, no longer read).
# * The old /etc/traefik/dynamic directory is REMOVED: it holds per-domain
# YAML that nothing reads any more, and keeping it would invite confusion
# about which proxy is live.
# * The pre-rename marker /etc/vpsmgr/.install-traefik-off is still honored
# (it records "80/443 was taken, so keep the proxy installed but down"),
# and is deleted once the new state has been recorded.
# * Ordering is deliberate: install + validate the new proxy FIRST, stop
# Traefik, start HAProxy, and only then delete the Traefik files. A failure
# before the start leaves the box on Traefik and aborts the install loudly.
#
# Do NOT add a "keep Traefik as a fallback" branch: a fallback would mean two
# proxies fighting for 80/443 after a reboot.
# ---------------------------------------------------------------------------
set -uo pipefail
log(){ echo "[30] $*"; }
die(){ echo "[30] error: $*" >&2; exit 1; }
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# ---------------------------------------------------------------------------
# 1. Pinned version: BRANCH is the contract, the patch level floats within it.
# ---------------------------------------------------------------------------
# Changing HAPROXY_BRANCH is the ONLY edit needed for a future major upgrade.
# It must stay in sync with cfg.HaproxyBranch in the panel (the panel refuses
# to manage a binary from a different branch) and with the two places below:
# HAPROXY_REPO_SLUG -> the apt source path (…/performance/<distro>/ha<XY>)
# HAPROXY_BRANCH_RE -> the version assertion in verify_version
# A wrong slug makes `apt-get update` 404 and the install fails loudly; a wrong
# regex makes verify_version fail. Neither can silently install the wrong proxy.
HAPROXY_BRANCH="3.4"
HAPROXY_BRANCH_RE='^3\.4\.'
HAPROXY_REPO_SLUG="ha34"
HAPROXY_KEY_URL="https://pks.haproxy.com/linux/community/RPM-GPG-KEY-HAProxy"
HAPROXY_KEY="/usr/share/keyrings/HAPROXY-key-community.asc"
HAPROXY_PKG="haproxy-awslc"
# The published layout root. Must match cfg.DefaultProxyDir.
HAPROXY_DIR="/etc/haproxy"
ENTRY_CFG="$HAPROXY_DIR/haproxy.cfg"
CURRENT_LINK="$HAPROXY_DIR/current"
# ---------------------------------------------------------------------------
# 2. Distribution keys for the apt source. The vendor repo is per-distribution
# AND per-branch, so both have to be resolved before writing the source.
# ---------------------------------------------------------------------------
OS_ID=""
OS_CODENAME=""
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-}"
OS_CODENAME="${VERSION_CODENAME:-}"
fi
case "$OS_ID" in
debian|ubuntu) ;;
*) die "unsupported distribution '${OS_ID:-unknown}' for the HAProxy package repository (need debian or ubuntu)" ;;
esac
[[ -n "$OS_CODENAME" ]] || die "could not determine VERSION_CODENAME from /etc/os-release"
# Surface any previous HAProxy install early. An OLDER branch (e.g. 3.2) is
# upgraded in place by the branch switch below, which is expected; the version
# assertion at the end is what proves the switch worked.
if [[ -x /usr/sbin/haproxy ]]; then
log "existing haproxy: $(/usr/sbin/haproxy -v 2>/dev/null | head -1)"
fi
# ---------------------------------------------------------------------------
# 3. Install the pinned branch from the official vendor repository.
# ---------------------------------------------------------------------------
install_repo(){
# gpg + curl + ca-certificates are needed for the key and the repo; the
# installer's 00-check step normally provides them, this covers a standalone
# run of this script on a bare host.
for p in curl ca-certificates gpg; do
dpkg -s "$p" >/dev/null 2>&1 || {
log "installing $p (needed for the HAProxy repository)"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$p"
}
done
if [[ ! -s "$HAPROXY_KEY" ]]; then
# download_keyring: the vendor key endpoint is plain HTTPS (no TLS client
# auth), so a fetch failure is fatal — never continue without the key, or
# apt would trust an unverified repository.
log "fetching the HAProxy repository key"
mkdir -p /usr/share/keyrings
curl -fsSL --max-time 60 -o "$HAPROXY_KEY.tmp" "$HAPROXY_KEY_URL" \
|| die "could not download the HAProxy repository key"
mv "$HAPROXY_KEY.tmp" "$HAPROXY_KEY"
chmod 0644 "$HAPROXY_KEY"
fi
local src="/etc/apt/sources.list.d/haproxy.list"
local want="deb [signed-by=$HAPROXY_KEY] https://www.haproxy.com/download/haproxy/performance/$OS_ID/$HAPROXY_REPO_SLUG $OS_CODENAME main"
if [[ ! -f "$src" ]] || [[ "$(cat "$src" 2>/dev/null)" != "$want" ]]; then
# Any older source line (a previous branch) is replaced wholesale: leaving
# both would let apt pick whichever branch has the higher version.
printf '%s\n' "$want" > "$src"
chmod 0644 "$src"
log "configured the HAProxy $HAPROXY_BRANCH repository for $OS_ID/$OS_CODENAME"
# A stale package list from the previous branch must not be reused.
rm -f /var/lib/apt/lists/*haproxy* 2>/dev/null || true
fi
apt-get update -qq || die "apt-get update failed (check the HAProxy repository)"
}
verify_version(){
local bin="${1:-/usr/sbin/haproxy}"
[[ -x "$bin" ]] || die "HAProxy binary not found at $bin"
local out
out="$("$bin" -v 2>/dev/null | head -1)"
local ver
ver="$(awk '{print $3}' <<<"$out")"
# The vendor package reports "HAProxy version 3.4.4-0+ha34+deb13u1 2026/08/27".
if [[ ! "$ver" =~ $HAPROXY_BRANCH_RE ]]; then
die "installed HAProxy is '$ver', which is not on the pinned branch $HAPROXY_BRANCH ($out)
a newer/older branch source is still configured — fix /etc/apt/sources.list.d/haproxy.list"
fi
log "haproxy $ver (branch $HAPROXY_BRANCH)"
}
install_repo
if ! dpkg -s "$HAPROXY_PKG" >/dev/null 2>&1; then
log "installing $HAPROXY_PKG (HAProxy $HAPROXY_BRANCH, AWS-LC build)"
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$HAPROXY_PKG" \
|| die "could not install $HAPROXY_PKG"
else
# Already present: make sure it is the newest patch of the pinned branch.
# `--only-upgrade` never installs anything new, so a no-op stays a no-op.
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade "$HAPROXY_PKG" >/dev/null 2>&1 || true
fi
verify_version /usr/sbin/haproxy
# ---------------------------------------------------------------------------
# 4. Directories. The panel (user 'vps') owns the layout root and publishes
# generations into it; HAProxy only READS them, so the dirs stay 0755.
# ---------------------------------------------------------------------------
mkdir -p "$HAPROXY_DIR" /run/haproxy
# Keep any configuration an operator already had before we overwrite it. This
# is a one-time courtesy for the very first install of this script on a host
# that ran the distribution's haproxy package.
if [[ -f "$ENTRY_CFG" ]] && ! grep -q "Managed by vpsmgr" "$ENTRY_CFG" 2>/dev/null; then
cp -a "$ENTRY_CFG" "$ENTRY_CFG.before-vpsmgr" 2>/dev/null || true
log "kept the previous $ENTRY_CFG as $ENTRY_CFG.before-vpsmgr"
fi
cp "$ROOT/configs/haproxy/haproxy.cfg" "$ENTRY_CFG"
chmod 0644 "$ENTRY_CFG"
# A generation must exist before the unit is ever started, because the entry
# config resolves its map paths through "$CURRENT_LINK/maps/" and HAProxy
# refuses to start when a map cannot be opened. The panel republishes this from
# the DB at startup; this is only the bootstrap (empty maps = every request
# falls through to the reject backends).
# The unit loads BOTH the entry config and this file, so "the generation exists"
# means domains.cfg plus both maps.
if [[ ! -f "$CURRENT_LINK/domains.cfg" ]] || [[ ! -f "$CURRENT_LINK/maps/http.map" ]]; then
log "bootstrapping an empty HAProxy generation"
gen="$HAPROXY_DIR/releases/000001"
mkdir -p "$gen/maps"
cp "$ROOT/configs/haproxy/domains.cfg" "$gen/domains.cfg"
cp "$ROOT/configs/haproxy/maps/http.map" "$gen/maps/http.map"
cp "$ROOT/configs/haproxy/maps/sni.map" "$gen/maps/sni.map"
printf 'generation: 1\ndomains: 0\n' > "$gen/manifest"
printf '1\n' > "$HAPROXY_DIR/generation"
# Atomic switch: a temp link renamed over the real one, so a reader (or a
# concurrent panel publish) never observes a missing link.
ln -sfn "releases/000001" "$CURRENT_LINK.tmp" && mv -Tf "$CURRENT_LINK.tmp" "$CURRENT_LINK"
fi
# ---------------------------------------------------------------------------
# 5. The unit. Debian's haproxy package ships /lib/systemd/system/haproxy.service
# plus an /etc/default/haproxy conffile; ours lives in /etc/systemd/system,
# which takes precedence, so exactly one process can ever own 80/443.
# ---------------------------------------------------------------------------
if [[ -f /lib/systemd/system/haproxy.service ]] || [[ -f /usr/lib/systemd/system/haproxy.service ]]; then
log "distribution haproxy unit present — overridden by /etc/systemd/system/haproxy.service"
fi
cp "$ROOT/configs/systemd/haproxy.service" /etc/systemd/system/haproxy.service
chmod 0644 /etc/systemd/system/haproxy.service
systemctl daemon-reload
# The haproxy account is created by the package's maintainer scripts. If a
# future package drops it (or we ever switch to a hand-built binary), create it
# here; the config's "user haproxy" would otherwise fail to start.
if ! id -u haproxy >/dev/null 2>&1; then
useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin haproxy
log "created the 'haproxy' service account"
fi
# ---------------------------------------------------------------------------
# 6. Effective enablement: an IPv4 domain-proxy-eligible policy AND the
# domain-proxy switch. web-only qualifies; false does not.
# ---------------------------------------------------------------------------
V4_PROXY_ALLOWED=1
V4_FWD_EFFECTIVE="${VPSMGR_V4_FORWARD:-1}"
case "${V4_FWD_EFFECTIVE,,}" in
1|true|web-only) V4_PROXY_ALLOWED=1 ;;
*) V4_PROXY_ALLOWED=0 ;;
esac
# Proxy switch: installer override first (VPSMGR_HAPROXY, or the legacy
# VPSMGR_TRAEFIK an old installer exported), then an adopted config file
# (net.haproxy, or the pre-rename net.traefik key), then the default (enabled).
PROXY_CFG=""
pick_bool(){
case "${1,,}" in
1|true) PROXY_CFG=true ;;
0|false) PROXY_CFG=false ;;
*) die "$2 must be 1/0/true/false (got '$1')" ;;
esac
}
if [[ -n "${VPSMGR_HAPROXY:-}" ]]; then
pick_bool "$VPSMGR_HAPROXY" VPSMGR_HAPROXY
elif [[ -n "${VPSMGR_TRAEFIK:-}" ]]; then
pick_bool "$VPSMGR_TRAEFIK" VPSMGR_TRAEFIK
elif [[ -f /etc/vpsmgr/config.yaml ]]; then
# Read the new key when present, else the legacy one. Both are plain "true"/
# "false" values under the net: section.
v="$(grep -E '^\s+haproxy:' /etc/vpsmgr/config.yaml 2>/dev/null | awk -F': ' '{print $2}' | tr -d '"')"
if [[ -z "$v" ]]; then
v="$(grep -E '^\s+traefik:' /etc/vpsmgr/config.yaml 2>/dev/null | awk -F': ' '{print $2}' | tr -d '"')"
fi
[[ -n "$v" ]] && pick_bool "$v" config
fi
# ---------------------------------------------------------------------------
# 7. Port check BEFORE touching Traefik, so a conflict can never leave the box
# with no proxy at all. A Traefik/HAProxy listener we already own is not a
# conflict (that is the normal upgrade path).
# ---------------------------------------------------------------------------
ports_busy(){
local port="$1" proc
while read -r line; do
[[ -z "$line" ]] && continue
proc="$(sed -n 's/.*users:(("\([^"]*\)".*/\1/p' <<<"$line")"
case "${proc##*/}" in
haproxy|traefik|vpsmgr) continue ;; # ours
esac
return 0
done < <(ss -H -tlnp 2>/dev/null | awk -v p=":$port" '$4 ~ p"$"')
return 1
}
# ---------------------------------------------------------------------------
# 8. TRAEFIK REMOVAL — one-way, after the new proxy is proven startable.
# ---------------------------------------------------------------------------
traefik_present(){
[[ -x /usr/local/bin/traefik ]] \
|| [[ -f /etc/systemd/system/traefik.service ]] \
|| [[ -d /etc/traefik ]] \
|| id -u traefik >/dev/null 2>&1
}
remove_traefik(){
traefik_present || return 0
log "removing the Traefik era install (one-way: HAProxy is the only proxy now)"
# Stop and disable FIRST: a disabled unit must not come back on reboot and
# race HAProxy for 80/443.
systemctl disable --now traefik.service >/dev/null 2>&1 || true
rm -f /etc/systemd/system/traefik.service
systemctl daemon-reload >/dev/null 2>&1 || true
# Program and service account.
rm -f /usr/local/bin/traefik
if id -u traefik >/dev/null 2>&1; then
userdel traefik >/dev/null 2>&1 || true
fi
# userdel leaves the same-named primary group behind on some systems; a
# stale group makes a later `useradd traefik` fail with exit 9.
if getent group traefik >/dev/null 2>&1; then
groupdel traefik >/dev/null 2>&1 || true
fi
# Per-domain YAML from the old proxy. Nothing reads it now; leaving it would
# only confuse an operator debugging "which proxy is serving this domain".
rm -rf /etc/traefik/dynamic
# The config FILE is kept deliberately: it documents the previous setup and
# removing it would discard host-specific provider settings. It is inert.
[[ -f /etc/traefik/traefik.yaml ]] && log "kept /etc/traefik/traefik.yaml (no longer read)"
log "traefik removed: binary, unit, service account and dynamic rules"
}
# The marker records "80/443 was already taken when this host was installed, so
# keep the domain proxy installed but down". It predates the rename; honor it
# and then retire it (the state it describes now lives in net.haproxy).
if [[ -f /etc/vpsmgr/.install-traefik-off ]]; then
log "found .install-traefik-off — keeping the domain proxy installed but disabled"
PROXY_CFG=false
fi
[[ -n "$PROXY_CFG" ]] || PROXY_CFG=true
CONFLICT=""
if ports_busy 80; then CONFLICT="80"; fi
if ports_busy 443; then CONFLICT="${CONFLICT:+$CONFLICT and }443"; fi
want_start=1
if [[ "$V4_PROXY_ALLOWED" -eq 0 ]]; then
want_start=0
log "IPv4 inbound fully off — HAProxy installed but disabled (domains kept)"
elif [[ "$PROXY_CFG" != "true" ]]; then
want_start=0
log "net.haproxy is false — HAProxy installed but disabled (not started/autostarted; domains kept)"
elif [[ -n "$CONFLICT" ]]; then
want_start=0
log "port $CONFLICT is in use by another process — HAProxy installed but disabled"
log "free the port, then run: vps config set net.haproxy true"
fi
# The candidate configuration must be valid BEFORE we touch the running proxy:
# a broken config would otherwise take the domain service down with it.
# Validate EXACTLY the command line the unit is about to run (see
# configs/systemd/haproxy.service): the entry config alone is not the
# configuration, it only declares the frontends, the reject backends and the
# timeouts. The per-domain backends and the routing maps live in the
# generation, so checking one file would happily pass a setup that answers 404
# on :80 and closes every :443 connection.
/usr/sbin/haproxy -c -q -f "$ENTRY_CFG" -f "$CURRENT_LINK/domains.cfg" 2>/dev/null \
|| { /usr/sbin/haproxy -c -f "$ENTRY_CFG" -f "$CURRENT_LINK/domains.cfg";
die "the generated HAProxy configuration is invalid"; }
log "configuration check passed (entry config + generation)"
if [[ "$want_start" -eq 1 ]]; then
systemctl enable --now haproxy.service >/dev/null 2>&1 || true
sleep 1
if ! systemctl is-active --quiet haproxy.service; then
# Only NOW is a message about the still-running Traefik accurate.
if systemctl is-active --quiet traefik.service 2>/dev/null; then
systemctl status haproxy.service --no-pager | tail -5
die "HAProxy failed to start while Traefik is still running — free 80/443 or disable it, then re-run the installer"
fi
systemctl status haproxy.service --no-pager | tail -5
die "HAProxy failed to start"
fi
log "HAProxy running"
# Only after HAProxy is confirmed active do we tear Traefik down, so a failed
# start leaves the host exactly as it was.
remove_traefik
else
# Not starting: disable boot autostart so a reboot cannot bring up a proxy
# the operator asked to keep off.
systemctl disable --now haproxy.service >/dev/null 2>&1 || true
# Traefik must still go: it must never be the process serving domains after
# this script runs, even when HAProxy stays down.
remove_traefik
fi
rm -f /etc/vpsmgr/.install-traefik-off 2>/dev/null || true
log "domain proxy: $( [[ "$want_start" -eq 1 ]] && echo "running (HAProxy $HAPROXY_BRANCH)" || echo "installed but disabled" )"
echo "[30] haproxy ready"