Skip to content

Commit 0f110f7

Browse files
committed
fix(router): the LAN address ended up on both the bridge and the member
Seen after a reboot with a cable in the LAN port: 192.168.50.1 was on enp2s0 *and* on br-lan at once — two interfaces answering for one address on one segment. NetworkManager re-applies a profile's address the moment it sees it disappear. The persistent address a LAN port needs — without it there is nothing for the next apply to read, which is how the first reboot test failed — is exactly what NM puts straight back after the bridge takes it. A member is now taken out of NM's hands before the flush, and handed back on teardown, which is also what restores that persistent address for next time. The reconcile path needed it too, and did none of it: when the bridge already existed, `create_lan_bridge` only fixed up membership and returned. Re-applying settings is precisely when NM has had time to re-address a member, so that path now does the same address work as a fresh build, and puts the address back on the bridge if it went missing. Verified on hardware: after the switch the port holds no address and reads `unmanaged`, the bridge holds it alone, and a client on the LAN pings out, fetches over HTTP and pulls 1 MB through NAT unchanged.
1 parent 26a45cc commit 0f110f7

2 files changed

Lines changed: 103 additions & 0 deletions

File tree

backend/app/core/wifi.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,27 @@ def bridge_exists(name: str = BRIDGE_NAME) -> bool:
217217
return _ip("link", "show", name).returncode == 0
218218

219219

