From 87bd4f8c7f88e05365603ba7553edaced3a0e2c7 Mon Sep 17 00:00:00 2001 From: Sem <931684+sembrestels@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:59:33 +0200 Subject: [PATCH] fix: enable WiFi forwarding and respect NAT setting --- README.md | 17 +++++ setup-wizard.yml | 7 +- src/Dockerfile | 1 + src/entrypoint.sh | 25 +++---- src/network.sh | 64 ++++++++++++++++ tests/network-namespace.sh | 55 ++++++++++++++ tests/test_network.py | 147 +++++++++++++++++++++++++++++++++++++ 7 files changed, 299 insertions(+), 17 deletions(-) create mode 100644 src/network.sh create mode 100644 tests/network-namespace.sh create mode 100644 tests/test_network.py diff --git a/README.md b/README.md index 40e8385..9c45a1b 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,23 @@ $ docker-compose up -d $ docker-compose down ``` +### NAT and routed mode + +`NAT=true` (the default) enables masquerading for the WiFi subnet. Set +`NAT=false` in Advanced Options to use routed mode; the upstream network must +then have a return route to the WiFi subnet. Both modes enable IPv4 forwarding +and `ip_dynaddr`. Other NAT values cause startup to fail before moving the radio. +Repeated network setup does not add duplicate masquerade rules. Switching to +routed mode removes exact copies of the package's existing rule, including +duplicates from older versions, and preserves other rules. + +Run the isolated regression tests without a radio or firewall privileges: + +```sh +python3 -m unittest discover -s tests -v +bash -n src/entrypoint.sh src/network.sh +``` + ### Status ``` diff --git a/setup-wizard.yml b/setup-wizard.yml index 99f81b2..59ed979 100644 --- a/setup-wizard.yml +++ b/setup-wizard.yml @@ -44,6 +44,11 @@ fields: name: NAT title: NAT description: >- - Configure WIFI as NAT + Use true to masquerade WiFi traffic. Use false for routed mode, which + requires an upstream return route to the WiFi subnet. IPv4 forwarding + remains enabled in both modes. + enum: + - "true" + - "false" required: true if: { "advanced": { "enum": ["yes"] } } diff --git a/src/Dockerfile b/src/Dockerfile index 11902cb..b799f4b 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -11,6 +11,7 @@ RUN apk add --no-cache 'ca-certificates>=20261020' 'ca-certificates-bundle>=2026 # Entrypoint executable COPY entrypoint.sh /usr/bin/entrypoint.sh +COPY network.sh /usr/bin/network.sh RUN chmod u+x /usr/bin/entrypoint.sh ENTRYPOINT [ "/usr/bin/entrypoint.sh" ] diff --git a/src/entrypoint.sh b/src/entrypoint.sh index a8b3c87..c430db1 100644 --- a/src/entrypoint.sh +++ b/src/entrypoint.sh @@ -206,27 +206,19 @@ EOF fi } -# IP forwarding -function ip_forward { - for i in ip_dynaddr ip_forward ; do - if [ $(cat /proc/sys/net/ipv4/$i) ]; then - echo -e "${BLUE}[INFO]${NC} $i already 1" - else - echo "1" > /proc/sys/net/ipv4/$i - fi - done -} +# Forwarding and NAT are kept separate: routed mode also needs forwarding. +source "$(dirname "${BASH_SOURCE[0]}")/network.sh" # Configure IP settings (address, subnet, ip forwarding, ip tables) function ip_setup { echo -e "${BLUE}[INFO]${NC} Removing addresses from interface ${INTERFACE}" - ip addr flush dev "${INTERFACE}" + ip addr flush dev "${INTERFACE}" || return 1 echo -e "${BLUE}[INFO]${NC} Attaching wifi address to interface ${INTERFACE}" - ip addr add "${AP_ADDR}/24" dev "${INTERFACE}" + ip addr add "${AP_ADDR}/24" dev "${INTERFACE}" || return 1 echo -e "${BLUE}[INFO]${NC} Enabling ip_dynaddr, ip_forward" - ip_forward - echo -e "${BLUE}[INFO]${NC} Generating ip tables for subnet ${SUBNET}" - iptables -t nat -A POSTROUTING -s "${SUBNET}/24" ! -d "${SUBNET}/24" -j MASQUERADE + ip_forward || return 1 + echo -e "${BLUE}[INFO]${NC} Configuring NAT=${NAT} for subnet ${SUBNET}" + nat_setup || return 1 } # Execute binaries for hostapd and dnsmasq, do not change the current order of execution. Another approach would be to use them as services and execute them on startup (check Dockerfile comment) @@ -274,10 +266,11 @@ trap 'sigterm_handler' TERM INT # MAIN # ######## +validate_nat || exit 1 print_banner get_interface get_phy phy_setup interface_setup -ip_setup +ip_setup || exit 1 service_start diff --git a/src/network.sh b/src/network.sh new file mode 100644 index 0000000..886c48e --- /dev/null +++ b/src/network.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# Shared with the isolated network regression tests. The optional directory +# argument lets tests exercise forwarding without touching real sysctls. +function ip_forward { + local sysctl_dir="${1:-/proc/sys/net/ipv4}" + local setting value + for setting in ip_dynaddr ip_forward; do + if ! value=$(cat "${sysctl_dir}/${setting}"); then + echo "[ERROR] Cannot read IPv4 ${setting}" >&2 + return 1 + fi + if [ "$value" = "1" ]; then + echo "[INFO] ${setting} already 1" + else + echo "[INFO] Enabling ${setting}" + if ! printf '1\n' > "${sysctl_dir}/${setting}"; then + echo "[ERROR] Cannot enable IPv4 ${setting}" >&2 + return 1 + fi + fi + done +} + +function validate_nat { + case "$NAT" in + true|false) ;; + *) echo "[ERROR] NAT must be true or false" >&2; return 1 ;; + esac +} + +function nat_setup { + validate_nat || return 1 + # Match the existing package rule exactly, including rules from old images. + # Never flush a chain or change rules for other subnets/targets. + local -a rule=(POSTROUTING -s "${SUBNET}/24" ! -d "${SUBNET}/24" -j MASQUERADE) + local status + while true; do + if iptables -w 5 -t nat -C "${rule[@]}" 2>/dev/null; then + status=0 + else + status=$? + fi + case "$status" in + 0) + if [ "$NAT" = true ]; then + return 0 + fi + # Remove all copies left by older, non-idempotent startups. + iptables -w 5 -t nat -D "${rule[@]}" || return 1 + ;; + 1) + if [ "$NAT" = true ]; then + iptables -w 5 -t nat -A "${rule[@]}" || return 1 + fi + return 0 + ;; + *) + echo "[ERROR] Cannot inspect WiFi NAT rule (iptables status ${status})" >&2 + return 1 + ;; + esac + done +} diff --git a/tests/network-namespace.sh b/tests/network-namespace.sh new file mode 100644 index 0000000..8116d79 --- /dev/null +++ b/tests/network-namespace.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Run only in a disposable --network none container, never on a live Dappnode. +set -euo pipefail +if [ "$(ls /sys/class/net)" != lo ]; then + echo "This test requires an isolated network namespace with only loopback." >&2 + exit 1 +fi +source /usr/bin/network.sh +export SUBNET=10.20.12.0 NAT=true +rule=(POSTROUTING -s "${SUBNET}/24" ! -d "${SUBNET}/24" -j MASQUERADE) +unrelated=(POSTROUTING -s 192.0.2.0/24 -j MASQUERADE) +rule_count() { iptables -t nat -S POSTROUTING | grep -c '^-A ' || true; } + +printf '0\n' > /proc/sys/net/ipv4/ip_dynaddr +printf '0\n' > /proc/sys/net/ipv4/ip_forward +ip_forward +test "$(cat /proc/sys/net/ipv4/ip_dynaddr)" = 1 +test "$(cat /proc/sys/net/ipv4/ip_forward)" = 1 +ip_forward +echo 'PASS: real namespace sysctls 0 -> 1 and repeated setup' + +iptables -t nat -A "${unrelated[@]}" +nat_setup +nat_setup +iptables -t nat -C "${rule[@]}" +test "$(rule_count)" = 2 +echo 'PASS: NAT enabled without duplicate rules' + +# Reproduce duplicate rules left by older entrypoints before changing modes. +iptables -t nat -A "${rule[@]}" +iptables -t nat -A "${rule[@]}" +NAT=false +nat_setup +nat_setup +test "$(rule_count)" = 1 +iptables -t nat -C "${unrelated[@]}" +test "$(cat /proc/sys/net/ipv4/ip_forward)" = 1 +echo 'PASS: NAT disabled; legacy duplicates removed; unrelated rule and forwarding retained' + +NAT=true +nat_setup +test "$(rule_count)" = 2 +iptables -t nat -C "${unrelated[@]}" +echo 'PASS: transition back to NAT enabled' + +if NAT=invalid /usr/bin/entrypoint.sh > /tmp/invalid-nat.log 2>&1; then + echo 'Invalid NAT unexpectedly started the package' >&2 + exit 1 +fi +grep -q 'NAT must be true or false' /tmp/invalid-nat.log +test "$(rule_count)" = 2 +echo 'PASS: packaged entrypoint rejects invalid NAT before changing networking' +bash -n /usr/bin/entrypoint.sh /usr/bin/network.sh +dnsmasq --test +echo 'PASS: packaged shell and dnsmasq configuration checks' diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..bd5bdb8 --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,147 @@ +"""Exercise network state transitions with fixture sysctls and an iptables model.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +RULE = ["POSTROUTING", "-s", "10.20.12.0/24", "!", "-d", "10.20.12.0/24", "-j", "MASQUERADE"] +UNRELATED = ["POSTROUTING", "-s", "192.0.2.0/24", "-j", "MASQUERADE"] +FAKE_IPTABLES = r''' +import json, os, pathlib, sys +args = sys.argv[1:] +if args[:4] != ["-w", "5", "-t", "nat"]: + sys.exit(2) +operation, rule = args[4], args[5:] +if operation == os.environ.get("FAIL_OPERATION"): + sys.exit(int(os.environ.get("FAIL_STATUS", "4"))) +path = pathlib.Path(os.environ["RULES_FILE"]) +rules = json.loads(path.read_text()) +if operation == "-C": + sys.exit(0 if rule in rules else 1) +if operation == "-A": + rules.append(rule) +elif operation == "-D" and rule in rules: + rules.remove(rule) +else: + sys.exit(2) +path.write_text(json.dumps(rules)) +''' + + +class NetworkTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.directory = Path(self.temp.name) + self.sysctls = self.directory / "sysctls" + self.sysctls.mkdir() + self.set_forwarding("0", "0") + self.rules_file = self.directory / "rules.json" + self.rules_file.write_text("[]") + binary = self.directory / "iptables" + binary.write_text(f"#!{sys.executable}\n" + FAKE_IPTABLES) + binary.chmod(0o755) + self.env = dict(os.environ, PATH=f"{self.directory}:{os.environ['PATH']}", + RULES_FILE=str(self.rules_file), SUBNET="10.20.12.0", NAT="true") + + def set_forwarding(self, dynaddr, forwarding): + (self.sysctls / "ip_dynaddr").write_text(dynaddr + "\n") + (self.sysctls / "ip_forward").write_text(forwarding + "\n") + + def run_network(self, commands, **env): + return subprocess.run(["bash", "-c", 'source "$1"; ' + commands, + "network-test", str(ROOT / "src/network.sh"), str(self.sysctls)], + env=dict(self.env, **env), text=True, capture_output=True) + + def assert_success(self, result): + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def rules(self): + return json.loads(self.rules_file.read_text()) + + def test_disabled_forwarding_is_enabled(self): + self.assert_success(self.run_network('ip_forward "$2"')) + self.assertEqual((self.sysctls / "ip_dynaddr").read_text(), "1\n") + self.assertEqual((self.sysctls / "ip_forward").read_text(), "1\n") + + def test_enabled_forwarding_is_not_rewritten(self): + self.set_forwarding("1", "1") + before = {path.name: path.stat().st_mtime_ns for path in self.sysctls.iterdir()} + self.assert_success(self.run_network('ip_forward "$2"')) + self.assertEqual(before, {path.name: path.stat().st_mtime_ns for path in self.sysctls.iterdir()}) + + def test_mixed_forwarding_state_is_enabled(self): + self.set_forwarding("1", "0") + self.assert_success(self.run_network('ip_forward "$2"')) + self.assertEqual((self.sysctls / "ip_forward").read_text(), "1\n") + + def test_missing_sysctl_fails(self): + (self.sysctls / "ip_forward").unlink() + result = self.run_network('ip_forward "$2"') + self.assertNotEqual(result.returncode, 0) + self.assertIn("Cannot read IPv4 ip_forward", result.stderr) + + @unittest.skipIf(os.geteuid() == 0, "Run as a non-root user to exercise filesystem write denial") + def test_readonly_sysctl_fails(self): + (self.sysctls / "ip_forward").chmod(0o444) + result = self.run_network('ip_forward "$2"') + self.assertNotEqual(result.returncode, 0) + self.assertIn("Cannot enable IPv4 ip_forward", result.stderr) + + def test_nat_default_and_repeated_setup(self): + self.assert_success(self.run_network("nat_setup && nat_setup")) + self.assertEqual(self.rules(), [RULE]) + + def test_nat_false_adds_no_rule(self): + self.assert_success(self.run_network("nat_setup", NAT="false")) + self.assertEqual(self.rules(), []) + + def test_nat_false_removes_legacy_duplicates_only(self): + self.rules_file.write_text(json.dumps([UNRELATED, RULE, RULE])) + self.assert_success(self.run_network("nat_setup && nat_setup", NAT="false")) + self.assertEqual(self.rules(), [UNRELATED]) + + def test_nat_mode_transitions(self): + self.rules_file.write_text(json.dumps([UNRELATED])) + for mode, expected in [("true", [UNRELATED, RULE]), ("false", [UNRELATED]), + ("true", [UNRELATED, RULE])]: + self.assert_success(self.run_network("nat_setup", NAT=mode)) + self.assertEqual(self.rules(), expected) + + def test_forwarding_is_independent_of_nat(self): + for mode in ["true", "false"]: + self.set_forwarding("0", "0") + self.assert_success(self.run_network('ip_forward "$2" && nat_setup', NAT=mode)) + self.assertEqual((self.sysctls / "ip_forward").read_text(), "1\n") + + def test_invalid_nat_values_fail_without_changing_rules(self): + for mode in ["yes", "1", "FALSE", "", "false; echo invalid"]: + result = self.run_network("nat_setup", NAT=mode) + self.assertNotEqual(result.returncode, 0, mode) + self.assertEqual(self.rules(), []) + + def test_rule_inspection_errors_fail_in_both_modes(self): + for mode in ["true", "false"]: + result = self.run_network("nat_setup", NAT=mode, FAIL_OPERATION="-C") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.rules(), []) + + def test_rule_append_error_fails(self): + result = self.run_network("nat_setup", FAIL_OPERATION="-A") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.rules(), []) + + def test_rule_delete_error_fails(self): + self.rules_file.write_text(json.dumps([RULE])) + result = self.run_network("nat_setup", NAT="false", FAIL_OPERATION="-D") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.rules(), [RULE]) + + +if __name__ == "__main__": + unittest.main()