🎈 Sync 2026-09-09 01:06:03

This commit is contained in:
github-actions[bot] committed 2026-09-09 01:06:03 +08:00
1 parent 6701c0a72f
commit 6dadf1e827
35 files changed
+5887

No files matched your search

@@ -0,0 +1,180 @@
#!/bin/sh
set -eu
. /usr/libexec/haproxy-manager/common.sh
umask 077
LOCK_DIR=/var/lock/haproxy-manager.apply
LOCK_BUSY_EXIT=75
TMP_CFG="/tmp/haproxy-manager-apply.$$"
BACKUP_ID=""
APPLIED=0
UHTTPD_CHANGED=0
if [ "${1:-}" = --backup ]; then
BACKUP_ID="${2:-}"
[ "$#" -eq 2 ] || { echo "Usage: apply [--backup YYYYMMDD-HHMMSS]" >&2; exit 1; }
is_valid_backup_id "$BACKUP_ID" || { echo "Invalid backup identifier" >&2; exit 1; }
elif [ "$#" -ne 0 ]; then
echo "Usage: apply [--backup YYYYMMDD-HHMMSS]" >&2
exit 1
fi
operation_lock_acquire "$LOCK_DIR" || {
echo "Another HAProxy Manager operation is already running." >&2
exit "$LOCK_BUSY_EXIT"
}
cleanup_temp() {
rm -f "$TMP_CFG"
}
release_lock() {
operation_lock_release "$LOCK_DIR"
}
cleanup() {
cleanup_temp
release_lock
}
restore_on_error() {
status=$?
trap - EXIT
if [ "$APPLIED" != 1 ] && [ -n "$BACKUP_ID" ]; then
if [ "$UHTTPD_CHANGED" = 1 ]; then
cleanup_temp
(
sleep 2
if /usr/libexec/haproxy-manager/rollback --sync "$BACKUP_ID" >/dev/null 2>&1; then
logger -t haproxy-manager "Apply failed; restored $BACKUP_ID"
else
logger -t haproxy-manager "Apply failed; could not restore $BACKUP_ID"
fi
release_lock
) >/dev/null 2>&1 &
operation_lock_set_pid "$LOCK_DIR" "$!"
exit "$status"
fi
if /usr/libexec/haproxy-manager/rollback --sync "$BACKUP_ID" >/dev/null 2>&1; then
logger -t haproxy-manager "Apply failed; restored $BACKUP_ID"
else
logger -t haproxy-manager "Apply failed; could not restore $BACKUP_ID"
fi
fi
cleanup
exit "$status"
}
trap restore_on_error EXIT
if [ -n "$BACKUP_ID" ]; then
[ -d "$(backup_dir)/$BACKUP_ID" ] || { echo "Backup not found: $BACKUP_ID" >&2; exit 1; }
else
BACKUP_ID="$(basename "$(/usr/libexec/haproxy-manager/backup)")"
fi
/usr/libexec/haproxy-manager/migrate >/dev/null
defer_disabled_reload() {
APPLIED=1
trap - EXIT
cleanup_temp
(
sleep 2
if /etc/init.d/uhttpd reload >/dev/null 2>&1; then
logger -t haproxy-manager "LuCI listener bindings restored"
else
if /usr/libexec/haproxy-manager/rollback --sync "$BACKUP_ID" >/dev/null 2>&1; then
logger -t haproxy-manager "LuCI reload failed; restored $BACKUP_ID"
else
logger -t haproxy-manager "LuCI reload failed; could not restore $BACKUP_ID"
fi
fi
release_lock
) >/dev/null 2>&1 &
operation_lock_set_pid "$LOCK_DIR" "$!"
}
if [ "$(uci_get main enabled 0)" != 1 ]; then
MODE="$(active_mode)"
restore_uhttpd_bindings
[ "$UHTTPD_CHANGED" != 1 ] || uci commit uhttpd
/usr/libexec/haproxy-manager/firewall-sync --disable >/dev/null
case "$MODE" in
generated|raw)
/etc/init.d/haproxy stop >/dev/null 2>&1 || true
/etc/init.d/haproxy disable >/dev/null 2>&1 || true
uci set "$CONFIG.main.active_mode=none"
uci commit "$CONFIG"
;;
esac
if [ "$UHTTPD_CHANGED" = 1 ]; then
defer_disabled_reload
echo "Managed HAProxy service disabled. LuCI bindings will be restored shortly. Backup: $BACKUP_ID"
exit 0
fi
APPLIED=1
trap - EXIT
cleanup
echo "Managed HAProxy service disabled. Backup: $BACKUP_ID"
exit 0
fi
/usr/libexec/haproxy-manager/generate "$TMP_CFG" >/dev/null
/usr/libexec/haproxy-manager/validate "$TMP_CFG"
if [ "$(uci_get main manage_firewall 0)" = 1 ]; then
/usr/libexec/haproxy-manager/firewall-sync --check >/dev/null
fi
MANAGE_UHTTPD="$(uci_get main manage_uhttpd_bind 0)"
LAN_BIND_IP="$(lan_ip || true)"
if [ "$MANAGE_UHTTPD" = 1 ]; then
[ -n "$LAN_BIND_IP" ] || { echo "Cannot detect LAN IPv4 address. Set the LuCI LAN address manually." >&2; exit 1; }
is_valid_ip_or_host "$LAN_BIND_IP" || { echo "Invalid LuCI LAN address: $LAN_BIND_IP" >&2; exit 1; }
capture_uhttpd_bindings
set_uhttpd_list listen_http "$LAN_BIND_IP:80"
set_uhttpd_list listen_https "$LAN_BIND_IP:443"
else
restore_uhttpd_bindings
fi
[ "$UHTTPD_CHANGED" != 1 ] || uci commit uhttpd
/usr/libexec/haproxy-manager/firewall-sync >/dev/null
atomic_install_file "$TMP_CFG" "$HAPROXY_CFG"
uci set "$CONFIG.main.active_mode=generated"
uci commit "$CONFIG"
/etc/init.d/haproxy enable >/dev/null 2>&1 || true
if [ "$UHTTPD_CHANGED" = 1 ]; then
APPLIED=1
trap - EXIT
cleanup_temp
(
sleep 2
if /etc/init.d/uhttpd reload >/dev/null 2>&1 && /etc/init.d/haproxy restart >/dev/null 2>&1; then
logger -t haproxy-manager "LuCI and HAProxy reloaded successfully"
else
if /usr/libexec/haproxy-manager/rollback --sync "$BACKUP_ID" >/dev/null 2>&1; then
logger -t haproxy-manager "Service reload failed; restored $BACKUP_ID"
else
logger -t haproxy-manager "Service reload failed; could not restore $BACKUP_ID"
fi
fi
release_lock
) >/dev/null 2>&1 &
operation_lock_set_pid "$LOCK_DIR" "$!"
echo "Applied successfully. LuCI and HAProxy will reload shortly. Backup: $BACKUP_ID"
exit 0
fi
/etc/init.d/haproxy restart
APPLIED=1
trap - EXIT
cleanup
echo "Applied successfully. Backup: $BACKUP_ID"
@@ -0,0 +1,45 @@
#!/bin/sh
set -eu
. /usr/libexec/haproxy-manager/common.sh
umask 077
SRC="${1:-}"
is_safe_temp_path "$SRC" || { echo "Refusing unsafe raw config path: $SRC" >&2; exit 1; }
[ -f "$SRC" ] || { echo "Raw config file not found: $SRC" >&2; exit 1; }
LOCK_DIR=/var/lock/haproxy-manager.apply
operation_lock_acquire "$LOCK_DIR" || { echo "Another HAProxy Manager operation is already running." >&2; exit 1; }
TMP_RAW="/tmp/haproxy-manager-raw-apply.$$"
BACKUP_ID=""
APPLIED=0
restore_on_error() {
status=$?
trap - EXIT
if [ "$APPLIED" != 1 ] && [ -n "$BACKUP_ID" ]; then
/usr/libexec/haproxy-manager/rollback --sync "$BACKUP_ID" >/dev/null 2>&1 || true
logger -t haproxy-manager "Raw apply failed; restored $BACKUP_ID"
fi
rm -f "$TMP_RAW" "$SRC"
operation_lock_release "$LOCK_DIR"
exit "$status"
}
trap restore_on_error EXIT
cp "$SRC" "$TMP_RAW"
rm -f "$SRC"
/usr/libexec/haproxy-manager/validate "$TMP_RAW"
BACKUP_ID="$(basename "$(/usr/libexec/haproxy-manager/backup)")"
atomic_install_file "$TMP_RAW" "$HAPROXY_CFG"
uci set "$CONFIG.main.active_mode=raw"
uci commit "$CONFIG"
/etc/init.d/haproxy enable >/dev/null 2>&1 || true
/etc/init.d/haproxy restart
APPLIED=1
trap - EXIT
rm -f "$TMP_RAW" "$SRC"
operation_lock_release "$LOCK_DIR"
echo "Raw HAProxy config applied. Backup: $BACKUP_ID"
@@ -0,0 +1,59 @@
#!/bin/sh
set -eu
. /usr/libexec/haproxy-manager/common.sh
REQUESTED_BASE="$(backup_dir)"
STAMP="$(date +%Y%m%d-%H%M%S)"
umask 077
mkdir -p "$REQUESTED_BASE"
BASE="$(resolve_storage_dir "$REQUESTED_BASE")" || {
echo "Recovery point directory resolves outside /root or /mnt" >&2
exit 1
}
chmod 700 "$BASE" 2>/dev/null || true
DIR="$BASE/$STAMP"
attempt=1
while ! mkdir "$DIR" 2>/dev/null; do
[ "$attempt" -lt 5 ] || {
echo "Cannot create recovery point in $BASE after $attempt attempts" >&2
exit 1
}
sleep 1
attempt=$((attempt + 1))
STAMP="$(date +%Y%m%d-%H%M%S)"
DIR="$BASE/$STAMP"
done
chmod 700 "$DIR" 2>/dev/null || true
[ -f /etc/config/firewall ] && cp /etc/config/firewall "$DIR/firewall"
[ -f /etc/config/uhttpd ] && cp /etc/config/uhttpd "$DIR/uhttpd"
[ -f /etc/config/haproxy_manager ] && cp /etc/config/haproxy_manager "$DIR/haproxy_manager"
[ -f "$HAPROXY_CFG" ] && cp "$HAPROXY_CFG" "$DIR/haproxy.cfg"
{
if /etc/init.d/haproxy enabled >/dev/null 2>&1; then
printf 'enabled=1\n'
else
printf 'enabled=0\n'
fi
if /etc/init.d/haproxy status >/dev/null 2>&1; then
printf 'running=1\n'
else
printf 'running=0\n'
fi
} > "$DIR/haproxy.state"
printf '%s\n' "$STAMP" > "$BASE/LAST"
count=0
for old in $(find "$BASE" -maxdepth 1 -type d -name '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' 2>/dev/null | sort -r); do
count=$((count + 1))
[ "$count" -le "$BACKUP_LIMIT" ] && continue
case "$old" in
"$BASE"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
rm -f "$old"/*
rmdir "$old"
;;
esac
done
printf '%s\n' "$DIR"
@@ -0,0 +1,22 @@
#!/bin/sh
. /usr/libexec/haproxy-manager/common.sh
BASE="$(backup_dir)"
[ ! -d "$BASE" ] || BASE="$(resolve_storage_dir "$BASE")" || exit 1
LAST=""
[ ! -r "$BASE/LAST" ] || LAST="$(basename "$(cat "$BASE/LAST")")"
count=0
for dir in $(find "$BASE" -maxdepth 1 -type d -name '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' 2>/dev/null | sort -r); do
[ -d "$dir" ] || continue
id="$(basename "$dir")"
current=0
[ "$id" != "$LAST" ] || current=1
files=0
for file in firewall uhttpd haproxy_manager haproxy.cfg; do
[ ! -f "$dir/$file" ] || files=$((files + 1))
done
printf 'backup\t%s\t%s\t%s\n' "$id" "$current" "$files"
count=$((count + 1))
[ "$count" -lt "$BACKUP_LIMIT" ] || break
done
@@ -0,0 +1,361 @@
#!/bin/sh
CONFIG=haproxy_manager
HAPROXY_CFG=/etc/haproxy.cfg
TMP_CFG=/tmp/haproxy-manager-generated.cfg
BACKUP_LIMIT=7
INCIDENT_LIMIT=7
FIREWALL_RULE_NAME='HAProxy Manager: WAN listeners'
FIREWALL_DISABLED_PREFIX='HAProxy Manager disabled: '
uci_get() {
local section="$1"
local option="$2"
local default="${3:-}"
uci -q get "$CONFIG.$section.$option" 2>/dev/null || printf '%s\n' "$default"
}
backup_dir() {
local dir
dir="$(uci_get main backup_dir /root/haproxy-manager-backups)"
is_safe_storage_dir "$dir" || {
echo "Recovery point directory must be a plain path under /root or /mnt: $dir" >&2
return 1
}
printf '%s\n' "$dir"
}
incident_dir() {
local dir
dir="$(uci_get main incident_dir /root/haproxy-manager-incidents)"
is_safe_storage_dir "$dir" || {
echo "Incident directory must be a plain path under /root or /mnt: $dir" >&2
return 1
}
printf '%s\n' "$dir"
}
is_safe_storage_dir() {
case "$1" in
/root/*|/mnt/*) ;;
*) return 1 ;;
esac
case "/${1#/}/" in
*/../*|*/./*|*[!A-Za-z0-9_./-]*) return 1 ;;
esac
return 0
}
resolve_storage_dir() {
local dir="$1" resolved
[ -d "$dir" ] || return 1
resolved="$(cd "$dir" 2>/dev/null && pwd -P)" || return 1
is_safe_storage_dir "$resolved" || return 1
printf '%s\n' "$resolved"
}
is_safe_temp_path() {
local path="$1" name
name="${path#/tmp/}"
[ "$path" = "/tmp/$name" ] || return 1
case "$name" in
haproxy-manager-[A-Za-z0-9._-]*) return 0 ;;
*) return 1 ;;
esac
}
operation_lock_acquire() {
local lock_dir="$1" lock_pid
if mkdir "$lock_dir" 2>/dev/null; then
printf '%s\n' "$$" > "$lock_dir/pid"
return 0
fi
lock_pid="$(cat "$lock_dir/pid" 2>/dev/null || true)"
case "$lock_pid" in
''|*[!0-9]*) lock_pid=0 ;;
esac
if [ "$lock_pid" -gt 1 ] 2>/dev/null && kill -0 "$lock_pid" 2>/dev/null; then
return 1
fi
rm -f "$lock_dir/pid"
rmdir "$lock_dir" 2>/dev/null || return 1
mkdir "$lock_dir" 2>/dev/null || return 1
printf '%s\n' "$$" > "$lock_dir/pid"
}
operation_lock_set_pid() {
printf '%s\n' "$2" > "$1/pid"
}
operation_lock_release() {
rm -f "$1/pid"
rmdir "$1" 2>/dev/null || true
}
atomic_install_file() {
local source="$1" destination="$2" temporary mode
[ -f "$source" ] || return 1
temporary="$destination.haproxy-manager.$$"
rm -f "$temporary"
if ! cp "$source" "$temporary"; then
rm -f "$temporary"
return 1
fi
mode="$(stat -c '%a' "$destination" 2>/dev/null || stat -c '%a' "$source" 2>/dev/null || echo 600)"
chmod "$mode" "$temporary" || { rm -f "$temporary"; return 1; }
mv -f "$temporary" "$destination" || { rm -f "$temporary"; return 1; }
}
active_mode() {
local mode
mode="$(uci_get main active_mode)"
case "$mode" in
generated|raw|none) printf '%s\n' "$mode" ;;
*)
if [ "$(uci_get main enabled 0)" = 1 ] &&
grep -q '^# Generated by luci-app-haproxy-manager\.' "$HAPROXY_CFG" 2>/dev/null; then
printf 'generated\n'
else
printf 'none\n'
fi
;;
esac
}
wan_interface() {
local interface
interface="$(uci_get main wan_interface wan)"
case "$interface" in
''|*[!A-Za-z0-9_.-]*) return 1 ;;
esac
printf '%s\n' "$interface"
}
wan_ip() {
local configured interface
configured="$(uci_get main wan_bind_ip auto)"
if [ "$configured" != auto ] && [ -n "$configured" ]; then
printf '%s\n' "$configured"
return 0
fi
interface="$(wan_interface)" || return 1
ubus call "network.interface.$interface" status 2>/dev/null \
| jsonfilter -e '@["ipv4-address"][0].address' 2>/dev/null \
| grep -m1 .
}
lan_ip() {
local configured
configured="$(uci_get main lan_bind_ip auto)"
if [ "$configured" != auto ] && [ -n "$configured" ]; then
printf '%s\n' "$configured"
return 0
fi
ubus call network.interface.lan status 2>/dev/null \
| jsonfilter -e '@["ipv4-address"][0].address' 2>/dev/null \
| grep -m1 .
}
route_name() {
printf '%s' "$1" | tr '.:-' '___' | tr -cd 'A-Za-z0-9_'
}
is_valid_host() {
case "$1" in
''|[!A-Za-z0-9]*|*[!A-Za-z0-9]) return 1 ;;
*[!A-Za-z0-9.-]*) return 1 ;;
esac
return 0
}
is_valid_ip_or_host() {
case "$1" in
''|*[!A-Za-z0-9_.:-]*) return 1 ;;
esac
return 0
}
is_valid_backup_id() {
case "$1" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) return 0 ;;
*) return 1 ;;
esac
}
backend_address() {
local host="$1"
local port="$2"
case "$host" in
*:*) printf '[%s]:%s\n' "$host" "$port" ;;
*) printf '%s:%s\n' "$host" "$port" ;;
esac
}
is_valid_port() {
local port="$1"
[ "$port" -ge 1 ] 2>/dev/null && [ "$port" -le 65535 ] 2>/dev/null
}
route_sections() {
uci -q show "$CONFIG" \
| sed -n "s/^$CONFIG\\.\\(@route\\[[0-9][0-9]*\\]\\)=route/\\1/p"
}
route_kind() {
local section="$1"
local kind
kind="$(uci_get "$section" kind)"
[ -n "$kind" ] || kind="$(uci_get "$section" protocol http)"
printf '%s\n' "$kind"
}
route_port_maps() {
local section="$1"
local kind="$2"
local public_port backend_port
case "$kind" in
ssh)
public_port="$(uci_get "$section" ssh_listen_port 22)"
backend_port="$(uci_get "$section" ssh_backend_port 22)"
printf '%s:%s\n' "$public_port" "$backend_port"
;;
rdp)
public_port="$(uci_get "$section" rdp_listen_port 3389)"
backend_port="$(uci_get "$section" rdp_backend_port 3389)"
printf '%s:%s\n' "$public_port" "$backend_port"
;;
custom)
for map in $(uci_get "$section" port_map); do
printf '%s\n' "$map"
done
;;
tcp)
public_port="$(uci_get "$section" listen_port "$(uci_get "$section" backend_port)")"
backend_port="$(uci_get "$section" backend_port)"
printf '%s:%s\n' "$public_port" "$backend_port"
;;
esac
}
is_valid_port_map() {
local map="$1"
local public_port backend_port
case "$map" in
*:*:*) return 1 ;;
*:*) ;;
*) return 1 ;;
esac
public_port="${map%%:*}"
backend_port="${map#*:}"
is_valid_port "$public_port" && is_valid_port "$backend_port"
}
required_ports() {
local section enabled kind http_enabled https_enabled map
local http_port https_port
http_port="$(uci_get main http_port 80)"
https_port="$(uci_get main https_port 443)"
[ "$(uci_get main enabled 0)" = 1 ] || return 0
for section in $(route_sections); do
enabled="$(uci_get "$section" enabled 1)"
[ "$enabled" = 1 ] || continue
kind="$(route_kind "$section")"
case "$kind" in
web)
http_enabled="$(uci_get "$section" web_http 1)"
https_enabled="$(uci_get "$section" web_https 1)"
[ "$http_enabled" != 1 ] || printf '%s\n' "$http_port"
[ "$https_enabled" != 1 ] || printf '%s\n' "$https_port"
;;
http)
printf '%s\n' "$http_port"
;;
https)
printf '%s\n' "$https_port"
;;
both)
printf '%s\n%s\n' "$http_port" "$https_port"
;;
ssh|rdp|custom|tcp)
for map in $(route_port_maps "$section" "$kind"); do
printf '%s\n' "${map%%:*}"
done
;;
esac
done | sort -nu
}
port_spec_contains() {
local spec="$1"
local wanted="$2"
local token first last
for token in $(printf '%s' "$spec" | tr ',' ' '); do
case "$token" in
*-*)
first="${token%%-*}"
last="${token#*-}"
[ "$wanted" -ge "$first" ] 2>/dev/null && [ "$wanted" -le "$last" ] 2>/dev/null && return 0
;;
*)
[ "$token" = "$wanted" ] && return 0
;;
esac
done
return 1
}
set_uhttpd_list() {
local option values current address
option="$1"
values="$2"
current="$(uci -q get "uhttpd.main.$option" 2>/dev/null || true)"
[ "$current" = "$values" ] && return 0
uci -q delete "uhttpd.main.$option" || true
for address in $values; do
uci add_list "uhttpd.main.$option=$address"
done
UHTTPD_CHANGED=1
}
capture_uhttpd_bindings() {
local saved_http saved_https address
[ "$(uci_get main uhttpd_bind_owned 0)" != 1 ] || return 0
saved_http="$(uci -q get uhttpd.main.listen_http 2>/dev/null || true)"
saved_https="$(uci -q get uhttpd.main.listen_https 2>/dev/null || true)"
uci -q delete "$CONFIG.main.uhttpd_saved_listen_http" || true
uci -q delete "$CONFIG.main.uhttpd_saved_listen_https" || true
for address in $saved_http; do
uci add_list "$CONFIG.main.uhttpd_saved_listen_http=$address"
done
for address in $saved_https; do
uci add_list "$CONFIG.main.uhttpd_saved_listen_https=$address"
done
uci set "$CONFIG.main.uhttpd_bind_owned=1"
uci commit "$CONFIG"
}
restore_uhttpd_bindings() {
[ "$(uci_get main uhttpd_bind_owned 0)" = 1 ] || return 0
set_uhttpd_list listen_http "$(uci_get main uhttpd_saved_listen_http)"
set_uhttpd_list listen_https "$(uci_get main uhttpd_saved_listen_https)"
uci -q delete "$CONFIG.main.uhttpd_saved_listen_http" || true
uci -q delete "$CONFIG.main.uhttpd_saved_listen_https" || true
uci -q delete "$CONFIG.main.uhttpd_bind_owned" || true
uci commit "$CONFIG"
}
@@ -0,0 +1,65 @@
#!/bin/sh
. /usr/libexec/haproxy-manager/common.sh
ZONE="$(uci_get main firewall_zone wan)"
MANAGED="$(uci_get main manage_firewall 0)"
MODE="$(uci_get main firewall_conflict_mode warn)"
PORTS="$(required_ports)"
WAN_POLICY=unknown
HAS_MANAGED_RULE=0
CONFLICTS=0
for zone in $(uci -q show firewall | sed -n 's/^firewall\.\(@zone\[[0-9][0-9]*\]\)=zone/\1/p'); do
[ "$(uci -q get "firewall.$zone.name")" = "$ZONE" ] || continue
WAN_POLICY="$(uci -q get "firewall.$zone.input" 2>/dev/null || echo REJECT)"
break
done
for rule in $(uci -q show firewall | sed -n 's/^firewall\.\(@rule\[[0-9][0-9]*\]\)=rule/\1/p'); do
name="$(uci -q get "firewall.$rule.name" 2>/dev/null || true)"
[ "$name" = "$FIREWALL_RULE_NAME" ] || continue
HAS_MANAGED_RULE=1
break
done
printf 'enabled\t%s\n' "$MANAGED"
printf 'zone\t%s\n' "$ZONE"
printf 'policy\t%s\n' "$WAN_POLICY"
printf 'mode\t%s\n' "$MODE"
printf 'managed_rule\t%s\n' "$HAS_MANAGED_RULE"
for port in $PORTS; do
printf 'port\t%s\n' "$port"
done
for redirect in $(uci -q show firewall | sed -n 's/^firewall\.\(@redirect\[[0-9][0-9]*\]\)=redirect/\1/p'); do
redirect_enabled="$(uci -q get "firewall.$redirect.enabled" 2>/dev/null || echo 1)"
redirect_name="$(uci -q get "firewall.$redirect.name" 2>/dev/null || true)"
if [ "$redirect_enabled" != 1 ]; then
case "$redirect_name" in
"$FIREWALL_DISABLED_PREFIX"*)
[ "$MANAGED" = 1 ] && [ "$MODE" != disable ] || continue
;;
*) continue ;;
esac
fi
[ "$(uci -q get "firewall.$redirect.src" 2>/dev/null || true)" = "$ZONE" ] || continue
proto="$(uci -q get "firewall.$redirect.proto" 2>/dev/null || echo 'tcp udp')"
case " $proto " in *' tcp '*|*' tcpudp '*) ;; *) continue ;; esac
spec="$(uci -q get "firewall.$redirect.src_dport" 2>/dev/null || true)"
[ -n "$spec" ] || continue
for port in $PORTS; do
port_spec_contains "$spec" "$port" || continue
name="${redirect_name:-$redirect}"
dest_ip="$(uci -q get "firewall.$redirect.dest_ip" 2>/dev/null || true)"
dest_port="$(uci -q get "firewall.$redirect.dest_port" 2>/dev/null || true)"
printf 'conflict\t%s\t%s\t%s\t%s\t%s\n' "$redirect" "$name" "$spec" "$dest_ip" "$dest_port"
CONFLICTS=$((CONFLICTS + 1))
break
done
done
printf 'conflicts\t%s\n' "$CONFLICTS"
@@ -0,0 +1,106 @@
#!/bin/sh
set -eu
. /usr/libexec/haproxy-manager/common.sh
umask 077
CHECK_ONLY=0
FORCE_DISABLED=0
case "${1:-}" in
'') ;;
--check) CHECK_ONLY=1 ;;
--disable) FORCE_DISABLED=1 ;;
*) echo "Usage: firewall-sync [--check|--disable]" >&2; exit 1 ;;
esac
ZONE="$(uci_get main firewall_zone wan)"
MODE="$(uci_get main firewall_conflict_mode warn)"
MANAGED="$(uci_get main manage_firewall 0)"
[ "$FORCE_DISABLED" != 1 ] || MANAGED=0
PLAN=/tmp/haproxy-manager-firewall-plan.$$
/usr/libexec/haproxy-manager/firewall-plan > "$PLAN"
trap 'rm -f "$PLAN"' EXIT
CONFLICTS="$(sed -n 's/^conflicts[[:space:]]*//p' "$PLAN")"
if [ "$MANAGED" = 1 ] && [ "${CONFLICTS:-0}" -gt 0 ] && [ "$MODE" != disable ]; then
echo "Enabled firewall port forwards conflict with HAProxy listeners:" >&2
awk -F '\t' '$1 == "conflict" { printf "- %s (WAN %s -> %s:%s)\n", $3, $4, $5, $6 }' "$PLAN" >&2
echo "Choose automatic conflict resolution in Settings or disable the redirects manually." >&2
exit 3
fi
[ "$CHECK_ONLY" != 1 ] || { cat "$PLAN"; exit 0; }
PORTS=""
[ "$MANAGED" != 1 ] || PORTS="$(sed -n 's/^port[[:space:]]*//p' "$PLAN")"
CHANGED=0
for redirect in $(uci -q show firewall | sed -n 's/^firewall\.\(@redirect\[[0-9][0-9]*\]\)=redirect/\1/p'); do
name="$(uci -q get "firewall.$redirect.name" 2>/dev/null || true)"
case "$name" in
"$FIREWALL_DISABLED_PREFIX"*) managed_disabled=1 ;;
*) managed_disabled=0 ;;
esac
[ "$managed_disabled" = 1 ] || continue
spec="$(uci -q get "firewall.$redirect.src_dport" 2>/dev/null || true)"
still_needed=0
for port in $PORTS; do
port_spec_contains "$spec" "$port" || continue
still_needed=1
break
done
[ "$still_needed" = 1 ] && continue
uci set "firewall.$redirect.enabled=1"
CHANGED=1
name="${name#"$FIREWALL_DISABLED_PREFIX"}"
if [ -n "$name" ]; then
uci set "firewall.$redirect.name=$name"
else
uci -q delete "firewall.$redirect.name" || true
fi
done
if [ "$MANAGED" = 1 ] && [ "$MODE" = disable ]; then
for redirect in $(awk -F '\t' '$1 == "conflict" { print $2 }' "$PLAN"); do
[ -n "$redirect" ] || continue
name="$(uci -q get "firewall.$redirect.name" 2>/dev/null || true)"
case "$name" in
"$FIREWALL_DISABLED_PREFIX"*) ;;
*) uci set "firewall.$redirect.name=$FIREWALL_DISABLED_PREFIX$name" ;;
esac
uci set "firewall.$redirect.enabled=0"
CHANGED=1
done
fi
for index in $(uci -q show firewall | sed -n 's/^firewall\.@rule\[\([0-9][0-9]*\)\]=rule/\1/p' | sort -rn); do
rule="@rule[$index]"
name="$(uci -q get "firewall.$rule.name" 2>/dev/null || true)"
if [ "$name" = "$FIREWALL_RULE_NAME" ]; then
uci -q delete "firewall.$rule" || true
CHANGED=1
fi
done
if [ -n "$PORTS" ]; then
rule="$(uci add firewall rule)"
uci set "firewall.$rule.name=$FIREWALL_RULE_NAME"
uci set "firewall.$rule.src=$ZONE"
uci set "firewall.$rule.proto=tcp"
uci set "firewall.$rule.target=ACCEPT"
uci set "firewall.$rule.family=ipv4"
for port in $PORTS; do
uci add_list "firewall.$rule.dest_port=$port"
done
CHANGED=1
fi
if [ "$CHANGED" = 1 ]; then
uci commit firewall
/etc/init.d/firewall reload
fi
if [ "$MANAGED" = 1 ]; then
echo "Firewall rules synchronized"
else
echo "Firewall automation disabled; managed rules removed"
fi
@@ -0,0 +1,234 @@
#!/bin/sh
set -eu
. /usr/libexec/haproxy-manager/common.sh
umask 077
OUT="${1:-$TMP_CFG}"
is_safe_temp_path "$OUT" || { echo "Refusing unsafe generated config path: $OUT" >&2; exit 1; }
[ ! -d "$OUT" ] || { echo "Refusing generated config directory: $OUT" >&2; exit 1; }
WORK_OUT="$OUT.new.$$"
ENABLED="$(uci_get main enabled 0)"
HTTP_PORT="$(uci_get main http_port 80)"
HTTPS_PORT="$(uci_get main https_port 443)"
WAN_IP="$(wan_ip || true)"
if [ "$ENABLED" != 1 ]; then
echo "Managed HAProxy configuration is disabled." >&2
exit 2
fi
is_valid_port "$HTTP_PORT" || { echo "Invalid public HTTP port: $HTTP_PORT" >&2; exit 1; }
is_valid_port "$HTTPS_PORT" || { echo "Invalid public HTTPS port: $HTTPS_PORT" >&2; exit 1; }
if [ -z "$WAN_IP" ]; then
echo "Cannot detect WAN IPv4 address. Set the WAN bind address manually." >&2
exit 1
fi
is_valid_ip_or_host "$WAN_IP" || { echo "Invalid WAN bind address: $WAN_IP" >&2; exit 1; }
TMP_HTTP_RULES=/tmp/haproxy-manager-http-rules.$$
TMP_HTTP_BACKENDS=/tmp/haproxy-manager-http-backends.$$
TMP_TLS_RULES=/tmp/haproxy-manager-tls-rules.$$
TMP_TLS_BACKENDS=/tmp/haproxy-manager-tls-backends.$$
TMP_TCP_FRONTENDS=/tmp/haproxy-manager-tcp-frontends.$$
TMP_PORTS=/tmp/haproxy-manager-listen-ports.$$
: > "$TMP_HTTP_RULES"
: > "$TMP_HTTP_BACKENDS"
: > "$TMP_TLS_RULES"
: > "$TMP_TLS_BACKENDS"
: > "$TMP_TCP_FRONTENDS"
: > "$TMP_PORTS"
trap 'rm -f "$WORK_OUT" "$TMP_HTTP_RULES" "$TMP_HTTP_BACKENDS" "$TMP_TLS_RULES" "$TMP_TLS_BACKENDS" "$TMP_TCP_FRONTENDS" "$TMP_PORTS"' EXIT
HAS_HTTP=0
HAS_HTTPS=0
HAS_TCP=0
claim_port() {
local port="$1"
local owner="$2"
local existing
if grep -q "^${port}[[:space:]]" "$TMP_PORTS"; then
existing="$(sed -n "s/^${port}[[:space:]]*//p" "$TMP_PORTS" | head -1)"
echo "Public TCP port $port conflicts with ${existing:-another service}." >&2
exit 1
fi
printf '%s\t%s\n' "$port" "$owner" >> "$TMP_PORTS"
}
add_http_route() {
local name="$1" host="$2" backend_host="$3" backend_port="$4"
local address
address="$(backend_address "$backend_host" "$backend_port")"
if [ "$HAS_HTTP" != 1 ]; then
claim_port "$HTTP_PORT" "the shared Web HTTP listener"
HAS_HTTP=1
fi
{
printf ' acl host_%s hdr(host),host_only -i %s\n' "$name" "$host"
printf ' use_backend be_http_%s if host_%s\n' "$name" "$name"
} >> "$TMP_HTTP_RULES"
{
printf 'backend be_http_%s\n' "$name"
printf ' mode http\n'
printf ' option httpchk GET /\n'
printf ' server srv1 %s check\n\n' "$address"
} >> "$TMP_HTTP_BACKENDS"
}
add_https_route() {
local name="$1" host="$2" backend_host="$3" backend_port="$4"
local address
address="$(backend_address "$backend_host" "$backend_port")"
if [ "$HAS_HTTPS" != 1 ]; then
claim_port "$HTTPS_PORT" "the shared Web HTTPS listener"
HAS_HTTPS=1
fi
printf ' use_backend be_tls_%s if { req.ssl_sni -i %s }\n' "$name" "$host" >> "$TMP_TLS_RULES"
{
printf 'backend be_tls_%s\n' "$name"
printf ' mode tcp\n'
printf ' server srv1 %s check\n\n' "$address"
} >> "$TMP_TLS_BACKENDS"
}
add_tcp_route() {
local name="$1" label="$2" backend_host="$3" public_port="$4" backend_port="$5"
local address bind_address
address="$(backend_address "$backend_host" "$backend_port")"
bind_address="$(backend_address "$WAN_IP" "$public_port")"
claim_port "$public_port" "service $label"
HAS_TCP=1
{
printf 'frontend fe_tcp_%s\n' "$name"
printf ' bind %s\n' "$bind_address"
printf ' mode tcp\n'
printf ' default_backend be_tcp_%s\n\n' "$name"
printf 'backend be_tcp_%s\n' "$name"
printf ' mode tcp\n'
printf ' server srv1 %s check\n\n' "$address"
} >> "$TMP_TCP_FRONTENDS"
}
for section in $(route_sections); do
enabled="$(uci_get "$section" enabled 1)"
[ "$enabled" = 1 ] || continue
kind="$(route_kind "$section")"
label="$(uci_get "$section" name)"
host="$(uci_get "$section" host)"
backend_host="$(uci_get "$section" backend_host)"
[ -n "$label" ] || label="$host"
[ -n "$label" ] || label="$kind"
name="$(route_name "${label}_${section}")"
is_valid_ip_or_host "$backend_host" || { echo "Invalid backend host for $label: $backend_host" >&2; exit 1; }
case "$kind" in
web)
is_valid_host "$host" || { echo "Invalid or missing domain for Web service $label: $host" >&2; exit 1; }
http_enabled="$(uci_get "$section" web_http 1)"
https_enabled="$(uci_get "$section" web_https 1)"
[ "$http_enabled" = 1 ] || [ "$https_enabled" = 1 ] || { echo "Web service $label has no enabled protocol." >&2; exit 1; }
if [ "$http_enabled" = 1 ]; then
backend_port="$(uci_get "$section" backend_http_port 80)"
is_valid_port "$backend_port" || { echo "Invalid HTTP backend port for $label: $backend_port" >&2; exit 1; }
add_http_route "$name" "$host" "$backend_host" "$backend_port"
fi
if [ "$https_enabled" = 1 ]; then
backend_port="$(uci_get "$section" backend_https_port 443)"
is_valid_port "$backend_port" || { echo "Invalid HTTPS backend port for $label: $backend_port" >&2; exit 1; }
add_https_route "$name" "$host" "$backend_host" "$backend_port"
fi
;;
http|https|both)
is_valid_host "$host" || { echo "Invalid or missing domain for legacy service $label: $host" >&2; exit 1; }
backend_port="$(uci_get "$section" backend_port)"
is_valid_port "$backend_port" || { echo "Invalid backend port for $label: $backend_port" >&2; exit 1; }
case "$kind" in http|both) add_http_route "$name" "$host" "$backend_host" "$backend_port" ;; esac
case "$kind" in https|both) add_https_route "$name" "$host" "$backend_host" "$backend_port" ;; esac
;;
ssh|rdp|custom|tcp)
map_index=0
maps="$(route_port_maps "$section" "$kind")"
[ -n "$maps" ] || { echo "No TCP port mappings configured for $label." >&2; exit 1; }
for map in $maps; do
is_valid_port_map "$map" || { echo "Invalid TCP port mapping for $label: $map" >&2; exit 1; }
public_port="${map%%:*}"
backend_port="${map#*:}"
map_index=$((map_index + 1))
add_tcp_route "${name}_${map_index}" "$label" "$backend_host" "$public_port" "$backend_port"
done
;;
*)
echo "Invalid service type for $label: $kind" >&2
exit 1
;;
esac
done
if [ "$HAS_HTTP" != 1 ] && [ "$HAS_HTTPS" != 1 ] && [ "$HAS_TCP" != 1 ]; then
echo "No enabled services define a HAProxy listener." >&2
exit 2
fi
{
printf '# Generated by luci-app-haproxy-manager. Manual changes may be overwritten.\n'
printf 'global\n'
printf ' log /dev/log local0\n'
printf ' maxconn 2048\n'
printf ' daemon\n\n'
printf 'defaults\n'
printf ' log global\n'
printf ' timeout connect 5s\n'
printf ' timeout client 60s\n'
printf ' timeout server 60s\n'
printf ' option dontlognull\n\n'
if [ "$HAS_HTTP" = 1 ]; then
http_bind="$(backend_address "$WAN_IP" "$HTTP_PORT")"
printf 'frontend fe_web_http\n'
printf ' bind %s\n' "$http_bind"
printf ' mode http\n'
printf ' option httplog\n'
cat "$TMP_HTTP_RULES"
printf ' default_backend be_not_found\n\n'
cat "$TMP_HTTP_BACKENDS"
fi
if [ "$HAS_HTTPS" = 1 ]; then
https_bind="$(backend_address "$WAN_IP" "$HTTPS_PORT")"
printf 'frontend fe_web_https\n'
printf ' bind %s\n' "$https_bind"
printf ' mode tcp\n'
printf ' tcp-request inspect-delay 5s\n'
printf ' tcp-request content accept if { req.ssl_hello_type 1 }\n'
cat "$TMP_TLS_RULES"
printf ' default_backend be_tls_not_found\n\n'
cat "$TMP_TLS_BACKENDS"
fi
cat "$TMP_TCP_FRONTENDS"
printf 'backend be_not_found\n'
printf ' mode http\n'
printf ' http-request return status 404 content-type text/plain string "No HAProxy route matched this host.\\n"\n\n'
printf 'backend be_tls_not_found\n'
printf ' mode tcp\n'
printf ' tcp-request content reject\n'
} > "$WORK_OUT"
# Replace the path itself so a stale symlink cannot redirect this root write.
rm -f "$OUT"
mv "$WORK_OUT" "$OUT"
printf '%s\n' "$OUT"
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
. /usr/libexec/haproxy-manager/common.sh
ID="${1:-}"
is_valid_backup_id "$ID" || { echo "Invalid incident identifier" >&2; exit 1; }
BASE="$(incident_dir)"
[ ! -d "$BASE" ] || BASE="$(resolve_storage_dir "$BASE")" || exit 1
FILE="$BASE/$ID/diagnostics.log"
[ -f "$FILE" ] || { echo "Incident report not found: $ID" >&2; exit 1; }
# Keep the RPC response bounded even if a platform command produced excessive output.
tail -c 65536 "$FILE"
@@ -0,0 +1,28 @@
#!/bin/sh
. /usr/libexec/haproxy-manager/common.sh
BASE="$(incident_dir)"
[ ! -d "$BASE" ] || BASE="$(resolve_storage_dir "$BASE")" || exit 1
LAST=""
[ ! -r "$BASE/LAST" ] || LAST="$(basename "$(sed -n '1p' "$BASE/LAST")")"
safe_field() {
printf '%s' "$1" | tr '\t\r\n' ' '
}
count=0
for dir in $(find "$BASE" -maxdepth 1 -type d -name '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' 2>/dev/null | sort -r); do
[ -d "$dir" ] || continue
id="$(basename "$dir")"
latest=0
[ "$id" != "$LAST" ] || latest=1
result="$(sed -n '1p' "$dir/result" 2>/dev/null || echo detected)"
action="$(sed -n 's/^action=//p' "$dir/meta" 2>/dev/null | head -1)"
interface="$(sed -n 's/^interface=//p' "$dir/meta" 2>/dev/null | head -1)"
reason="$(sed -n 's/^reason=//p' "$dir/meta" 2>/dev/null | head -1)"
printf 'incident\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$id" "$latest" "$(safe_field "$result")" "$(safe_field "$action")" \
"$(safe_field "$interface")" "$(safe_field "$reason")"
count=$((count + 1))
[ "$count" -lt "$INCIDENT_LIMIT" ] || break
done
@@ -0,0 +1,150 @@
#!/bin/sh
set -eu
. /usr/libexec/haproxy-manager/common.sh
migrate_firewall_markers() {
local section marker name changed
changed=0
for section in $(uci -q show firewall | sed -n 's/^firewall\.\(@redirect\[[0-9][0-9]*\]\)=redirect/\1/p'); do
marker="$(uci -q get "firewall.$section.hm_disabled_by_haproxy_manager" 2>/dev/null || true)"
[ -n "$marker" ] || continue
if [ "$marker" = 1 ]; then
name="$(uci -q get "firewall.$section.name" 2>/dev/null || true)"
case "$name" in
"$FIREWALL_DISABLED_PREFIX"*) ;;
*) uci set "firewall.$section.name=$FIREWALL_DISABLED_PREFIX$name" ;;
esac
fi
uci -q delete "firewall.$section.hm_disabled_by_haproxy_manager" || true
changed=1
done
for section in $(uci -q show firewall | sed -n 's/^firewall\.\(@rule\[[0-9][0-9]*\]\)=rule/\1/p'); do
marker="$(uci -q get "firewall.$section.hm_managed" 2>/dev/null || true)"
[ -n "$marker" ] || continue
[ "$marker" != 1 ] || uci set "firewall.$section.name=$FIREWALL_RULE_NAME"
uci -q delete "firewall.$section.hm_managed" || true
changed=1
done
[ "$changed" != 1 ] || uci commit firewall
}
migrate_firewall_markers
SCHEMA_VERSION="$(uci_get main schema_version 1)"
MIGRATED=0
if [ "$SCHEMA_VERSION" -lt 2 ] 2>/dev/null; then
SECTIONS="$(route_sections)"
[ -z "$SECTIONS" ] || /usr/libexec/haproxy-manager/backup >/dev/null
CONSUMED=/tmp/haproxy-manager-migrate-consumed.$$
: > "$CONSUMED"
trap 'rm -f "$CONSUMED"' EXIT
is_consumed() {
grep -qx "$1" "$CONSUMED"
}
to_web() {
local section="$1" http_enabled="$2" https_enabled="$3" http_backend="$4" https_backend="$5"
uci set "$CONFIG.$section.kind=web"
uci set "$CONFIG.$section.web_http=$http_enabled"
uci set "$CONFIG.$section.web_https=$https_enabled"
[ "$http_enabled" != 1 ] || uci set "$CONFIG.$section.backend_http_port=$http_backend"
[ "$https_enabled" != 1 ] || uci set "$CONFIG.$section.backend_https_port=$https_backend"
uci -q delete "$CONFIG.$section.protocol" || true
uci -q delete "$CONFIG.$section.listen_port" || true
uci -q delete "$CONFIG.$section.backend_port" || true
}
clean_web_name() {
local section="$1" host="$2" name clean
name="$(uci_get "$section" name)"
clean="$name"
case "$name" in
"$host 80"|"$host:80"|"$host HTTP"|"$host http") clean="$host" ;;
*" HTTP") clean="${name% HTTP}" ;;
*" http") clean="${name% http}" ;;
esac
[ -z "$clean" ] || [ "$clean" = "$name" ] || uci set "$CONFIG.$section.name=$clean"
}
for section in $SECTIONS; do
protocol="$(uci_get "$section" protocol)"
case "$protocol" in
http)
host="$(uci_get "$section" host)"
backend_host="$(uci_get "$section" backend_host)"
enabled="$(uci_get "$section" enabled 1)"
http_backend="$(uci_get "$section" backend_port 80)"
match=""
for candidate in $SECTIONS; do
is_consumed "$candidate" && continue
[ "$(uci_get "$candidate" protocol)" = https ] || continue
[ "$(uci_get "$candidate" host)" = "$host" ] || continue
[ "$(uci_get "$candidate" backend_host)" = "$backend_host" ] || continue
[ "$(uci_get "$candidate" enabled 1)" = "$enabled" ] || continue
match="$candidate"
break
done
if [ -n "$match" ]; then
clean_web_name "$section" "$host"
to_web "$section" 1 1 "$http_backend" "$(uci_get "$match" backend_port 443)"
printf '%s\n' "$match" >> "$CONSUMED"
else
clean_web_name "$section" "$host"
to_web "$section" 1 0 "$http_backend" 443
fi
;;
both)
backend="$(uci_get "$section" backend_port 80)"
clean_web_name "$section" "$(uci_get "$section" host)"
to_web "$section" 1 1 "$backend" "$backend"
;;
tcp)
public_port="$(uci_get "$section" listen_port "$(uci_get "$section" backend_port)")"
backend_port="$(uci_get "$section" backend_port)"
uci set "$CONFIG.$section.kind=custom"
uci -q delete "$CONFIG.$section.port_map" || true
uci add_list "$CONFIG.$section.port_map=$public_port:$backend_port"
uci -q delete "$CONFIG.$section.protocol" || true
uci -q delete "$CONFIG.$section.listen_port" || true
uci -q delete "$CONFIG.$section.backend_port" || true
;;
esac
done
for section in $SECTIONS; do
is_consumed "$section" && continue
[ "$(uci_get "$section" protocol)" = https ] || continue
to_web "$section" 0 1 80 "$(uci_get "$section" backend_port 443)"
done
sed -n 's/^@route\[\([0-9][0-9]*\)\]$/\1/p' "$CONSUMED" | sort -rn | while read -r index; do
uci -q delete "$CONFIG.@route[$index]" || true
done
MIGRATED=1
fi
if [ "$SCHEMA_VERSION" -lt 3 ] 2>/dev/null; then
if [ "$(uci_get main enabled 0)" = 1 ] &&
grep -q '^# Generated by luci-app-haproxy-manager\.' "$HAPROXY_CFG" 2>/dev/null; then
uci set "$CONFIG.main.active_mode=generated"
else
uci set "$CONFIG.main.active_mode=none"
fi
uci set "$CONFIG.main.schema_version=3"
uci commit "$CONFIG"
MIGRATED=1
fi
[ "$MIGRATED" != 1 ] || echo "Configuration schema upgraded to version 3"
@@ -0,0 +1,44 @@
#!/bin/sh
set -eu
. /usr/libexec/haproxy-manager/common.sh
[ "$(uci_get main webhook_enabled 0)" = 1 ] || exit 0
URL="$(uci_get main webhook_url)"
case "$URL" in
http://*) AUTHORITY="${URL#http://}" ;;
https://*) AUTHORITY="${URL#https://}" ;;
*) echo "Webhook URL must use HTTP or HTTPS" >&2; exit 1 ;;
esac
[ -n "${AUTHORITY%%/*}" ] || { echo "Webhook URL must include a host" >&2; exit 1; }
case "$URL" in
*[![:graph:]]*) echo "Webhook URL contains invalid characters" >&2; exit 1 ;;
esac
if command -v uclient-fetch >/dev/null 2>&1; then
FETCHER=uclient-fetch
elif command -v wget >/dev/null 2>&1; then
FETCHER=wget
else
echo "A compatible wget implementation is required for webhook delivery" >&2
exit 1
fi
[ -r /usr/share/libubox/jshn.sh ] || { echo "jshn.sh is not available" >&2; exit 1; }
INCIDENT_ID="$(printf '%s' "${1:-unknown}" | tr -cd '0-9-')"
RESULT="$(printf '%s' "${2:-unknown}" | tr -cd 'A-Za-z0-9_.:-')"
ACTION="$(printf '%s' "${3:-unknown}" | tr -cd 'A-Za-z0-9_.:-')"
INTERFACE="$(printf '%s' "${4:-unknown}" | tr -cd 'A-Za-z0-9_.:-')"
REASON="$(printf '%s' "${5:-unknown}" | tr -cd 'A-Za-z0-9_.:-')"
. /usr/share/libubox/jshn.sh
json_init
json_add_string event haproxy-manager-recovery
json_add_string incident "$INCIDENT_ID"
json_add_string result "$RESULT"
json_add_string action "$ACTION"
json_add_string interface "$INTERFACE"
json_add_string reason "$REASON"
PAYLOAD="$(json_dump)"
"$FETCHER" -q -T 10 -O /dev/null \
--header='Content-Type: application/json' \
--post-data="$PAYLOAD" "$URL"
@@ -0,0 +1,4 @@
#!/bin/sh
. /usr/libexec/haproxy-manager/common.sh
required_ports
@@ -0,0 +1,259 @@
#!/bin/sh
set -eu
. /usr/libexec/haproxy-manager/common.sh
umask 077
ACTION="$(printf '%s' "${1:-network}" | tr -cd 'A-Za-z0-9_.:-')"
INTERFACE="$(printf '%s' "${2:-unknown}" | tr -cd 'A-Za-z0-9_.:-')"
RECOVER_LOCK=/var/lock/haproxy-manager.recover
APPLY_LOCK=/var/lock/haproxy-manager.apply
APPLY_LOCK_HELD=0
TMP_CFG="/tmp/haproxy-manager-recover.$$"
INCIDENT=""
BASE=""
DIAGNOSTICS=/dev/null
[ -n "$ACTION" ] || ACTION=network
[ -n "$INTERFACE" ] || INTERFACE=unknown
[ "$(uci_get main auto_recover 1)" = 1 ] || exit 0
[ "$(uci_get main enabled 0)" = 1 ] || exit 0
RECONCILE_GENERATED=0
case "$ACTION" in
ifup|ifupdate)
[ "$INTERFACE" = "$(wan_interface)" ] || exit 0
RECONCILE_GENERATED=1
;;
esac
[ -x /etc/init.d/haproxy ] || exit 0
/etc/init.d/haproxy enabled >/dev/null 2>&1 || exit 0
MODE="$(active_mode)"
case "$MODE" in
generated|raw) ;;
*) exit 0 ;;
esac
cleanup() {
rm -f "$TMP_CFG"
[ "$APPLY_LOCK_HELD" != 1 ] || operation_lock_release "$APPLY_LOCK"
operation_lock_release "$RECOVER_LOCK"
}
trim_incidents() {
local count old
count=0
for old in $(find "$BASE" -maxdepth 1 -type d -name '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' 2>/dev/null | sort -r); do
count=$((count + 1))
[ "$count" -le "$INCIDENT_LIMIT" ] && continue
case "$old" in
"$BASE"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
rm -f "$old/meta" "$old/result" "$old/diagnostics.log"
rmdir "$old" 2>/dev/null || true
;;
esac
done
}
create_incident() {
local reason="$1" id attempt target_ip requested_base
requested_base="$(incident_dir)" || return 1
umask 077
mkdir -p "$requested_base" || {
logger -t haproxy-manager "Cannot create incident report directory: $requested_base"
return 1
}
BASE="$(resolve_storage_dir "$requested_base")" || {
logger -t haproxy-manager "Incident directory resolves outside /root or /mnt"
return 1
}
id="$(date +%Y%m%d-%H%M%S)"
INCIDENT="$BASE/$id"
attempt=1
while ! mkdir "$INCIDENT" 2>/dev/null; do
[ "$attempt" -lt 5 ] || {
logger -t haproxy-manager "Cannot create incident report in $BASE"
return 1
}
sleep 1
attempt=$((attempt + 1))
id="$(date +%Y%m%d-%H%M%S)"
INCIDENT="$BASE/$id"
done
chmod 700 "$BASE" "$INCIDENT" 2>/dev/null || true
target_ip="$(wan_ip 2>/dev/null || echo unknown)"
cat > "$INCIDENT/meta" <<EOF || return 1
created=$id
action=$ACTION
interface=$INTERFACE
reason=$reason
target_ip=$target_ip
EOF
printf 'detected\n' > "$INCIDENT/result" || return 1
printf '%s\n' "$id" > "$BASE/LAST" || return 1
trim_incidents
DIAGNOSTICS="$INCIDENT/diagnostics.log"
if [ "$reason" = wan-address-change ]; then
{
printf 'HAProxy Manager WAN listener reconciliation\n'
printf 'Incident: %s\nAction: %s\nInterface: %s\nReason: %s\nTarget WAN address: %s\n' \
"$id" "$ACTION" "$INTERFACE" "$reason" "$target_ip"
} > "$DIAGNOSTICS" 2>&1
return 0
fi
{
printf 'HAProxy Manager automatic recovery\n'
printf 'Incident: %s\nAction: %s\nInterface: %s\nReason: %s\nTarget WAN address: %s\n\n' \
"$id" "$ACTION" "$INTERFACE" "$reason" "$target_ip"
printf '== Time and uptime ==\n'
date || true
uptime || true
printf '\n== Memory ==\n'
free || true
printf '\n== IPv4 addresses ==\n'
ip -4 addr show || true
printf '\n== HAProxy procd state ==\n'
ubus call service list '{"name":"haproxy","verbose":true}' || true
printf '\n== Network interfaces ==\n'
ubus call network.interface dump || true
printf '\n== Recent HAProxy, procd, and OOM messages ==\n'
logread | grep -iE 'haproxy|procd.*instance|out of memory|oom|killed process' | tail -n 160 || true
printf '\n== Kernel OOM messages ==\n'
dmesg | grep -Ei 'oom|out of memory|killed process|haproxy' | tail -n 80 || true
} > "$DIAGNOSTICS" 2>&1
}
set_result() {
local result="$1" id reason
if [ -z "$INCIDENT" ] || [ ! -d "$INCIDENT" ]; then
logger -t haproxy-manager "Recovery result without incident report: $result"
return 0
fi
if ! printf '%s\n' "$result" > "$INCIDENT/result"; then
logger -t haproxy-manager "Could not update incident report with result: $result"
INCIDENT=""
DIAGNOSTICS=/dev/null
return 0
fi
id="${INCIDENT##*/}"
reason="$(sed -n 's/^reason=//p' "$INCIDENT/meta" 2>/dev/null | head -1)"
(
if ! /usr/libexec/haproxy-manager/notify "$id" "$result" "$ACTION" "$INTERFACE" "$reason"; then
logger -t haproxy-manager "Webhook notification failed for incident $id"
fi
) >/dev/null 2>&1 &
}
operation_lock_acquire "$RECOVER_LOCK" || exit 0
trap cleanup EXIT
# Let netifd finish installing addresses and routes before comparing listeners.
sleep 2
operation_lock_acquire "$APPLY_LOCK" || exit 0
APPLY_LOCK_HELD=1
CONFIG_CHANGED=0
GENERATION_FAILED=0
VALIDATION_FAILED=0
if [ "$RECONCILE_GENERATED" = 1 ] && [ "$MODE" = generated ] &&
[ "$(uci_get main wan_bind_ip auto)" = auto ]; then
if /usr/libexec/haproxy-manager/generate "$TMP_CFG" >/dev/null 2>&1; then
if /usr/libexec/haproxy-manager/validate "$TMP_CFG" >/dev/null 2>&1; then
cmp -s "$TMP_CFG" "$HAPROXY_CFG" || CONFIG_CHANGED=1
else
VALIDATION_FAILED=1
fi
else
GENERATION_FAILED=1
fi
fi
if [ "$GENERATION_FAILED" != 1 ] && [ "$VALIDATION_FAILED" != 1 ] &&
[ "$CONFIG_CHANGED" != 1 ] && /etc/init.d/haproxy status >/dev/null 2>&1; then
exit 0
fi
if [ "$GENERATION_FAILED" = 1 ]; then
REASON=wan-address-unavailable
elif [ "$VALIDATION_FAILED" = 1 ]; then
REASON=invalid-generated-config
elif [ "$CONFIG_CHANGED" = 1 ]; then
REASON=wan-address-change
else
REASON=service-stopped
fi
if ! create_incident "$REASON"; then
logger -t haproxy-manager "Incident report could not be stored; recovery will continue"
INCIDENT=""
DIAGNOSTICS=/dev/null
fi
logger -t haproxy-manager "HAProxy recovery started after $ACTION on $INTERFACE ($REASON)"
if [ "$GENERATION_FAILED" = 1 ]; then
set_result address-unavailable
logger -t haproxy-manager "Automatic recovery stopped: WAN address is unavailable"
exit 1
fi
if [ "$VALIDATION_FAILED" = 1 ]; then
set_result invalid-generated-config
logger -t haproxy-manager "Automatic recovery stopped: generated configuration is invalid"
exit 1
fi
if [ "$CONFIG_CHANGED" = 1 ]; then
BACKUP_PATH=""
if ! BACKUP_PATH="$(/usr/libexec/haproxy-manager/backup 2>> "$DIAGNOSTICS")"; then
set_result backup-failed
logger -t haproxy-manager "Automatic recovery stopped: snapshot creation failed"
exit 1
fi
printf '\nRecovery point: %s\n' "${BACKUP_PATH##*/}" >> "$DIAGNOSTICS" || true
if ! atomic_install_file "$TMP_CFG" "$HAPROXY_CFG"; then
set_result install-failed
logger -t haproxy-manager "Automatic recovery stopped: HAProxy configuration could not be installed"
exit 1
fi
if /etc/init.d/haproxy restart >> "$DIAGNOSTICS" 2>&1 &&
/etc/init.d/haproxy status >/dev/null 2>&1; then
set_result reconciled
logger -t haproxy-manager "HAProxy listeners reconciled with the current WAN address"
exit 0
fi
if /usr/libexec/haproxy-manager/rollback --sync "${BACKUP_PATH##*/}" >> "$DIAGNOSTICS" 2>&1; then
set_result rolled-back
logger -t haproxy-manager "WAN listener reconciliation failed and was rolled back"
else
set_result rollback-failed
logger -t haproxy-manager "WAN listener reconciliation and rollback both failed"
fi
exit 1
fi
if ! /etc/init.d/haproxy check >> "$DIAGNOSTICS" 2>&1; then
set_result invalid-config
logger -t haproxy-manager "Automatic recovery stopped: HAProxy configuration is invalid"
exit 1
fi
attempt=1
while [ "$attempt" -le 5 ]; do
printf '\n== Start attempt %s ==\n' "$attempt" >> "$DIAGNOSTICS" || true
/etc/init.d/haproxy restart >> "$DIAGNOSTICS" 2>&1 || true
sleep $((attempt + 1))
if /etc/init.d/haproxy status >/dev/null 2>&1; then
set_result recovered
{
printf '\n== Recovered listeners ==\n'
ps w | grep '[h]aproxy' || true
netstat -lntp 2>/dev/null | grep haproxy || true
} >> "$DIAGNOSTICS" 2>&1
logger -t haproxy-manager "HAProxy recovered after $attempt attempt(s)"
exit 0
fi
attempt=$((attempt + 1))
done
set_result failed
logger -t haproxy-manager "HAProxy automatic recovery failed after 5 attempts"
exit 1
@@ -0,0 +1,124 @@
#!/bin/sh
set -eu
. /usr/libexec/haproxy-manager/common.sh
SYNC=0
LOCK_DIR=/var/lock/haproxy-manager.apply
LOCK_HELD=0
if [ "${1:-}" = --sync ]; then
[ "$#" -eq 2 ] || { echo "Usage: rollback [--sync] [last|YYYYMMDD-HHMMSS]" >&2; exit 1; }
SYNC=1
TARGET="$2"
else
[ "$#" -le 1 ] || { echo "Usage: rollback [--sync] [last|YYYYMMDD-HHMMSS]" >&2; exit 1; }
TARGET="${1:-last}"
fi
release_lock() {
[ "$LOCK_HELD" != 1 ] || operation_lock_release "$LOCK_DIR"
}
if [ "$SYNC" != 1 ]; then
attempt=0
while ! operation_lock_acquire "$LOCK_DIR"; do
[ "$attempt" -lt 15 ] || { echo "Another HAProxy Manager operation is still running." >&2; exit 1; }
attempt=$((attempt + 1))
sleep 1
done
LOCK_HELD=1
trap 'status=$?; release_lock; exit "$status"' EXIT
fi
BASE="$(backup_dir)"
[ ! -d "$BASE" ] || BASE="$(resolve_storage_dir "$BASE")" || {
echo "Recovery point directory resolves outside /root or /mnt" >&2
exit 1
}
if [ "$TARGET" = last ]; then
[ -f "$BASE/LAST" ] || { echo "No LAST backup marker found" >&2; exit 1; }
TARGET="$(basename "$(cat "$BASE/LAST")")"
fi
is_valid_backup_id "$TARGET" || { echo "Invalid backup identifier" >&2; exit 1; }
TARGET_DIR="$BASE/$TARGET"
[ -d "$TARGET_DIR" ] || { echo "Backup not found: $TARGET" >&2; exit 1; }
firewall_changed=0
uhttpd_changed=0
state_failed=0
if [ -f "$TARGET_DIR/firewall" ] && ! cmp -s "$TARGET_DIR/firewall" /etc/config/firewall; then
atomic_install_file "$TARGET_DIR/firewall" /etc/config/firewall
firewall_changed=1
fi
if [ -f "$TARGET_DIR/uhttpd" ] && ! cmp -s "$TARGET_DIR/uhttpd" /etc/config/uhttpd; then
atomic_install_file "$TARGET_DIR/uhttpd" /etc/config/uhttpd
uhttpd_changed=1
fi
[ -f "$TARGET_DIR/haproxy_manager" ] && atomic_install_file "$TARGET_DIR/haproxy_manager" /etc/config/haproxy_manager
[ -f "$TARGET_DIR/haproxy.cfg" ] && atomic_install_file "$TARGET_DIR/haproxy.cfg" "$HAPROXY_CFG"
if [ "$firewall_changed" = 1 ] && ! /etc/init.d/firewall restart >/dev/null 2>&1; then
logger -t haproxy-manager "Firewall restart failed while restoring $TARGET"
state_failed=1
fi
haproxy_enabled=1
haproxy_running=1
if [ -f "$TARGET_DIR/haproxy.state" ]; then
haproxy_enabled="$(sed -n 's/^enabled=//p' "$TARGET_DIR/haproxy.state" | head -1)"
haproxy_running="$(sed -n 's/^running=//p' "$TARGET_DIR/haproxy.state" | head -1)"
fi
if [ "$haproxy_enabled" = 1 ]; then
if ! /etc/init.d/haproxy enable >/dev/null 2>&1; then
logger -t haproxy-manager "Could not enable HAProxy while restoring $TARGET"
state_failed=1
fi
else
if ! /etc/init.d/haproxy disable >/dev/null 2>&1; then
logger -t haproxy-manager "Could not disable HAProxy while restoring $TARGET"
state_failed=1
fi
fi
restore_services() {
local failed="$state_failed"
if [ "$uhttpd_changed" = 1 ] && ! /etc/init.d/uhttpd restart >/dev/null 2>&1; then
logger -t haproxy-manager "LuCI restart failed while restoring $TARGET"
failed=1
fi
if [ "$haproxy_running" = 1 ]; then
if ! /etc/init.d/haproxy restart >/dev/null 2>&1 ||
! /etc/init.d/haproxy status >/dev/null 2>&1; then
logger -t haproxy-manager "HAProxy restart failed while restoring $TARGET"
failed=1
fi
else
if ! /etc/init.d/haproxy stop >/dev/null 2>&1 ||
/etc/init.d/haproxy status >/dev/null 2>&1; then
logger -t haproxy-manager "HAProxy stop failed while restoring $TARGET"
failed=1
fi
fi
return "$failed"
}
if [ "$SYNC" = 1 ]; then
restore_services || exit 1
else
LOCK_HELD=0
trap - EXIT
(
sleep 2
if ! restore_services; then
logger -t haproxy-manager "Service restoration failed for recovery point $TARGET"
fi
operation_lock_release "$LOCK_DIR"
) >/dev/null 2>&1 &
operation_lock_set_pid "$LOCK_DIR" "$!"
fi
echo "Restored backup $TARGET. Affected services will reload shortly."
@@ -0,0 +1,56 @@
#!/bin/sh
. /usr/libexec/haproxy-manager/common.sh
enabled="$(uci_get main enabled 0)"
wan="$(wan_ip 2>/dev/null || echo unknown)"
version="not-available"
service="stopped"
backup_count=0
auto_recovery="$(uci_get main auto_recover 1)"
mode="$(active_mode)"
last_incident=""
last_incident_result="none"
last_incident_interface=""
if command -v haproxy >/dev/null 2>&1; then
version="$(haproxy -v 2>/dev/null | sed -n '1s/^HAProxy version \([^ ]*\).*/\1/p')"
[ -n "$version" ] || version="unknown"
fi
if [ -x /etc/init.d/haproxy ] && /etc/init.d/haproxy status >/dev/null 2>&1; then
service="running"
fi
backup_count="$(/usr/libexec/haproxy-manager/backups 2>/dev/null | wc -l | tr -d ' ')"
INCIDENT_BASE="$(incident_dir)"
if [ -d "$INCIDENT_BASE" ]; then
INCIDENT_BASE="$(resolve_storage_dir "$INCIDENT_BASE")" || INCIDENT_BASE=""
fi
if [ -f "$INCIDENT_BASE/LAST" ]; then
last_incident="$(sed -n '1p' "$INCIDENT_BASE/LAST")"
if is_valid_backup_id "$last_incident" && [ -d "$INCIDENT_BASE/$last_incident" ]; then
last_incident_result="$(sed -n '1p' "$INCIDENT_BASE/$last_incident/result" 2>/dev/null || echo detected)"
last_incident_interface="$(sed -n 's/^interface=//p' "$INCIDENT_BASE/$last_incident/meta" 2>/dev/null | head -1)"
else
last_incident=""
last_incident_result="none"
fi
fi
printf 'enabled\t%s\n' "$enabled"
printf 'wan_ip\t%s\n' "$wan"
printf 'version\t%s\n' "$version"
printf 'service\t%s\n' "$service"
printf 'backup_count\t%s\n' "$backup_count"
printf 'auto_recovery\t%s\n' "$auto_recovery"
printf 'active_mode\t%s\n' "$mode"
printf 'last_incident\t%s\n' "$last_incident"
printf 'last_incident_result\t%s\n' "$last_incident_result"
printf 'last_incident_interface\t%s\n' "$last_incident_interface"
netstat -lntp 2>/dev/null | awk '
NR > 2 && $6 == "LISTEN" && ($7 ~ /haproxy/ || $7 ~ /uhttpd/) {
printf "listener\t%s\t%s\t%s\n", $1, $4, $7
}
'
@@ -0,0 +1,70 @@
#!/bin/sh
set -u
. /usr/libexec/haproxy-manager/common.sh
LOCK_DIR=/var/lock/haproxy-manager.apply
LOCK_HELD=0
UHTTPD_CHANGED=0
MODE="$(active_mode)"
FAILED=0
attempt=0
while ! operation_lock_acquire "$LOCK_DIR"; do
[ "$attempt" -lt 15 ] || {
logger -t haproxy-manager "Package removal could not acquire the operation lock"
exit 1
}
attempt=$((attempt + 1))
sleep 1
done
LOCK_HELD=1
trap '[ "$LOCK_HELD" != 1 ] || operation_lock_release "$LOCK_DIR"' EXIT
/usr/libexec/haproxy-manager/backup >/dev/null 2>&1 ||
logger -t haproxy-manager "Could not create a final recovery point before package removal"
if ! restore_uhttpd_bindings; then
logger -t haproxy-manager "Could not restore the original LuCI listener bindings"
FAILED=1
fi
if [ "$UHTTPD_CHANGED" = 1 ] && ! uci commit uhttpd; then
logger -t haproxy-manager "Could not commit the restored LuCI listener bindings"
FAILED=1
fi
if ! /usr/libexec/haproxy-manager/firewall-sync --disable >/dev/null 2>&1; then
logger -t haproxy-manager "Could not remove owned firewall state"
FAILED=1
fi
case "$MODE" in
generated|raw)
if ! /etc/init.d/haproxy stop >/dev/null 2>&1 ||
/etc/init.d/haproxy status >/dev/null 2>&1; then
logger -t haproxy-manager "Could not stop HAProxy during package removal"
FAILED=1
fi
if ! /etc/init.d/haproxy disable >/dev/null 2>&1; then
logger -t haproxy-manager "Could not disable HAProxy during package removal"
FAILED=1
fi
;;
esac
if [ "$UHTTPD_CHANGED" = 1 ]; then
/etc/init.d/uhttpd restart >/dev/null 2>&1 ||
{ logger -t haproxy-manager "Could not restart LuCI after restoring its listeners"; FAILED=1; }
fi
if [ "$FAILED" != 0 ]; then
logger -t haproxy-manager "Package runtime cleanup is incomplete; removal was stopped"
exit 1
fi
uci set "$CONFIG.main.active_mode=none"
uci commit "$CONFIG"
LOCK_HELD=0
operation_lock_release "$LOCK_DIR"
logger -t haproxy-manager "Removed package-owned HAProxy, firewall, and LuCI runtime state"
exit 0
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
. /usr/libexec/haproxy-manager/common.sh
CFG="${1:-/etc/haproxy.cfg}"
if [ "$CFG" != "$HAPROXY_CFG" ]; then
is_safe_temp_path "$CFG" || { echo "Refusing unsafe HAProxy config path: $CFG" >&2; exit 1; }
fi
if ! command -v haproxy >/dev/null 2>&1; then
echo "haproxy binary is not installed" >&2
exit 1
fi
haproxy -c -f "$CFG"