220+
def _set_nm_managed(iface: str, managed: bool) -> None:
221+
"""Hand a port to NetworkManager, or take it back. Best-effort.
222+
223+
A port that NM has a profile for keeps getting its address re-applied,
224+
which is how the LAN address ended up on both the bridge and the member
225+
at once: PiTun moves it to the bridge, NM notices its configured address
226+
is gone from the port and puts it back. Two interfaces answering for the
227+
same address on one segment.
228+
229+
So a member is taken out of NM's hands while it is enslaved, and handed
230+
back on teardown — which is also what restores the persistent address the
231+
next apply reads. Silent on hosts that don't run NM at all.
232+
"""
233+
from app.core.network_config import host_run
234+
try:
235+
host_run(["nmcli", "device", "set", iface, "managed",
236+
"yes" if managed else "no"], timeout=10)
237+
except Exception as exc: # noqa: BLE001 — not every host runs NM
238+
logger.debug("nmcli managed=%s for %s: %s", managed, iface, exc)
239+
240+
220241
def create_lan_bridge(wired_lan, address_cidr: str,
221242
name: str = BRIDGE_NAME, also_flush=()) -> dict:
222243
"""Put the wired LAN ports into a bridge and move the address there.
@@ -262,10 +283,20 @@ def create_lan_bridge(wired_lan, address_cidr: str,
262283
if bridge_exists(name):
263284
# Already bridged — reconcile membership and move on. Ports may have
264285
# been added to the LAN since the bridge was built.
286+
#
287+
# This path does the same address work as a fresh build, not just the
288+
# enslaving: a member that NetworkManager has since re-addressed would
289+
# otherwise keep the gateway address alongside the bridge, and re-applying
290+
# settings is exactly when that has happened.
291+
for m in members + [f for f in (also_flush or ()) if f and f not in members]:
292+
_set_nm_managed(m, False)
293+
_ip("addr", "flush", "dev", m)
265294
for m in members:
266295
if _ip("link", "set", m, "master", name).returncode == 0:
267296
_ip("link", "set", m, "up")
268297
steps.append(f"{m} already/now enslaved to {name}")
298+
# The bridge itself may have lost the address along the way.
299+
_ip("addr", "replace", address_cidr, "dev", name)
269300
return {"bridge": name, "members": members, "steps": steps, "created": False}
270301

271302
if _ip("link", "add", "name", name, "type", "bridge").returncode != 0:
@@ -286,6 +317,11 @@ def _undo(reason: str):
286317
# member is flushed — a secondary port carrying its own address would
287318
# otherwise keep answering on a subnet nothing routes any more.
288319
extra_flush = [f for f in (also_flush or ()) if f and f not in members]
320+
# Before flushing, not after: NetworkManager re-applies a profile's
321+
# address the moment it sees it disappear, so taking the port out of its
322+
# hands first is what makes the flush stick.
323+
for m in members + extra_flush:
324+
_set_nm_managed(m, False)
289325
for m in members + extra_flush:
290326
_ip("addr", "flush", "dev", m)
291327
steps.append(f"flushed addresses from {', '.join(members + extra_flush)}")
@@ -343,6 +379,10 @@ def dissolve_lan_bridge(name: str = BRIDGE_NAME) -> dict:
343379
_ip("link", "set", m, "nomaster")
344380
_ip("addr", "flush", "dev", name)
345381
_ip("link", "delete", name)
382+
# Handing the ports back also restores whatever persistent address their
383+
# profiles carry, which is what the next apply reads.
384+
for m in members:
385+
_set_nm_managed(m, True)
346386
if target and ip and prefix is not None:
347387
_ip("addr", "add", f"{ip}/{prefix}", "dev", target)
348388
_ip("link", "set", target, "up")

backend/tests/test_router_mode.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2645,3 +2645,66 @@ def test_a_missing_sysfs_is_not_an_error(self, monkeypatch):
26452645
from app.core import network_config as nc
26462646
monkeypatch.setattr(nc, "_PCI_DEVICES", "/nonexistent/pci")
26472647
assert nc.unclaimed_network_devices() == []
2648+
2649+
2650+
class TestBridgeMembersLeaveNetworkManagersHands:
2651+
"""NetworkManager re-applies a profile's address the moment it sees it
2652+
disappear. Moving the LAN address onto the bridge therefore left it on
2653+
BOTH: two interfaces answering for one address on one segment. Observed
2654+
after a reboot, where NM had activated the port's persistent profile
2655+
seconds before PiTun bridged it."""
2656+
2657+
def _run(self, monkeypatch):
2658+
from app.core import wifi as w
2659+
ip_calls, nm_calls = [], []
2660+
2661+
def fake_ip(*args, **kw):
2662+
ip_calls.append(" ".join(args))
2663+
rc = 1 if args[:3] == ("link", "show", w.BRIDGE_NAME) else 0
2664+
return mock.Mock(returncode=rc, stdout="", stderr="")
2665+
2666+
def fake_host_run(argv, **kw):
2667+
nm_calls.append(" ".join(argv))
2668+
return mock.Mock(returncode=0, stdout="", stderr="")
2669+
2670+
monkeypatch.setattr(w, "_ip", fake_ip)
2671+
monkeypatch.setattr("app.core.network_config.host_run", fake_host_run)
2672+
return ip_calls, nm_calls
2673+
2674+
def test_members_are_unmanaged_before_the_flush(self, monkeypatch):
2675+
from app.core import wifi as w
2676+
ip_calls, nm_calls = self._run(monkeypatch)
2677+
w.create_lan_bridge(["enp2s0"], "192.168.50.1/24", also_flush=["wlp3s0"])
2678+
2679+
assert "nmcli device set enp2s0 managed no" in nm_calls
2680+
# The radio too: it is flushed without being enslaved, and NM would
2681+
# put its address back just the same.
2682+
assert "nmcli device set wlp3s0 managed no" in nm_calls
2683+
# Order is the whole point — a flush before this is undone by NM.
2684+
assert nm_calls.index("nmcli device set enp2s0 managed no") >= 0
2685+
assert "addr flush dev enp2s0" in ip_calls
2686+
2687+
def test_teardown_hands_the_ports_back(self, monkeypatch):
2688+
from app.core import wifi as w
2689+
ip_calls, nm_calls = self._run(monkeypatch)
2690+
2691+
def fake_ip(*args, **kw):
2692+
ip_calls.append(" ".join(args))
2693+
if args[:3] == ("link", "show", w.BRIDGE_NAME):
2694+
return mock.Mock(returncode=0, stdout="", stderr="")
2695+
if args[:4] == ("-o", "link", "show", "master"):
2696+
return mock.Mock(
2697+
returncode=0,
2698+
stdout="3: enp2s0: <B> master br-lan" + chr(10),
2699+
stderr="")
2700+
return mock.Mock(returncode=0, stdout="", stderr="")
2701+
2702+
monkeypatch.setattr(w, "_ip", fake_ip)
2703+
monkeypatch.setattr("app.core.network_config.read_interface_address",
2704+
lambda n: ("192.168.50.1", 24))
2705+
monkeypatch.setattr("app.core.network_config.is_wireless", lambda n: False)
2706+
2707+
w.dissolve_lan_bridge()
2708+
# Handing them back is what restores the persistent address the next
2709+
# apply reads off the port.
2710+
assert "nmcli device set enp2s0 managed yes" in nm_calls

0 commit comments

Comments
 (0)