summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAdam Smith <adamsmith@yzguy.io>2026-10-08 01:20:09 +0000
committerChristian Breunig <christian@breunig.cc>2026-11-07 22:19:52 +0100
commit9babe4468dec43d2df20488bacd3877d77c42b35 (patch)
tree5e2b7e9114411a9e5a17efdecbd83a68bcf087b6 /src
parentd6902e6423ec78b5b99d96b6427ebee771ea6c38 (diff)
downloadvyos-1x-9babe4468dec43d2df20488bacd3877d77c42b35.tar.gz
vyos-1x-9babe4468dec43d2df20488bacd3877d77c42b35.zip
wwan: T9388: apply a static bearer's address/gateway/DNS directly
QMI raw-ip modems get their address, gateway and DNS from ModemManager as a "static" bearer instead of over a DHCP(v6) or Router Advertisement exchange, so "address dhcp", "address dhcpv6" and "ipv6 address autoconf" never produced a usable config and only worked for IPv4 by accident. Check the interface's connected bearer first. If its method for the family is static, apply it directly; if it is dhcp, or there is no bearer yet, fall through to the existing DHCP(v6) client / kernel SLAAC unchanged. - Address and DNS are applied from the bearer. DNS is registered with vyos-hostsd under the same dhcp-<if>/dhcpv6-<if> tags a real client uses. A DHCP(v6) client which was started while the bearer was still missing, including one which keeps restarting, is stopped before the settings are applied. - The default route is rendered into FRR as a tag 210 static route, the same way a DHCP one is. WWANIf records the gateway of the bearer in /run/vyos-wwan, which the new get_wwan_router() filter reads, and a new block in staticd.frr.j2 fed by get_wwan_interfaces() renders the IPv4 and IPv6 default routes. Distance, no-default-route and VRFs behave as for DHCP, also for an interface without "address dhcp". frrender notices a gateway change, and WWANIf reconciles FRR with vyos-frr-render.py whenever the recorded gateway changes, the way the dhclient lease hook does, so op-mode reconnect is covered as well. The state is written atomically, as the renderer reads it concurrently. - The address installed from the bearer is recorded in /run/vyos-wwan too. It is removed on reconnect, when the family is no longer configured and when the interface goes away, and nothing else on the interface is ever touched. - vyos-netlinkd applies the bearer again after it re-dials a lost session, as the new session can come back with a different address or gateway. A DHCP client used to pick that up on its own. What a previous session left behind is dropped for a family the new one does not supply, the configured DHCP(v6) client or SLAAC is started for it as a commit would, and an incomplete result is retried on the following reconcile passes for a while. - The bearer is picked by its connected status rather than bearers[0], and its config is polled briefly with wait_for() until it is no longer missing or a placeholder, since it can be populated slightly after the bearer reports connected.
Diffstat (limited to 'src')
-rwxr-xr-xsrc/services/vyos-netlinkd71
-rw-r--r--src/tests/test_utils_wwan.py53
2 files changed, 124 insertions, 0 deletions
diff --git a/src/services/vyos-netlinkd b/src/services/vyos-netlinkd
index d6cc3c379..87cb15286 100755
--- a/src/services/vyos-netlinkd
+++ b/src/services/vyos-netlinkd
@@ -86,12 +86,21 @@ WWAN_RECONCILE_INTERVAL = 30
WWAN_BACKOFF_MIN = 30
WWAN_BACKOFF_MAX = 900
+# How often a static bearer which could not be applied completely after a
+# re-dial is tried again, one reconcile pass apart. The settings of a family
+# can arrive late, or the network may never supply it.
+WWAN_REAPPLY_RETRIES = 10
+
_wwan_next_attempt: dict[str, float] = {}
_wwan_backoff: dict[str, int] = {}
# Interfaces with a dial in flight. Dialling blocks for tens of seconds, so it
# runs on a worker thread and must not be started twice for one interface.
_wwan_dialing: set[str] = set()
+
+# Interfaces whose bearer is still to be applied completely, with the number of
+# attempts left.
+_wwan_reapply_pending: dict[str, int] = {}
_wwan_lock = threading.Lock()
# Legacy re-dial cron job, superseded by _reconcile_wwan(). Only present on a
@@ -285,6 +294,50 @@ def _wwan_dial_wanted(ifname: str) -> bool:
return True
+def _wwan_reapply(ifname: str) -> None:
+ """ Re-apply the addressing of a re-dialled WWAN interface. An incomplete
+ result is left pending for _reconcile_wwan() to try again. """
+ from vyos.config import Config
+ from vyos.configdict import get_interface_dict
+ from vyos.ifconfig import WWANIf
+
+ left = _wwan_reapply_pending.get(ifname, WWAN_REAPPLY_RETRIES)
+ try:
+ _, config = get_interface_dict(Config(), ['interfaces', 'wwan'], ifname)
+ complete = WWANIf(ifname).reapply_bearer(config)
+ except Exception as e:
+ syslog.syslog(syslog.LOG_ERR, f'Error applying the bearer of {ifname}: {e}')
+ complete = False
+
+ if complete:
+ _wwan_reapply_pending.pop(ifname, None)
+ elif left > 1:
+ _wwan_reapply_pending[ifname] = left - 1
+ else:
+ _wwan_reapply_pending.pop(ifname, None)
+ syslog.syslog(syslog.LOG_WARNING,
+ f'Giving up applying the bearer of {ifname}, '
+ 'its settings stayed incomplete')
+
+def _wwan_retry_reapply(ifname: str) -> None:
+ """ One more attempt at a bearer which was left incomplete. Runs on a
+ worker thread, as the bearer is polled for its settings. """
+ try:
+ if _wwan_dial_wanted(ifname):
+ _wwan_reapply(ifname)
+ finally:
+ with _wwan_lock:
+ _wwan_dialing.discard(ifname)
+
+def _wwan_retry_reapply_async(ifname: str) -> None:
+ with _wwan_lock:
+ if ifname in _wwan_dialing:
+ return None
+ _wwan_dialing.add(ifname)
+
+ threading.Thread(target=_wwan_retry_reapply, args=(ifname,),
+ name=f'wwan-reapply-{ifname}', daemon=True).start()
+
def _wwan_dial(ifname: str, config: dict) -> None:
""" Re-establish a lost WWAN session. Runs on a worker thread - dialling
blocks for as long as the modem takes to answer, and the event loop must
@@ -314,6 +367,13 @@ def _wwan_dial(ifname: str, config: dict) -> None:
syslog.syslog(syslog.LOG_INFO, f'Re-dialled WWAN interface {ifname}')
_wwan_clear_backoff(ifname)
+
+ # The new session can come back with a different address or
+ # gateway, so a static bearer has to be applied again - unless a
+ # commit started meanwhile, which applies it itself.
+ _wwan_reapply_pending[ifname] = WWAN_REAPPLY_RETRIES
+ if _wwan_dial_wanted(ifname):
+ _wwan_reapply(ifname)
return None
_wwan_defer(ifname, f'Failed to re-dial WWAN interface {ifname}')
@@ -362,8 +422,16 @@ def _reconcile_wwan() -> None:
['interfaces', 'wwan'], key_mangling=('-', '_'), get_first_key=True
)
if not config_dict:
+ _wwan_reapply_pending.clear()
return None
+ # Nothing left to apply for an interface which is gone, disabled or
+ # administratively disconnected
+ for ifname in list(_wwan_reapply_pending):
+ if (ifname not in config_dict or 'disable' in config_dict[ifname]
+ or is_admin_disconnected(ifname)):
+ _wwan_reapply_pending.pop(ifname, None)
+
for ifname, config in config_dict.items():
# Do not dial an interface the user has explicitly disabled, but let it
# start from a clean slate once it is enabled again.
@@ -380,6 +448,9 @@ def _reconcile_wwan() -> None:
if is_wwan_connected(ifname):
_wwan_clear_backoff(ifname)
+ # The session is up but its bearer was not applied completely
+ if ifname in _wwan_reapply_pending:
+ _wwan_retry_reapply_async(ifname)
continue
if monotonic() < _wwan_next_attempt.get(ifname, 0):
diff --git a/src/tests/test_utils_wwan.py b/src/tests/test_utils_wwan.py
index 1114745ea..dd53bd5d0 100644
--- a/src/tests/test_utils_wwan.py
+++ b/src/tests/test_utils_wwan.py
@@ -12,8 +12,13 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
+import os
+import stat
+import tempfile
from unittest import TestCase
+from unittest.mock import patch
+from vyos.utils import wwan
from vyos.utils.wwan import connect_options
autoconf = {'address': {'autoconf': {}}}
@@ -48,3 +53,51 @@ class TestUtilsWWAN(TestCase):
connect_options(config),
'ip-type=ipv4,apn=internet,user=user,password=secret',
)
+
+
+class TestUtilsWWANBearerState(TestCase):
+ def setUp(self):
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ patcher = patch.object(wwan, 'bearer_state_dir', tmp.name)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_nothing_recorded(self):
+ self.assertIsNone(wwan.get_bearer_router('wwan0', 'ipv4'))
+ self.assertIsNone(wwan.get_bearer_address('wwan0', 'ipv6'))
+
+ def test_round_trip_per_interface_and_family(self):
+ wwan.set_bearer_router('wwan0', 'ipv4', '192.0.2.1')
+ wwan.set_bearer_router('wwan0', 'ipv6', '2001:db8::1')
+ wwan.set_bearer_address('wwan0', 'ipv4', '192.0.2.2/30')
+ wwan.set_bearer_router('wwan1', 'ipv4', '198.51.100.1')
+
+ self.assertEqual(wwan.get_bearer_router('wwan0', 'ipv4'), '192.0.2.1')
+ self.assertEqual(wwan.get_bearer_router('wwan0', 'ipv6'), '2001:db8::1')
+ self.assertEqual(wwan.get_bearer_address('wwan0', 'ipv4'), '192.0.2.2/30')
+ self.assertEqual(wwan.get_bearer_router('wwan1', 'ipv4'), '198.51.100.1')
+ # the router and the address of a family are kept apart
+ self.assertIsNone(wwan.get_bearer_address('wwan0', 'ipv6'))
+
+ def test_empty_value_forgets(self):
+ wwan.set_bearer_router('wwan0', 'ipv4', '192.0.2.1')
+ wwan.set_bearer_router('wwan0', 'ipv4', None)
+ self.assertIsNone(wwan.get_bearer_router('wwan0', 'ipv4'))
+ # forgetting something that was never recorded is not an error
+ wwan.set_bearer_address('wwan0', 'ipv6', None)
+
+ def test_write_is_atomic(self):
+ wwan.set_bearer_router('wwan0', 'ipv4', '192.0.2.1')
+ # a failure while replacing leaves the old value in place and no
+ # temporary file behind
+ with patch.object(wwan.os, 'replace', side_effect=OSError):
+ with self.assertRaises(OSError):
+ wwan.set_bearer_router('wwan0', 'ipv4', '198.51.100.1')
+ self.assertEqual(wwan.get_bearer_router('wwan0', 'ipv4'), '192.0.2.1')
+ self.assertEqual(os.listdir(wwan.bearer_state_dir), ['wwan0.ipv4-router'])
+
+ def test_state_file_is_readable_by_others(self):
+ wwan.set_bearer_address('wwan0', 'ipv4', '192.0.2.2/30')
+ path = os.path.join(wwan.bearer_state_dir, 'wwan0.ipv4-address')
+ self.assertEqual(stat.S_IMODE(os.stat(path).st_mode), 0o644)