#!/bin/sh # ============================================================================ # HAPX-UI privileged network helper (Phase 1: IPv6 / DNS resolvers / flush). # # Invoked ONLY as: sudo /usr/local/sbin/hapx-ui-netcfg [args] # via a single NOPASSWD sudoers rule. Every subcommand is FIXED and every value # is STRICTLY validated before it touches a file — no argument is ever passed to # a shell, and IP arguments are constrained to [0-9a-fA-F.:] so nothing can be # injected into the files we write. Read-only `status` is safe to poll. # ============================================================================ set -eu SYSCTL_FILE=/etc/sysctl.d/99-hapx-ipv6.conf RESOLV=/etc/resolv.conf IFACE_DIR=/etc/network/interfaces.d # Rollback snapshots live in a ROOT-OWNED sibling of the service data dir, NOT # inside /var/lib/hapx-ui (which install.sh chowns to the service user). If they # lived under the service-writable dir, a compromised service user could plant a # malicious eth0.bak and get it installed as root via `iface-rollback`. Same # reasoning as STAGING_DIR / cs-state in the updater/crowdsec helpers. BK_DIR=/var/lib/hapx-ui-netcfg-backup ROLLBACK_SECS=120 TUNE_SYSCTL=/etc/sysctl.d/90-hapx-haproxy.conf TUNE_JOURNALD=/etc/systemd/journald.conf.d/90-hapx.conf TUNE_MODULES=/etc/modules-load.d/90-hapx.conf TUNE_ALT=/etc/sysctl.d/99-hapx-tcp.conf # Reject empty, a leading '-' (would be read as an option by ifup/ifdown), and # anything outside the ifupdown-safe charset. Anchored so no traversal into # $IFACE_DIR / $BK_DIR is possible. is_iface() { case "$1" in ''|-*|*[!a-zA-Z0-9._-]*) return 1 ;; *) return 0 ;; esac; } is_cidr() { case "$1" in *[!0-9./]*) return 1 ;; *.*.*.*/[0-9]*) return 0 ;; *) return 1 ;; esac; } # Create the root-only snapshot dir (self-healing: also fixes a pre-existing dir # with looser ownership/mode left by an older version). ensure_bkdir() { mkdir -p "$BK_DIR" 2>/dev/null || true chown root:root "$BK_DIR" 2>/dev/null || true chmod 700 "$BK_DIR" 2>/dev/null || true } # Whitelist a saved interface config: every non-blank, non-comment line must be # one of the exact directive shapes iface-apply itself emits. Defence in depth on # top of the root-only BK_DIR — a snapshot must never carry ifupdown hook lines # (up/pre-up/post-up …) that would run as root on `ifup`. valid_iface_cfg() { _f="$1" [ -L "$_f" ] && return 1 # never follow a symlink [ -f "$_f" ] || return 1 while IFS= read -r _ln; do case "$_ln" in ''|'#'*) : ;; # blank / comment 'allow-hotplug '*) : ;; 'iface '*' inet dhcp') : ;; 'iface '*' inet static') : ;; 'iface '*' inet manual') : ;; ' address '*) is_cidr "${_ln#' address '}" || return 1 ;; ' gateway '*) is_ip "${_ln#' gateway '}" || return 1 ;; ' dns-nameservers '*) for _d in ${_ln#' dns-nameservers '}; do is_ip "$_d" || return 1; done ;; *) return 1 ;; # anything else → reject esac done < "$_f" return 0 } # Strict: a bare IPv4 or IPv6 literal only (no spaces, CIDR, ports, shell chars). is_ip() { case "$1" in *[!0-9a-fA-F.:]*) return 1 ;; # any disallowed char → reject esac # must look like IPv4 (a.b.c.d) or contain a ':' (IPv6). Reject empty. [ -n "$1" ] || return 1 case "$1" in *:*) return 0 ;; # IPv6 *.*.*.*) return 0 ;; # IPv4 dotted-quad *) return 1 ;; esac } cmd="${1:-}" # The caller (hapx-ui.service) runs under a strict systemd sandbox # (ProtectSystem=strict, ProtectKernelTunables) that is INHERITED by sudo # children — so /etc and /proc/sys are read-only here and any write fails with # exit 2. For mutating subcommands we re-exec the SAME command in a transient # systemd unit, which runs in PID1's context OUTSIDE that sandbox (root already, # via sudo). Read-only subcommands (status/iface-pending/iface-list) stay in-proc # so page rendering isn't slowed by spawning a unit. HAPX_NETCFG_UNCONFINED # guards against infinite re-exec. case "$cmd" in ipv6|dns|flushdns|iface-apply|iface-confirm|tune) if [ "${HAPX_NETCFG_UNCONFINED:-}" != 1 ] && [ -d /run/systemd/system ] && command -v systemd-run >/dev/null 2>&1; then exec systemd-run --pipe --wait --collect --quiet \ --unit="hapx-netcfg-run-$$" --setenv=HAPX_NETCFG_UNCONFINED=1 \ -- /usr/local/sbin/hapx-ui-netcfg "$@" fi ;; esac case "$cmd" in status) v6=$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null || echo 0) [ "$v6" = 1 ] && ipv6=off || ipv6=on ns=$(awk '/^[[:space:]]*nameserver[[:space:]]/{printf "%s%s", sep, $2; sep=","}' "$RESOLV" 2>/dev/null || true) printf '{"ipv6":"%s","dns":"%s"}\n' "$ipv6" "$ns" ;; # Die Zeitzone der Maschine. Sie steckt hier und nicht in einem eigenen Helfer, # weil ein zweiter Helfer eine zweite sudoers-Regel, einen zweiten Release- # Anhang und einen zweiten Installationsschritt bedeutet hätte — für einen # einzigen Aufruf von timedatectl. # # Geprüft wird gegen /usr/share/zoneinfo statt gegen eine eigene Liste: was das # System kennt, ist die Wahrheit, und der Name landet nie in einer Shell. Der # Zeichensatz ist eng gefasst und führende '-' sind raus, damit nichts als # Option gelesen wird; ".." kann wegen des Zeichensatzes nicht auftreten. timezone) tz="${2:-}" case "$tz" in ''|-*|*[!a-zA-Z0-9/_+-]*) echo "ungueltige Zeitzone" >&2; exit 2 ;; esac [ -f "/usr/share/zoneinfo/$tz" ] || { echo "unbekannte Zeitzone: $tz" >&2; exit 2; } timedatectl set-timezone "$tz" || exit 1 echo ok ;; # Arbeitsspeicher- und Protokollvorgaben. Zwei Fehleinstellungen, die kein # Mensch je bewusst gewaehlt hat, aber jede Anfrage bremsen: # # vm.swappiness steht auf 60. Auf einer Maschine mit knappem Speicher wirft # der Kern dann lieber den Arbeitsspeicher von HAProxy auf die Platte als # den Dateizwischenspeicher wegzuwerfen. Auf prod gemessen: der laufende # HAProxy-Arbeiter hatte 22 MB ausgelagert, und es wurden durchgehend # Seiten zurueckgeholt -- jede davon ein Plattenzugriff im Anfragepfad. # # Das Journal hat keinen Deckel. Auf prod stand es bei 4 GB, davon 91 % # HAProxy-Anfragezeilen, und jede dieser Zeilen wird zweimal geschrieben: # einmal von journald, einmal ueber rsyslog nach /var/log/haproxy.log. # Der dabei entstehende Schreib-Cache ist genau der Druck, der HAProxy # verdraengt. # # Bewusst nur diese beiden, risikoarmen und jederzeit ruecknehmbaren Groessen. # Die TCP-Einstellungen (BBR, fq, Puffergroessen) greifen tiefer ins # Netzverhalten ein und gehoeren nicht in einen stillen Automatismus. # # Das Log-Ziel in haproxy.cfg wird NICHT angefasst. Es umzustellen funktioniert # zwar -- HAProxy verbindet den Socket vor dem chroot --, aber der Socket wird # genau einmal verbunden und ueberlebt keinen rsyslog-Neustart. Danach waere # hapx-ui lautlos blind. tune) case "${2:-}" in status) sw=$(cat /proc/sys/vm/swappiness 2>/dev/null || echo -1) vf=$(cat /proc/sys/vm/vfs_cache_pressure 2>/dev/null || echo -1) cc=$(cat /proc/sys/net/ipv4/tcp_congestion_control 2>/dev/null || echo unbekannt) qd=$(cat /proc/sys/net/core/default_qdisc 2>/dev/null || echo unbekannt) # Der sysctl-Wert allein sagt NICHT, was an der Schnittstelle haengt: # er gilt nur fuer neu angelegte Warteschlangen, also ab dem naechsten # Hochfahren der Schnittstelle. Genau so ein Wert, der gesetzt aussieht # und nichts tut, ist die Fehlerklasse, die wir gerade aufraeumen -- # deshalb hier die gemessene Wahrheit daneben. ifc=$(ip route show default 2>/dev/null | awk "/^default/{print \$5; exit}") qdw=unbekannt if [ -n "$ifc" ]; then qdw=$(tc qdisc show dev "$ifc" 2>/dev/null \ | awk "!/^qdisc (noqueue|clsact|mq) /{print \$2; exit}") [ -n "$qdw" ] || qdw=$(tc qdisc show dev "$ifc" 2>/dev/null | awk "{print \$2; exit}") fi av=$(cat /proc/sys/net/ipv4/tcp_available_congestion_control 2>/dev/null || echo "") bbr=nein case " $av " in *" bbr "*) bbr=ja ;; esac jr=$(journalctl --disk-usage 2>/dev/null | grep -oE '[0-9.]+[KMG]' | head -1) cap=no [ -f "$TUNE_JOURNALD" ] && cap=yes printf '{"swappiness":%s,"vfs_cache_pressure":%s,"stauverfahren":"%s","warteschlange_vorgabe":"%s","warteschlange_wirksam":"%s","bbr_verfuegbar":"%s","journal":"%s","verwaltet":"%s"}\n' \ "$sw" "$vf" "$cc" "$qd" "${qdw:-unbekannt}" "$bbr" "${jr:-unbekannt}" "$cap" ;; apply) ensure_bkdir for f in "$TUNE_SYSCTL" "$TUNE_JOURNALD"; do [ -f "$f" ] && [ ! -f "$BK_DIR/$(basename "$f").orig" ] \ && cp -a "$f" "$BK_DIR/$(basename "$f").orig" done mkdir -p "$(dirname "$TUNE_JOURNALD")" "$(dirname "$TUNE_MODULES")" # BBR ist auf den meisten Kernen ein Modul und nicht vorgeladen. Erst # laden, dann nachsehen, ob es der Kern wirklich anbietet -- eine # sysctl-Zeile fuer ein unbekanntes Verfahren wuerde beim Anwenden # scheitern und die uebrigen Zeilen mitreissen. modprobe tcp_bbr >/dev/null 2>&1 || true avail=$(cat /proc/sys/net/ipv4/tcp_available_congestion_control 2>/dev/null || echo "") bbr_line="# BBR steht auf diesem Kern nicht zur Verfuegung -- cubic bleibt." case " $avail " in *" bbr "*) bbr_line="net.ipv4.tcp_congestion_control = bbr" printf 'tcp_bbr\n' > "$TUNE_MODULES" chmod 0644 "$TUNE_MODULES" ;; esac tmp=$(mktemp) || exit 1 cat > "$tmp" <&2 grep -vE "^[[:space:]]*($|#)" "$tmp" \ | grep -vE "^[a-z0-9_.-]+[[:space:]]*=[[:space:]]*[-0-9a-z_. ]+$" >&2 rm -f "$tmp" exit 1 fi chmod 0644 "$tmp" && mv -f "$tmp" "$TUNE_SYSCTL" || exit 1 # Aeltere, von uns selbst geschriebene Datei einsammeln: sie sortiert # hinter der neuen und wuerde sie sonst stillschweigend ueberstimmen. if [ -f "$TUNE_ALT" ] && grep -q "Von HAPX-UI verwaltet" "$TUNE_ALT" 2>/dev/null; then cp -a "$TUNE_ALT" "$BK_DIR/$(basename "$TUNE_ALT").orig" 2>/dev/null || true rm -f "$TUNE_ALT" fi tmp=$(mktemp) || exit 1 cat > "$tmp" <<'JOURNALD' # Von hapx-ui verwaltet. Siehe `hapx-ui-netcfg tune status`. # Ohne Deckel waechst das Journal unbegrenzt; auf einem Proxy sind fast alle # Eintraege HAProxy-Anfragezeilen, die ohnehin in /var/log/haproxy.log stehen. [Journal] SystemMaxUse=500M SystemKeepFree=1G MaxRetentionSec=1month JOURNALD chmod 0644 "$tmp" && mv -f "$tmp" "$TUNE_JOURNALD" || exit 1 sysctl --system >/dev/null 2>&1 || true systemctl restart systemd-journald >/dev/null 2>&1 || true journalctl --vacuum-size=500M >/dev/null 2>&1 || true echo ok ;; *) echo "tune: status|apply" >&2; exit 2 ;; esac ;; ipv6) case "${2:-}" in off) val=1 ;; on) val=0 ;; *) echo "usage: ipv6 on|off" >&2; exit 2 ;; esac umask 022 { echo "# Managed by HAPX-UI (Netzwerk-Einstellungen). Do not edit by hand." echo "net.ipv6.conf.all.disable_ipv6 = $val" echo "net.ipv6.conf.default.disable_ipv6 = $val" echo "net.ipv6.conf.lo.disable_ipv6 = 0" } > "$SYSCTL_FILE" sysctl -p "$SYSCTL_FILE" >/dev/null 2>&1 || sysctl --system >/dev/null 2>&1 || true echo ok ;; dns) shift [ "$#" -ge 1 ] || { echo "usage: dns [ip ...]" >&2; exit 2; } for ip in "$@"; do is_ip "$ip" || { echo "ungueltige IP: $ip" >&2; exit 2; }; done tmp="$(mktemp "${RESOLV}.hapx.XXXXXX")" { echo "# Managed by HAPX-UI (Netzwerk-Einstellungen)." for ip in "$@"; do echo "nameserver $ip"; done } > "$tmp" chmod 0644 "$tmp" # mv replaces a real file OR a systemd-resolved symlink with our static file. mv -f "$tmp" "$RESOLV" echo ok ;; flushdns) command -v resolvectl >/dev/null 2>&1 && resolvectl flush-caches >/dev/null 2>&1 || true command -v systemd-resolve >/dev/null 2>&1 && systemd-resolve --flush-caches >/dev/null 2>&1 || true systemctl restart systemd-resolved >/dev/null 2>&1 || true echo ok ;; # ---- Phase 2: per-adapter IPv4 (ifupdown) with auto-rollback -------------- # iface-apply dhcp # iface-apply static # iface-apply down # Backs up the current config, writes the new one, ARMS a transient # systemd timer that reverts in ROLLBACK_SECS unless `iface-confirm` runs, # then applies. The timer runs locally, so a config that kills the network # still gets rolled back and access is regained. No arg reaches a shell. iface-apply) ifc="${2:-}"; mode="${3:-}" is_iface "$ifc" || { echo "ungueltiger Adapter" >&2; exit 2; } # Validate the mode + all values BEFORE creating any file, so invalid input # is rejected cleanly (and leaves nothing behind). addr="${4:-}"; gw="${5:--}"; dns="${6:--}" case "$mode" in dhcp|down) : ;; static) is_cidr "$addr" || { echo "ungueltige Adresse (CIDR erwartet, z.B. 192.168.0.10/24)" >&2; exit 2; } if [ "$gw" != "-" ]; then is_ip "$gw" || { echo "ungueltiges Gateway" >&2; exit 2; }; fi if [ "$dns" != "-" ]; then for _d in $(echo "$dns" | tr ',' ' '); do is_ip "$_d" || { echo "ungueltiger DNS: $_d" >&2; exit 2; }; done fi ;; *) echo "usage: iface-apply dhcp|static |down" >&2; exit 2 ;; esac umask 022 ensure_bkdir; mkdir -p "$IFACE_DIR" # A snapshot already present means a PRIOR change is still awaiting rollback. # Overwriting it now would replace the pristine anchor with the (already # changed) config, so the auto-rollback would restore the broken state. Refuse # the second apply until the pending one is confirmed or the timer reverts. if [ -e "$BK_DIR/$ifc.bak" ] || [ -e "$BK_DIR/$ifc.absent" ]; then echo "Rollback fuer $ifc steht noch aus — bitte zuerst 'Behalten' bestaetigen oder ~2 Min abwarten" >&2 exit 3 fi # snapshot for rollback (record 'absent' if there is no current drop-in) if [ -f "$IFACE_DIR/$ifc.cfg" ]; then cp -f "$IFACE_DIR/$ifc.cfg" "$BK_DIR/$ifc.bak"; else : > "$BK_DIR/$ifc.absent"; fi tmp="$(mktemp "$IFACE_DIR/.$ifc.XXXXXX")" case "$mode" in dhcp) printf 'allow-hotplug %s\niface %s inet dhcp\n' "$ifc" "$ifc" > "$tmp" ;; static) { printf 'allow-hotplug %s\niface %s inet static\n address %s\n' "$ifc" "$ifc" "$addr" [ "$gw" = "-" ] || printf ' gateway %s\n' "$gw" [ "$dns" = "-" ] || printf ' dns-nameservers %s\n' "$(echo "$dns" | tr ',' ' ')" } > "$tmp" ;; down) printf '# %s administratively disabled by HAPX-UI\niface %s inet manual\n' "$ifc" "$ifc" > "$tmp" ;; *) rm -f "$tmp"; echo "usage: iface-apply dhcp|static |down" >&2; exit 2 ;; esac chmod 0644 "$tmp"; mv -f "$tmp" "$IFACE_DIR/$ifc.cfg" grep -qs '^source /etc/network/interfaces.d/\*' /etc/network/interfaces 2>/dev/null \ || echo 'source /etc/network/interfaces.d/*' >> /etc/network/interfaces # Warn if the main file ALSO defines this adapter inline — both definitions # would then be active and ifupdown behaviour becomes order-dependent. if grep -qsE "^[[:space:]]*iface[[:space:]]+${ifc}[[:space:]]+inet" /etc/network/interfaces 2>/dev/null; then echo "WARNUNG: $ifc ist bereits in /etc/network/interfaces definiert — bitte dort entfernen" >&2 fi # ARM rollback FIRST (before applying), so a change that kills the link is # always covered. Arming failure is fatal: without a timer the operator could # be locked out, so we revert immediately and report an error. systemctl stop "hapx-netrollback-$ifc.timer" "hapx-netrollback-$ifc.service" 2>/dev/null || true systemctl reset-failed "hapx-netrollback-$ifc.service" 2>/dev/null || true # AccuracySec=1s: without it systemd coalesces timers to a 1-min window, so # --on-active=120 could fire as late as ~180s. Pin it so "~2 min" is honest. # HAPX_NETCFG_ROLLBACK marks the ONLY legitimate caller of iface-rollback. if ! systemd-run --quiet --collect --unit="hapx-netrollback-$ifc" \ --on-active="$ROLLBACK_SECS" --timer-property=AccuracySec=1s \ --setenv=HAPX_NETCFG_ROLLBACK=1 \ /usr/local/sbin/hapx-ui-netcfg iface-rollback "$ifc" >/dev/null 2>&1; then # Roll back the just-written config by hand and fail loudly. if [ -f "$BK_DIR/$ifc.bak" ]; then cp -f "$BK_DIR/$ifc.bak" "$IFACE_DIR/$ifc.cfg" else rm -f "$IFACE_DIR/$ifc.cfg"; fi rm -f "$BK_DIR/$ifc.bak" "$BK_DIR/$ifc.absent" echo "Auto-Rollback-Timer konnte nicht scharfgeschaltet werden — Aenderung verworfen" >&2 exit 4 fi # apply the change ( ifdown "$ifc" 2>/dev/null; ifup "$ifc" 2>/dev/null ) || systemctl restart networking 2>/dev/null || true echo "armed $ROLLBACK_SECS" ;; iface-confirm) ifc="${2:-}"; is_iface "$ifc" || { echo "ungueltiger Adapter" >&2; exit 2; } systemctl stop "hapx-netrollback-$ifc.timer" "hapx-netrollback-$ifc.service" 2>/dev/null || true systemctl reset-failed "hapx-netrollback-$ifc.service" 2>/dev/null || true rm -f "$BK_DIR/$ifc.bak" "$BK_DIR/$ifc.absent" echo ok ;; iface-pending) # read-only: which adapters currently have an armed rollback timer sep=""; printf '[' for f in "$BK_DIR"/*.bak "$BK_DIR"/*.absent; do [ -e "$f" ] || continue b="${f##*/}"; ifc="${b%.*}" printf '%s"%s"' "$sep" "$ifc"; sep="," done printf ']\n' ;; iface-rollback) # Invoked ONLY by the armed transient timer (which sets HAPX_NETCFG_ROLLBACK). # It is deliberately NOT in the sudoers verb list, so the service user cannot # reach it; this env guard is defence in depth against any future sudo hole. [ "${HAPX_NETCFG_ROLLBACK:-}" = 1 ] || { echo "iface-rollback wird nur vom Rollback-Timer aufgerufen" >&2; exit 2; } ifc="${2:-}"; is_iface "$ifc" || exit 2 if [ -f "$BK_DIR/$ifc.bak" ]; then # Only restore a snapshot that still looks exactly like something we wrote # (no symlink, no injected ifupdown hook lines). Otherwise drop the drop-in # rather than install untrusted content as root. if valid_iface_cfg "$BK_DIR/$ifc.bak"; then cp -f "$BK_DIR/$ifc.bak" "$IFACE_DIR/$ifc.cfg" else echo "Rollback-Backup fuer $ifc ist manipuliert — verworfen" >&2 rm -f "$IFACE_DIR/$ifc.cfg" fi elif [ -f "$BK_DIR/$ifc.absent" ]; then rm -f "$IFACE_DIR/$ifc.cfg" fi rm -f "$BK_DIR/$ifc.bak" "$BK_DIR/$ifc.absent" ( ifdown "$ifc" 2>/dev/null; ifup "$ifc" 2>/dev/null ) || systemctl restart networking 2>/dev/null || true ;; *) echo "usage: hapx-ui-netcfg {status | ipv6 on|off | dns | flushdns |" >&2 echo " iface-apply ... | iface-confirm | iface-pending |" >&2 echo " timezone | tune status|apply}" >&2 exit 2 ;; esac