From 0cd08f2ee1c69bc1b9051bebe1a2a9c1cb01925c Mon Sep 17 00:00:00 2001 From: Kroese Date: Wed, 15 Jul 2026 12:24:25 +0200 Subject: [PATCH] fix: Improve NAT port forwarding (#1260) --- src/network.sh | 367 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 253 insertions(+), 114 deletions(-) diff --git a/src/network.sh b/src/network.sh index 010d48c..030d902 100644 --- a/src/network.sh +++ b/src/network.sh @@ -126,6 +126,11 @@ maskToCIDR() { local mask="$1" local prefix="" + if ! command -v ipcalc > /dev/null 2>&1; then + error "Required command 'ipcalc' is not installed!" + return 1 + fi + prefix=$(ipcalc -n -b "0.0.0.0/$mask" 2>/dev/null | awk ' /^Netmask:/ { for (i = 1; i <= NF; i++) { @@ -137,8 +142,8 @@ maskToCIDR() { } ') - if [[ ! "$prefix" =~ ^[0-9]+$ ]] || (( prefix < 1 || prefix > 30 )); then - error "Invalid MASK: '$mask'" + if [[ ! "$prefix" =~ ^[0-9]+$ ]] || (( prefix < 1 || prefix > 24 )); then + error "Invalid MASK: '$mask' (supported range: /1 through /24)" return 1 fi @@ -365,9 +370,6 @@ configureDNS() { arguments+=" --interface=$fa" arguments+=" --bind-interfaces" - # Set pid file - arguments+=" --pid-file=$DNSMASQ_PID" - # Workaround NET_RAW capability arguments+=" --no-ping" @@ -382,6 +384,9 @@ configureDNS() { # Set local dns resolver to dnsmasq when needed [ -f /etc/resolv.dnsmasq ] && arguments+=" --resolv-file=/etc/resolv.dnsmasq" + # Set pid file + arguments+=" --pid-file=$DNSMASQ_PID" + # Enable logging to file local log="/var/log/dnsmasq.log" rm -f "$log" @@ -413,37 +418,38 @@ configureDNS() { getHostPorts() { - local ports="" - local port="" local num="" - local proto="" + local port="" + local ports="" local mode="${1:-tcp}" local list="${HOST_PORTS// /}," for port in ${list//,/ }; do - proto="tcp" - num="$port" - - if [[ "$port" == *"/udp" ]]; then - proto="udp" - num="${port%/udp}" - elif [[ "$port" == *"/tcp" ]]; then - proto="tcp" - num="${port%/tcp}" - fi - - [ -z "$num" ] && continue + [ -z "$port" ] && continue case "$mode" in - "all" ) - ports+="$num/$proto," ;; "tcp" ) - [[ "$proto" == "tcp" ]] && ports+="$num," ;; - "udp" ) - [[ "$proto" == "udp" ]] && ports+="$num," ;; + [[ "$port" == *"/udp" ]] && continue + num="${port%/tcp}" + ;; + "all" ) + if [[ "$port" == *"/udp" ]]; then + num="${port%/udp}" + [ -n "$num" ] && ports+="$num/udp," + else + num="${port%/tcp}" + [ -n "$num" ] && ports+="$num/tcp," + fi + continue + ;; + *) + return 1 + ;; esac + [ -n "$num" ] && ports+="$num," + done # Remove duplicates @@ -455,21 +461,18 @@ getHostPorts() { getUserPorts() { - local ssh="22/tcp" - local dsm="5000/tcp,5001/tcp" + local defaults="22/tcp,5000/tcp,5001/tcp" + local list="$defaults,${USER_PORTS// /}," - local list="$ssh,$dsm," - list+="${USER_PORTS// /}," + local num="" + local ports="" + local proto="" + local userport="" + local hostport="" local exclude="" exclude=$(getHostPorts "all") - local ports="" - local userport="" - local hostport="" - local proto="" - local num="" - for userport in ${list//,/ }; do proto="tcp" @@ -488,6 +491,7 @@ getUserPorts() { for hostport in ${exclude//,/ }; do if [[ "$num/$proto" == "$hostport" ]]; then + num="" if [[ "$hostport" != "${WEB_PORT:-}/tcp" ]]; then @@ -505,6 +509,7 @@ getUserPorts() { # Remove duplicates echo "${ports//,,/,}," | awk 'BEGIN{RS=ORS=","} !seen[$0]++' | sed 's/,*$//g' + return 0 } @@ -808,14 +813,16 @@ configurePasst() { createBridge() { local gateway="$1" - local rc + local rc msg="" # Create a bridge with a static IP for the VM guest - { ip link add dev "$BRIDGE" type bridge; rc=$?; } || : + { msg=$(ip link add dev "$BRIDGE" type bridge 2>&1); rc=$?; } || : if (( rc != 0 )); then enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - warn "failed to create bridge. $ADD_ERR --cap-add NET_ADMIN" && return 1 + [ -n "$msg" ] && echo "$msg" >&2 + warn "failed to create bridge. $ADD_ERR --cap-add NET_ADMIN" + return 1 fi if [[ "$GUEST_MTU" != "0" ]]; then @@ -840,11 +847,16 @@ createBridge() { createTap() { local tuntap="$1" + local rc msg="" # Set tap to the bridge created - if ! ip tuntap add dev "$TAP" mode tap; then + { msg=$(ip tuntap add dev "$TAP" mode tap 2>&1); rc=$?; } || : + + if (( rc != 0 )); then enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 - warn "$tuntap" && return 1 + [ -n "$msg" ] && echo "$msg" >&2 + warn "$tuntap" + return 1 fi if [[ "$GUEST_MTU" != "0" ]]; then @@ -870,11 +882,79 @@ createTap() { return 0 } +checkExistingTables() { + + local rules="" + local conflicts="" + + rules=$(iptables -t nat -S PREROUTING 2>/dev/null || true) + conflicts=$(grep -E -- \ + '^-A PREROUTING .*(-j DNAT|-j REDIRECT)( |$)' \ + <<< "$rules" || true) + + if [ -n "$conflicts" ]; then + local msg="existing NAT rules may take precedence over VM port forwarding" + if enabled "$DEBUG"; then + warn "${msg}." + else + warn "${msg}; enable DEBUG=Y to inspect them." + fi + fi + + if enabled "$DEBUG" && [ -n "$rules" ]; then + printf "Existing NAT PREROUTING rules:\n\n%s\n\n" "$rules" + fi + + rules=$(iptables -t filter -S FORWARD 2>/dev/null || true) + conflicts=$(grep -E -- \ + '^-A FORWARD .*(-j DROP|-j REJECT)( |$)' \ + <<< "$rules" || true) + + if [ -n "$conflicts" ]; then + local msg="existing firewall rules may block traffic forwarded to or from the VM" + if enabled "$DEBUG"; then + warn "${msg}." + else + warn "${msg}; enable DEBUG=Y to inspect them." + fi + fi + + if enabled "$DEBUG" && [ -n "$rules" ]; then + printf "Existing filter FORWARD rules:\n\n%s\n\n" "$rules" + fi + + if enabled "$DEBUG"; then + + rules=$(iptables -t nat -S POSTROUTING 2>/dev/null || true) + + if [ -n "$rules" ]; then + printf "Existing NAT POSTROUTING rules:\n\n%s\n\n" "$rules" + fi + + rules=$(iptables -t mangle -S FORWARD 2>/dev/null || true) + + if [ -n "$rules" ]; then + printf "Existing mangle FORWARD rules:\n\n%s\n\n" "$rules" + fi + + rules=$(iptables -t mangle -S POSTROUTING 2>/dev/null || true) + + if [ -n "$rules" ]; then + printf "Existing mangle POSTROUTING rules:\n\n%s\n\n" "$rules" + fi + + fi + + return 0 +} + configureTables() { local ip="$1" local subnet="$2" - local exclude="$3" + local exclude="" + local port="" + local dnat_chain="QEMU_DNAT" local rule_tag="remove" local tables_err="failed to configure IP tables!" local tables="the 'ip_tables' kernel module is not loaded. Try this command: sudo modprobe ip_tables iptable_nat" @@ -885,53 +965,71 @@ configureTables() { return 1 fi - if [ -n "$exclude" ]; then - if [[ "$exclude" != *","* ]]; then - exclude=" ! --dport $exclude" - else - exclude=" -m multiport ! --dports $exclude" - fi - fi + checkExistingTables + exclude=$(getHostPorts) - # NAT traffic from bridge subnet to Docker uplink + # NAT traffic from the VM subnet leaving through any external interface. if ! iptables -t nat -A POSTROUTING \ - -o "$DEV" \ + ! -o "$BRIDGE" \ -s "$subnet" \ ! -d "$subnet" \ -m comment --comment "$rule_tag" \ -j MASQUERADE > /dev/null 2>&1; then enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 + if ! iptables -t nat -A POSTROUTING \ - -o "$DEV" \ + ! -o "$BRIDGE" \ -s "$subnet" \ ! -d "$subnet" \ -m comment --comment "$rule_tag" \ -j MASQUERADE; then - warn "$tables" && return 1 + warn "$tables" + return 1 fi fi - # shellcheck disable=SC2086 - if ! iptables -t nat -A PREROUTING \ - -i "$DEV" \ - -d "$UPLINK" \ - -p tcp${exclude} \ - -m comment --comment "$rule_tag" \ - -j DNAT --to "$ip"; then - warn "$tables_err" && return 1 + # Use a dedicated chain so protected TCP ports do not depend on multiport support. + if ! iptables -t nat -N "$dnat_chain"; then + warn "$tables_err" + return 1 fi - if ! iptables -t nat -A PREROUTING \ - -i "$DEV" \ - -d "$UPLINK" \ - -p udp \ + # Keep container-owned TCP ports handled by the container. + for port in ${exclude//,/ }; do + + [ -z "$port" ] && continue + + if ! iptables -t nat -A "$dnat_chain" \ + -p tcp \ + --dport "$port" \ + -m comment --comment "$rule_tag" \ + -j RETURN; then + warn "$tables_err" + return 1 + fi + + done + + # Forward every remaining protocol and port to the VM. + if ! iptables -t nat -A "$dnat_chain" \ -m comment --comment "$rule_tag" \ -j DNAT --to "$ip"; then - warn "$tables_err" && return 1 + warn "$tables_err" + return 1 + fi + + # Process incoming traffic addressed to the container through the VM chain. + if ! iptables -t nat -A PREROUTING \ + ! -i "$BRIDGE" \ + -m addrtype --dst-type LOCAL \ + -m comment --comment "$rule_tag" \ + -j "$dnat_chain"; then + warn "$tables_err" + return 1 fi if (( KERNEL > 4 )); then - # Hack for guest VMs complaining about "bad udp checksums in 5 packets" + # Hack for guest VMs complaining about "bad udp checksums in 5 packets". iptables -t mangle -A POSTROUTING \ -s "$subnet" \ -p udp \ @@ -955,22 +1053,26 @@ configureTables() { -m comment --comment "$rule_tag" \ -j TCPMSS --clamp-mss-to-pmtu > /dev/null 2>&1 || true - # Allow forwarding from bridge -> dev + # Allow forwarding from the VM bridge to external interfaces. if ! iptables -A FORWARD \ -i "$BRIDGE" \ - -o "$DEV" \ + ! -o "$BRIDGE" \ + -s "$subnet" \ -m comment --comment "$rule_tag" \ -j ACCEPT; then - warn "$tables_err" && return 1 + warn "$tables_err" + return 1 fi - # Allow forwarding from dev -> guest + # Allow forwarding from external interfaces to the VM. if ! iptables -A FORWARD \ - -i "$DEV" \ + ! -i "$BRIDGE" \ -o "$BRIDGE" \ + -d "$ip" \ -m comment --comment "$rule_tag" \ -j ACCEPT; then - warn "$tables_err" && return 1 + warn "$tables_err" + return 1 fi return 0 @@ -979,15 +1081,21 @@ configureTables() { configureNAT() { local tuntap="TUN device is missing. $ADD_ERR --device /dev/net/tun" - local rc + local msg="" + local rc ip subnet forwarding="" enabled "$DEBUG" && echo "Configuring NAT networking..." # Create the necessary file structure for /dev/net/tun if [ ! -c /dev/net/tun ]; then - [ ! -d /dev/net ] && mkdir -m 755 /dev/net - if mknod /dev/net/tun c 10 200; then + [ ! -d /dev/net ] && mkdir -m 755 /dev/net > /dev/null 2>&1 || : + + { msg=$(mknod /dev/net/tun c 10 200 2>&1); rc=$?; } || : + + if (( rc == 0 )); then chmod 666 /dev/net/tun + elif ! enabled "$ROOTLESS" || enabled "$DEBUG"; then + [ -n "$msg" ] && echo "$msg" >&2 fi fi @@ -997,7 +1105,10 @@ configureNAT() { fi # Check port forwarding flag - if [[ $(< /proc/sys/net/ipv4/ip_forward) -eq 0 ]]; then + [ -r /proc/sys/net/ipv4/ip_forward ] && + forwarding=$(< /proc/sys/net/ipv4/ip_forward) + + if [[ "$forwarding" != "1" ]]; then { sysctl -w net.ipv4.ip_forward=1 > /dev/null 2>&1; rc=$?; } || : if (( rc != 0 )) || [[ $(< /proc/sys/net/ipv4/ip_forward) -eq 0 ]]; then enabled "$ROOTLESS" && ! enabled "$DEBUG" && return 1 @@ -1006,8 +1117,6 @@ configureNAT() { fi fi - local ip exclude subnet - if [ -n "$IP" ]; then ip=$(guestIP "$IP" 2) else @@ -1030,8 +1139,7 @@ configureNAT() { GUEST_MTU=$(minMTU "$GUEST_MTU" "$(getMTU "$BRIDGE")" "$(getMTU "$TAP")") fi - exclude=$(getHostPorts) - configureTables "$ip" "$subnet" "$exclude" || return 1 + configureTables "$ip" "$subnet" || return 1 NET_OPTS="-netdev tap,id=hostnet0,ifname=$TAP" @@ -1065,9 +1173,13 @@ setTables() { testTables() { - # Test actual ruleset access instead of only checking the binary version. - iptables -w -t nat -S > /dev/null 2>&1 || return 1 - iptables-save -t nat > /dev/null 2>&1 || return 1 + local table="" + + # Test every table required by the networking rules. + for table in nat filter; do + iptables -t "$table" -S > /dev/null 2>&1 || return 1 + iptables-save -t "$table" > /dev/null 2>&1 || return 1 + done return 0 } @@ -1075,13 +1187,24 @@ testTables() { selectTables() { local mode="" + local current="" local modes=() - # Prefer nftables for Podman namespaces, but retain legacy first for Docker. - if [[ "${ENGINE,,}" == "podman" ]]; then - modes=( "nft" "legacy" ) - else + # Keep the currently selected backend when it is fully functional. + if testTables; then + return 0 + fi + + current=$(iptables --version 2>/dev/null || true) + + if [[ "$current" == *"nf_tables"* ]]; then + modes=( "legacy" ) + elif [[ "$current" == *"legacy"* ]]; then + modes=( "nft" ) + elif [[ "${ENGINE,,}" == "docker" ]]; then modes=( "legacy" "nft" ) + else + modes=( "nft" "legacy" ) fi for mode in "${modes[@]}"; do @@ -1100,6 +1223,7 @@ clearTables() { local line="" local rules="" local failed="N" + local dnat_chain="QEMU_DNAT" local rule_tag="remove" local re="--comment[[:space:]]+\"?$rule_tag\"?([[:space:]]|\$)" @@ -1107,30 +1231,46 @@ clearTables() { # Store the current iptables ruleset. ! rules=$(iptables-save 2> /dev/null) && return 1 - [ -z "$rules" ] && return 0 - # Delete every rule tagged with our unique identifier, - # leaving all other rules intact. - while IFS= read -r line; do + if [ -n "$rules" ]; then - case "$line" in - \*nat ) table="nat" ;; - \*filter ) table="filter" ;; - \*mangle ) table="mangle" ;; - \*raw ) table="raw" ;; - esac + # Delete every rule tagged with our unique identifier, + # leaving all other rules intact. + while IFS= read -r line; do - if [[ "$line" == -A* ]] && [[ "$line" =~ $re ]]; then - line="${line/-A /-D }" + case "$line" in + \*nat ) table="nat" ;; + \*filter ) table="filter" ;; + \*mangle ) table="mangle" ;; + \*raw ) table="raw" ;; + esac - # Parse the quoting produced by iptables-save before deleting the rule. - if ! printf '%s\n' "$line" | - xargs -r iptables -t "$table" > /dev/null 2>&1; then - failed="Y" + if [[ "$line" == -A* ]] && [[ "$line" =~ $re ]]; then + line="${line/-A /-D }" + + # Parse the quoting produced by iptables-save before deleting the rule. + if ! printf '%s\n' "$line" | + xargs -r iptables -t "$table" > /dev/null 2>&1; then + failed="Y" + fi fi + + done <<< "$rules" + + fi + + # Remove the dedicated DNAT chain after deleting its rules and references. + if iptables -t nat -S "$dnat_chain" > /dev/null 2>&1; then + + if ! iptables -t nat -F "$dnat_chain" > /dev/null 2>&1; then + failed="Y" fi - done <<< "$rules" + if ! iptables -t nat -X "$dnat_chain" > /dev/null 2>&1; then + failed="Y" + fi + + fi enabled "$failed" && return 1 return 0 @@ -1140,11 +1280,14 @@ clearTables() { # Cleanup # ###################################### -closeBridge() { +closeInterfaces() { local pids=( "$PASST_PID" "$DNSMASQ_PID" ) mKill "${pids[@]}" + exec 30>&- 2>/dev/null || true + exec 40>&- 2>/dev/null || true + ip link set "$TAP" down promisc off &> /dev/null || : ip link delete "$TAP" &> /dev/null || : @@ -1157,7 +1300,7 @@ closeBridge() { closeWeb() { - local pids=( "$WEB_PID" "$WSD_PID" ) + local pids=( "${WEB_PID:-}" "${WSD_PID:-}" ) mKill "${pids[@]}" return 0 @@ -1171,17 +1314,14 @@ closeNetwork() { disabled "$NETWORK" && return 0 - exec 30>&- 2>/dev/null || true - exec 40>&- 2>/dev/null || true - - closeBridge + closeInterfaces return 0 } cleanUp() { - closeBridge + closeInterfaces # Clean up old files rm -f "$PASST_PID" "$PASST_SOCKET" @@ -1383,8 +1523,7 @@ configureMAC() { exit 28 fi - # Keep the guest-facing gateway MAC stable across runs, otherwise Windows guests - # may detect a new network every boot. + # Keep the guest-facing gateway MAC stable across runs. GATEWAY_MAC=$(gatewayMAC "$MAC") return 0 @@ -1578,7 +1717,7 @@ else # Configure tap interface if ! configureNAT; then - closeBridge + closeInterfaces NETWORK="user" if ! enabled "$ROOTLESS" || enabled "$DEBUG"; then