From f148cc7c47a7fc6f81a06817de5f24e0836d0792 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Thu, 24 Sep 2026 15:26:28 +0000 Subject: wwan: T9350: move session re-dial from cron into vyos-netlinkd /etc/cron.d/vyos-wwan re-ran the conf_mode script every 5 minutes via op-mode "connect interface wwanN" to re-establish a session lost to RF signal loss. Re-entering conf_mode from cron is unsafe: the cron job knows nothing about the boot commit or about a commit in flight, so when a tick lands before the running config is populated, get_config() returns nothing but {'ifname', 'deleted'} and apply() takes the teardown branch instead of dialling. It then stops the DHCP clients, stops ModemManager and unlinks its own cron file, leaving wwan0 down with no way to recover until the next commit: CRON[1869]: (root) CMD (/usr/libexec/vyos/vyos-check-wwan.py) systemd[1]: Stopping DHCP client on wwan0... systemd[1]: Stopping Modem Manager... This is timing dependent - it only bites when a */5 tick coincides with boot - which is why it presents as an intermittently dead WWAN interface. Move the re-dial into vyos-netlinkd, which already tracks these interfaces for DHCP and already gates all of its work behind boot_configuration_complete() and commit_in_progress2(). A re-dial can therefore no longer land on a half-applied configuration. It also dials ModemManager directly rather than re-entering conf_mode, so the teardown branch is no longer reachable from the recovery path at all. --- python/vyos/utils/wwan.py | 124 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 python/vyos/utils/wwan.py (limited to 'python') diff --git a/python/vyos/utils/wwan.py b/python/vyos/utils/wwan.py new file mode 100644 index 000000000..fd8cd4f2e --- /dev/null +++ b/python/vyos/utils/wwan.py @@ -0,0 +1,124 @@ +# Copyright (C) VyOS Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + +"""ModemManager helpers shared between the WWAN conf_mode script and +vyos-netlinkd. + +Both dial the very same modem with the very same parameters - conf_mode when +the CLI config is applied, vyos-netlinkd when a session has to be re- +established after it was lost. Keeping the option string and the mmcli +invocations in one place means a re-dial can never drift from what conf_mode +would have done. +""" + +from time import sleep + +from vyos.utils.dict import dict_search +from vyos.utils.process import call +from vyos.utils.process import cmdl +from vyos.utils.process import DEVNULL +from vyos.utils.process import is_systemd_service_active + +service_name = 'ModemManager.service' + +# Bounded wait for a modem to appear after ModemManager has been started. The +# service being up is not enough - the modem is only dialable once it has been +# probed and exported on the bus, which takes a moment longer. +modem_wait_timeout = 25 +modem_wait_poll = 0.250 + +def modem_index(ifname: str) -> str: + """ Return the ModemManager modem index backing an interface, wwan0 -> 0 """ + if not ifname.startswith('wwan'): + raise ValueError(f'Specified interface "{ifname}" is not a WWAN interface') + return ifname[len('wwan'):] + +def start_modem_manager() -> None: + """ ModemManager is required to dial WWAN connections - a single instance + serves all modems. Start it if it is not running yet and wait until a modem + has been detected, so a dial that follows does not race the probing. """ + if is_systemd_service_active(service_name): + return None + + cmdl(['systemctl', 'start', service_name]) + + counter = int(modem_wait_timeout / modem_wait_poll) + while counter > 0: + counter -= 1 + try: + tmp = cmdl(['mmcli', '-L']) + except OSError: + # ModemManager is up but not yet answering on the bus - keep + # polling, this is expected for the first moments after start. + tmp = '' + if tmp and tmp != 'No modems were found': + break + sleep(modem_wait_poll) + + return None + +def connect_options(config: dict) -> str: + """ Build the mmcli --simple-connect option string for a WWAN interface. + + config is an interface configuration dict as returned by + get_interface_dict() in conf_mode or op_mode_config_dict() in an op-mode + context - both are key-mangled the same way, so both are accepted here. + """ + ip_type = 'ipv4' + slaac = dict_search('ipv6.address.autoconf', config) != None + if 'address' in config: + if 'dhcp' in config['address'] and ('dhcpv6' in config['address'] or slaac): + ip_type = 'ipv4v6' + elif 'dhcpv6' in config['address'] or slaac: + ip_type = 'ipv6' + elif 'dhcp' in config['address']: + ip_type = 'ipv4' + + options = f'ip-type={ip_type},apn=' + config['apn'] + if 'authentication' in config: + options += ',user={username},password={password}'.format(**config['authentication']) + + return options + +def modem_disconnect(ifname: str) -> None: + """ Disconnect every bearer of the modem backing ifname. The number of + bearers a modem can hold is limited, so we always disconnect before we + dial again. """ + call(f'mmcli --modem {modem_index(ifname)} --simple-disconnect') + +def modem_connect(ifname: str, options: str) -> bool: + """ Dial the modem backing ifname, returns True if the modem connected """ + modem = modem_index(ifname) + + # Some networks only ever admit a single combined IPv4+IPv6 PDN context per + # APN and reject a standalone IPv6 "Start Network" request outright (QMI + # CallEndReason ip-version-mismatch) once an IPv4 session already exists for + # that APN. ModemManager's --simple-connect with ip-type=ipv4v6 opens two + # separate WDS sessions (one per family) rather than negotiating both + # together, which is what a normal handset attach does and why this is + # invisible on most other devices. + # + # Pre-negotiating the attach-time PDN type upfront avoids the rejection; the + # resulting bearer only sets up what the *next* --simple-connect is allowed + # to request and can't be connected directly, so a failure here isn't fatal + # - unsupported modems simply proceed to --simple-connect exactly as before. + if options.partition('ip-type=')[2].partition(',')[0] in ('ipv6', 'ipv4v6'): + # best effort by definition, so do not report the rejection a modem + # without attach configuration support answers with on every dial + call(f'mmcli --modem {modem} --3gpp-set-initial-eps-bearer-settings="{options}"', + stdout=DEVNULL, stderr=DEVNULL) + + command = f'mmcli --modem {modem} --simple-connect="{options}"' + return call(command, stdout=DEVNULL) == 0 -- cgit v1.2.3 From 112fb62b8b15653c0a26e0de56f326cd2d67da9b Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Thu, 24 Sep 2026 15:27:27 +0000 Subject: wwan: T9350: properly disconnect "disconnect interface wwanN" Two things kept an operator disconnect from taking effect. vyos-netlinkd re-dials every configured WWAN interface that has lost its session. It cannot tell a session lost to RF signal loss from one that was taken down on purpose, so the disconnect was undone on the next reconcile pass, at most 30 seconds later: $ disconnect interface wwan0 08:53:33 t+10s state=registered 08:53:43 t+20s state=registered 08:53:53 t+30s state=connected <- re-dialled 08:54:03 t+40s state=connected vyos-netlinkd: WWAN interface wwan0 is not connected, re-dialling... vyos-netlinkd: Re-dialled WWAN interface wwan0 Record the intent instead of inferring it from the session state. op-mode "disconnect" drops a marker in /run/vyos-wwan, which _reconcile_wwan() honours and "connect" removes again. conf_mode removes it too, so a commit keeps re-asserting the configured state - the marker only survives for as long as nobody says otherwise, and never across a reboot. --- python/vyos/utils/wwan.py | 42 +++++++++++++++++++++++++++++++++++++-- src/conf_mode/interfaces_wwan.py | 6 ++++++ src/op_mode/connect_disconnect.py | 28 +++++++++++++++++++++++--- src/services/vyos-netlinkd | 8 ++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) (limited to 'python') diff --git a/python/vyos/utils/wwan.py b/python/vyos/utils/wwan.py index fd8cd4f2e..e677c7fa8 100644 --- a/python/vyos/utils/wwan.py +++ b/python/vyos/utils/wwan.py @@ -23,6 +23,8 @@ invocations in one place means a re-dial can never drift from what conf_mode would have done. """ +import os + from time import sleep from vyos.utils.dict import dict_search @@ -39,6 +41,14 @@ service_name = 'ModemManager.service' modem_wait_timeout = 25 modem_wait_poll = 0.250 +# Markers for WWAN interfaces the operator has taken down with op-mode +# "disconnect interface wwanN". vyos-netlinkd re-dials every configured +# interface that has lost its session, which would otherwise bring the +# interface back up within one reconcile interval and make the disconnect look +# like it never happened. Kept in /run - a manual disconnect is not meant to +# survive a reboot, and a commit re-asserts the configured state too. +admin_disconnect_dir = '/run/vyos-wwan' + def modem_index(ifname: str) -> str: """ Return the ModemManager modem index backing an interface, wwan0 -> 0 """ if not ifname.startswith('wwan'): @@ -92,11 +102,39 @@ def connect_options(config: dict) -> str: return options -def modem_disconnect(ifname: str) -> None: +def _admin_disconnect_marker(ifname: str) -> str: + return os.path.join(admin_disconnect_dir, f'{ifname}.disconnected') + +def set_admin_disconnected(ifname: str) -> None: + """ Mark a WWAN interface as administratively disconnected, so it is left + alone by the re-dial pass in vyos-netlinkd """ + # rejects anything that is not a WWAN interface, so a bogus name can never + # end up creating a file here + modem_index(ifname) + os.makedirs(admin_disconnect_dir, exist_ok=True) + with open(_admin_disconnect_marker(ifname), 'w'): + pass + +def clear_admin_disconnected(ifname: str) -> None: + """ Take a WWAN interface out of the administratively disconnected state """ + modem_index(ifname) + try: + os.unlink(_admin_disconnect_marker(ifname)) + except FileNotFoundError: + pass + +def is_admin_disconnected(ifname: str) -> bool: + """ True if a WWAN interface was taken down with op-mode "disconnect" and + has not been connected again since """ + return os.path.exists(_admin_disconnect_marker(ifname)) + +def modem_disconnect(ifname: str, quiet: bool = False) -> None: """ Disconnect every bearer of the modem backing ifname. The number of bearers a modem can hold is limited, so we always disconnect before we dial again. """ - call(f'mmcli --modem {modem_index(ifname)} --simple-disconnect') + call(f'mmcli --modem {modem_index(ifname)} --simple-disconnect', + stdout=DEVNULL if quiet else None, + stderr=DEVNULL if quiet else None) def modem_connect(ifname: str, options: str) -> bool: """ Dial the modem backing ifname, returns True if the modem connected """ diff --git a/src/conf_mode/interfaces_wwan.py b/src/conf_mode/interfaces_wwan.py index 7e02d9d7a..dd5a1e8c3 100755 --- a/src/conf_mode/interfaces_wwan.py +++ b/src/conf_mode/interfaces_wwan.py @@ -30,6 +30,7 @@ from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import WWANIf from vyos.utils.network import is_wwan_connected from vyos.utils.process import cmdl +from vyos.utils.wwan import clear_admin_disconnected from vyos.utils.wwan import connect_options from vyos.utils.wwan import modem_connect from vyos.utils.wwan import modem_disconnect @@ -117,6 +118,11 @@ def generate(wwan): return None def apply(wwan): + # A commit re-asserts the configured state, so it also takes the interface + # out of the administratively disconnected state that op-mode "disconnect + # interface wwanN" put it in. + clear_admin_disconnected(wwan['ifname']) + # ModemManager is required to dial WWAN connections - one instance is # required to serve all modems. Activate ModemManager on first invocation # of any WWAN interface. diff --git a/src/op_mode/connect_disconnect.py b/src/op_mode/connect_disconnect.py index 17cf9d7e0..9b43e4fd2 100755 --- a/src/op_mode/connect_disconnect.py +++ b/src/op_mode/connect_disconnect.py @@ -19,10 +19,13 @@ import argparse from psutil import process_iter +from vyos.ifconfig import WWANIf from vyos.utils.process import call from vyos.utils.commit import commit_in_progress from vyos.utils.network import is_wwan_connected -from vyos.utils.process import DEVNULL +from vyos.utils.wwan import clear_admin_disconnected +from vyos.utils.wwan import modem_disconnect +from vyos.utils.wwan import set_admin_disconnected def check_ppp_interface(interface): if not os.path.isfile(f'/etc/ppp/peers/{interface}'): @@ -52,6 +55,9 @@ def connect(interface): print(f'Interface {interface}: connecting...') call(f'systemctl restart ppp@{interface}.service') elif interface.startswith('wwan'): + # Take the interface out of the administratively disconnected state, so + # vyos-netlinkd re-dials it again should the session be lost later on + clear_admin_disconnected(interface) if is_wwan_connected(interface): print(f'Interface {interface}: already connected!') else: @@ -72,11 +78,27 @@ def disconnect(interface): print(f'Interface {interface}: disconnecting...') call(f'systemctl stop ppp@{interface}.service') elif interface.startswith('wwan'): + # Keep the interface down until it is connected again. vyos-netlinkd + # re-dials every configured WWAN interface that lost its session, and + # would otherwise undo this disconnect on its next reconcile pass. The + # marker is set even when the session is already down, so a disconnect + # issued while the modem happens to be searching for a network is not + # silently ignored. + set_admin_disconnected(interface) if not is_wwan_connected(interface): print(f'Interface {interface}: connection is already down') else: - modem = interface.lstrip('wwan') - call(f'mmcli --modem {modem} --simple-disconnect', stdout=DEVNULL) + print(f'Interface {interface}: disconnecting...') + modem_disconnect(interface, quiet=True) + + # Dropping the bearer does not touch the netdev - a cdc_mbim/qmi_wwan + # interface stays IFF_UP with carrier and an operstate of "unknown" + # once ModemManager is done, so it would keep reporting u/u and hold + # on to the address the DHCP client got for the session that just + # ended. Tear it down the same way conf_mode does for "disable". + w = WWANIf(interface) + if w.exists(interface): + w.remove() else: print(f'Unknown interface {interface}, cannot disconnect. Aborting!') diff --git a/src/services/vyos-netlinkd b/src/services/vyos-netlinkd index 5cce1ceff..4c91561d2 100755 --- a/src/services/vyos-netlinkd +++ b/src/services/vyos-netlinkd @@ -43,6 +43,7 @@ from vyos.utils.process import cmdl from vyos.utils.process import is_systemd_service_active from vyos.utils.process import stop_systemd_unit from vyos.utils.wwan import connect_options +from vyos.utils.wwan import is_admin_disconnected from vyos.utils.wwan import modem_connect from vyos.utils.wwan import modem_disconnect from vyos.utils.wwan import start_modem_manager @@ -370,6 +371,13 @@ def _reconcile_wwan() -> None: _wwan_clear_backoff(ifname) continue + # Do not undo an explicit op-mode "disconnect interface wwanN" - the + # session stays down until it is connected again or the configuration + # is re-applied by a commit. + if is_admin_disconnected(ifname): + _wwan_clear_backoff(ifname) + continue + if is_wwan_connected(ifname): _wwan_clear_backoff(ifname) continue -- cgit v1.2.3 From 79738ffce99c0d4afd313355df8ca12200e6800c Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Thu, 24 Sep 2026 21:48:13 +0200 Subject: wwan: T9350: wait for the modem to become dialable, not merely listed A modem is on the bus while still probing, so a dial could start too early and leave the interface without a session until something re-dialled it. --- python/vyos/utils/wwan.py | 52 +++++++++++++++++++++++++++------------- src/conf_mode/interfaces_wwan.py | 15 +++++++++--- src/services/vyos-netlinkd | 2 +- 3 files changed, 48 insertions(+), 21 deletions(-) (limited to 'python') diff --git a/python/vyos/utils/wwan.py b/python/vyos/utils/wwan.py index e677c7fa8..d8da34bfa 100644 --- a/python/vyos/utils/wwan.py +++ b/python/vyos/utils/wwan.py @@ -35,10 +35,13 @@ from vyos.utils.process import is_systemd_service_active service_name = 'ModemManager.service' -# Bounded wait for a modem to appear after ModemManager has been started. The -# service being up is not enough - the modem is only dialable once it has been -# probed and exported on the bus, which takes a moment longer. -modem_wait_timeout = 25 +# Bounded wait for a modem to become dialable. Neither the service being up +# nor the modem being listed is enough: it is exported on the bus as soon as +# it is created and reports state 'unknown' until probing finishes, and a dial +# in that window is refused. An HP lt4132 needed 33s of that, two 10s MBIM +# timeouts of it, so the budget is generous - it is only ever spent on a modem +# that is not answering. +modem_wait_timeout = 60 modem_wait_poll = 0.250 # Markers for WWAN interfaces the operator has taken down with op-mode @@ -55,25 +58,40 @@ def modem_index(ifname: str) -> str: raise ValueError(f'Specified interface "{ifname}" is not a WWAN interface') return ifname[len('wwan'):] -def start_modem_manager() -> None: +def modem_state(index: str) -> str: + """ ModemManager state of a modem, empty if it cannot be read yet """ + try: + tmp = cmdl(['mmcli', '--output-keyvalue', '--modem', index]) + except OSError: + # up but not yet answering on the bus, or no such modem + return '' + + for line in tmp.splitlines(): + key, _, value = line.partition(':') + if key.strip() == 'modem.generic.state': + return value.strip() + return '' + +def start_modem_manager(ifname: str) -> None: """ ModemManager is required to dial WWAN connections - a single instance - serves all modems. Start it if it is not running yet and wait until a modem - has been detected, so a dial that follows does not race the probing. """ - if is_systemd_service_active(service_name): - return None + serves all modems. Start it if it is not running yet, then wait until the + modem behind ifname will accept a dial. - cmdl(['systemctl', 'start', service_name]) + Waiting on the service, or on the modem merely being listed, is not + enough - both are true long before probing finishes, and dialling then + leaves the interface without a session until something re-dials it. """ + if not is_systemd_service_active(service_name): + cmdl(['systemctl', 'start', service_name]) + index = modem_index(ifname) counter = int(modem_wait_timeout / modem_wait_poll) while counter > 0: counter -= 1 - try: - tmp = cmdl(['mmcli', '-L']) - except OSError: - # ModemManager is up but not yet answering on the bus - keep - # polling, this is expected for the first moments after start. - tmp = '' - if tmp and tmp != 'No modems were found': + # 'unknown' and 'initializing' are both reported while the modem is + # still being brought up. Every other state means it is done, including + # 'failed' - waiting out the budget would not improve that, and the dial + # reports it far better than a timeout here could + if modem_state(index) not in ('', 'unknown', 'initializing'): break sleep(modem_wait_poll) diff --git a/src/conf_mode/interfaces_wwan.py b/src/conf_mode/interfaces_wwan.py index dd5a1e8c3..1f7407a9d 100755 --- a/src/conf_mode/interfaces_wwan.py +++ b/src/conf_mode/interfaces_wwan.py @@ -30,6 +30,7 @@ from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import WWANIf from vyos.utils.network import is_wwan_connected from vyos.utils.process import cmdl +from vyos.utils.process import is_systemd_service_active from vyos.utils.wwan import clear_admin_disconnected from vyos.utils.wwan import connect_options from vyos.utils.wwan import modem_connect @@ -126,9 +127,17 @@ def apply(wwan): # ModemManager is required to dial WWAN connections - one instance is # required to serve all modems. Activate ModemManager on first invocation # of any WWAN interface. - start_modem_manager() - - if 'shutdown_required' in wwan or (not is_wwan_connected(wwan['ifname'])): + # + # Tearing an interface down never dials, so it must not wait for the modem + # to become dialable either - the hardware being gone is a common reason to + # remove the configuration in the first place, and waiting out the budget + # for a modem that will never appear would stall the commit for nothing. + if 'deleted' not in wwan and 'disable' not in wwan: + start_modem_manager(wwan['ifname']) + + # No ModemManager, no session to drop - and nothing that would answer + if is_systemd_service_active(service_name) and ( + 'shutdown_required' in wwan or not is_wwan_connected(wwan['ifname'])): # Number of bearers is limited - always disconnect first modem_disconnect(wwan['ifname']) diff --git a/src/services/vyos-netlinkd b/src/services/vyos-netlinkd index 4c91561d2..d6cc3c379 100755 --- a/src/services/vyos-netlinkd +++ b/src/services/vyos-netlinkd @@ -292,7 +292,7 @@ def _wwan_dial(ifname: str, config: dict) -> None: try: # A session cannot be dialled without ModemManager. It is normally # started by conf_mode, but recover here too in case it died. - start_modem_manager() + start_modem_manager(ifname) if not _wwan_dial_wanted(ifname): return None -- cgit v1.2.3