#!/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 # 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) 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 ;; 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 exit 2 ;; esac