diff options
| author | Adam Smith <adamsmith@yzguy.io> | 2026-10-08 01:20:09 +0000 |
|---|---|---|
| committer | Christian Breunig <christian@breunig.cc> | 2026-11-07 22:19:52 +0100 |
| commit | 9babe4468dec43d2df20488bacd3877d77c42b35 (patch) | |
| tree | 5e2b7e9114411a9e5a17efdecbd83a68bcf087b6 /src | |
| parent | d6902e6423ec78b5b99d96b6427ebee771ea6c38 (diff) | |
| download | vyos-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-x | src/services/vyos-netlinkd | 71 | ||||
| -rw-r--r-- | src/tests/test_utils_wwan.py | 53 |
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) |
