Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
7 changes: 6 additions & 1 deletion setup-wizard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] } }
1 change: 1 addition & 0 deletions src/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
25 changes: 9 additions & 16 deletions src/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
64 changes: 64 additions & 0 deletions src/network.sh
Original file line number Diff line number Diff line change
@@ -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
}
55 changes: 55 additions & 0 deletions tests/network-namespace.sh
Original file line number Diff line number Diff line change
@@ -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'
147 changes: 147 additions & 0 deletions tests/test_network.py
Original file line number Diff line number Diff line change
@@ -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()