From b7a0743d8be8e5f6241cb46cd96f8019ba4f386e Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 2 Jun 2026 15:54:55 +0100 Subject: [PATCH] feat(network): add ipInSubnet + IP-only network reset scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundations for network-drift healing: - ipInSubnet(ip, cidr): prefix-aware CIDR membership (pure bash), so stored IPs can be checked against docker's real subnet. Honours the actual prefix, so a healthy /16-subnet + /24-ip-range install is not mistaken for drift. - dockerInstallApp now accepts reset_network="ip": re-roll the static IP from the current subnet but PRESERVE published host ports (clears only IP rows; LIBREPORTAL_RESET_IP_ONLY keeps port_allocate reusing existing ports). This is the heal path — a subnet move strands the IP, not the port, so we don't churn bookmarks/forwards/proxy upstreams. reset="true" still re-rolls both. Co-Authored-By: Claude Opus 4.8 --- .../app/functions/function_install_app.sh | 17 +++++++--- scripts/network/ip/ip_in_subnet.sh | 32 +++++++++++++++++++ .../network/ports/allocation/port_allocate.sh | 5 ++- 3 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 scripts/network/ip/ip_in_subnet.sh diff --git a/scripts/docker/app/functions/function_install_app.sh b/scripts/docker/app/functions/function_install_app.sh index 51bc8cf..76febb5 100755 --- a/scripts/docker/app/functions/function_install_app.sh +++ b/scripts/docker/app/functions/function_install_app.sh @@ -35,15 +35,24 @@ dockerInstallApp() return 1 fi - if [[ "$reset_network" == "true" ]]; then + # reset_network: "true" = re-roll IPs AND host ports; "ip" = re-roll the + # static IP only and PRESERVE published ports (the network-heal path — a + # subnet move strands the IP, not the port, so don't churn bookmarks / + # forwards / proxy upstreams). Anything else = preserve both (normal reuse). + if [[ "$reset_network" == "true" || "$reset_network" == "ip" ]]; then export LIBREPORTAL_RESET_NETWORK=1 + [[ "$reset_network" == "ip" ]] && export LIBREPORTAL_RESET_IP_ONLY=1 if declare -f ipRemoveFromDatabase >/dev/null 2>&1; then ipRemoveFromDatabase "$app_name" fi - if declare -f portsRemoveFromDatabase >/dev/null 2>&1; then + if [[ "$reset_network" != "ip" ]] && declare -f portsRemoveFromDatabase >/dev/null 2>&1; then portsRemoveFromDatabase "$app_name" fi - isNotice "Network reset: cleared previous IP/port allocations for $app_name." + if [[ "$reset_network" == "ip" ]]; then + isNotice "Network reset (IP-only): cleared previous IP allocation for $app_name; ports preserved." + else + isNotice "Network reset: cleared previous IP/port allocations for $app_name." + fi fi # Silently update the template config so apps.json regen reflects the saved @@ -71,5 +80,5 @@ dockerInstallApp() webuiGenerateLibrePortalConfig >/dev/null 2>&1 || true fi - unset LIBREPORTAL_RESET_NETWORK + unset LIBREPORTAL_RESET_NETWORK LIBREPORTAL_RESET_IP_ONLY } diff --git a/scripts/network/ip/ip_in_subnet.sh b/scripts/network/ip/ip_in_subnet.sh new file mode 100644 index 0000000..8ac87cf --- /dev/null +++ b/scripts/network/ip/ip_in_subnet.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# CIDR membership test: is $ip inside $cidr? +# ipInSubnet 10.123.154.5 10.123.154.0/24 -> 0 (in subnet) +# ipInSubnet 10.100.0.5 10.123.154.0/24 -> 1 (out of subnet) +# +# Pure-bash 32-bit integer masking that HONOURS the real prefix length, so a /16 +# genuinely contains its /24s. That matters here: the network is created with a +# /24 ip-range but its declared subnet can be /16 (configs/network/network_docker +# default), and a healthy "/16 subnet + /24 ip-range" install must NOT read as +# drift. Compare stored IPs against docker's actual subnet CIDR, not a forced /24. +# Returns 0 (in-subnet) or 1 (out-of-subnet / malformed input). +ipInSubnet() +{ + local ip="$1" cidr="$2" + [[ "$cidr" == */* ]] || return 1 + local base="${cidr%/*}" prefix="${cidr#*/}" + [[ "$prefix" =~ ^[0-9]+$ ]] && (( prefix >= 0 && prefix <= 32 )) || return 1 + + local re='^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$' + [[ "$ip" =~ $re ]] || return 1 + local i1=$((10#${BASH_REMATCH[1]})) i2=$((10#${BASH_REMATCH[2]})) i3=$((10#${BASH_REMATCH[3]})) i4=$((10#${BASH_REMATCH[4]})) + [[ "$base" =~ $re ]] || return 1 + local b1=$((10#${BASH_REMATCH[1]})) b2=$((10#${BASH_REMATCH[2]})) b3=$((10#${BASH_REMATCH[3]})) b4=$((10#${BASH_REMATCH[4]})) + + local ip_int=$(( (i1<<24) | (i2<<16) | (i3<<8) | i4 )) + local base_int=$(( (b1<<24) | (b2<<16) | (b3<<8) | b4 )) + local mask=0 + (( prefix > 0 )) && mask=$(( (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF )) + + (( (ip_int & mask) == (base_int & mask) )) +} diff --git a/scripts/network/ports/allocation/port_allocate.sh b/scripts/network/ports/allocation/port_allocate.sh index ccd417d..6491aca 100755 --- a/scripts/network/ports/allocation/port_allocate.sh +++ b/scripts/network/ports/allocation/port_allocate.sh @@ -49,7 +49,10 @@ portAllocate() if [[ "${port_external_ports[$i]}" == "random" ]]; then local existing_external_port="" - if [[ "$LIBREPORTAL_RESET_NETWORK" != "1" ]]; then + # Re-roll the port only on a full network reset. In IP-only mode + # (network heal) the port rows were left intact — reuse them so + # published host ports stay stable. + if [[ "$LIBREPORTAL_RESET_NETWORK" != "1" || "$LIBREPORTAL_RESET_IP_ONLY" == "1" ]]; then existing_external_port=$(portLookupExisting "$app_name" "$full_service_name") fi