From ce4f4c98f13dc039875bd23c51e081a5381917e0 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Fri, 25 Sep 2026 21:02:00 +0200 Subject: op-mode: T9354: run interface reconnect detached from calling session A reconnect is usually issued via the very interface being reconnected. Once the link drops, the calling session dies and the reconnect was killed by SIGHUP - or by SIGPIPE on the next write to the dead terminal - before the interface could be dialed again, leaving the system unreachable. The reconnect now runs in a transient systemd unit and survives the loss of the session. Progress is logged to the journal. PPPoE and SSTP restart the ppp service in a single operation instead of stopping and starting it, so there is no window in which an interrupted command leaves the link down. WWAN still disconnects the modem before dialing again. --- src/op_mode/connect_disconnect.py | 60 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/op_mode/connect_disconnect.py b/src/op_mode/connect_disconnect.py index 17cf9d7e0..e30f512a0 100755 --- a/src/op_mode/connect_disconnect.py +++ b/src/op_mode/connect_disconnect.py @@ -15,15 +15,22 @@ # along with this program. If not, see . import os +import re import argparse +from argparse import SUPPRESS from psutil import process_iter from vyos.utils.process import call +from vyos.utils.process import rc_cmd from vyos.utils.commit import commit_in_progress from vyos.utils.network import is_wwan_connected from vyos.utils.process import DEVNULL +# Dialer interfaces we support - also sanitizes the name before it becomes +# part of a systemd-run(1) command line +dialer_interface_re = r'(pppoe|sstpc|wwan)\d+' + def check_ppp_interface(interface): if not os.path.isfile(f'/etc/ppp/peers/{interface}'): print(f'Interface {interface} does not exist!') @@ -80,6 +87,51 @@ def disconnect(interface): else: print(f'Unknown interface {interface}, cannot disconnect. Aborting!') +def reconnect(interface): + """ Reconnect dialer interface """ + + if interface.startswith('pppoe') or interface.startswith('sstpc'): + check_ppp_interface(interface) + # Restarting the service hangs up and dials again in one operation, + # leaving no window in which we could be interrupted with the link down + print(f'Interface {interface}: reconnecting...') + call(f'systemctl restart ppp@{interface}.service') + elif interface.startswith('wwan'): + # The modem must be disconnected before it can be dialed again + disconnect(interface) + connect(interface) + else: + print(f'Unknown interface {interface}, cannot reconnect. Aborting!') + +def reconnect_detached(interface): + """ Reconnect dialer interface from a transient systemd unit. + + A reconnect is usually issued via the very interface being reconnected. + Once the link drops, the calling session dies and this process would be + killed by SIGHUP - or SIGPIPE on the next write to the dead terminal - + before the interface is dialed again, leaving the system unreachable. """ + + if not re.fullmatch(dialer_interface_re, interface): + print(f'Unknown interface {interface}, cannot reconnect. Aborting!') + exit(1) + + unit = f'vyos-reconnect-{interface}' + command = ( + f'systemd-run --quiet --collect --unit={unit} ' + f'{os.path.abspath(__file__)} --reconnect --detached ' + f'--interface {interface}' + ) + + rc, output = rc_cmd(command) + if rc != 0: + print(f'Interface {interface}: cannot reconnect: {output}') + exit(1) + + print( + f'Interface {interface}: reconnecting in the background, this session ' + f'may be disconnected. Progress is logged to journal unit {unit}.' + ) + def main(): parser = argparse.ArgumentParser() group = parser.add_mutually_exclusive_group() @@ -87,6 +139,8 @@ def main(): group.add_argument("--disconnect", help="Take down connection-oriented network interface", action="store_true") group.add_argument("--reconnect", help="Reconnect connection-oriented network interface", action="store_true") parser.add_argument("--interface", help="Interface name", action="store", required=True) + # Set only when we re-launch ourself detached from the calling session + parser.add_argument('--detached', action='store_true', help=SUPPRESS) args = parser.parse_args() # Disallow connecting interfaces while their configuration might be changing @@ -100,8 +154,10 @@ def main(): elif args.disconnect: disconnect(args.interface) elif args.reconnect: - disconnect(args.interface) - connect(args.interface) + if args.detached: + reconnect(args.interface) + else: + reconnect_detached(args.interface) else: parser.print_help() -- cgit v1.2.3 From 9b5110a868ef413fdc1303f834220d99af8ceead Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Fri, 25 Sep 2026 21:02:06 +0200 Subject: op-mode: T9354: replace "reset connection" with "reconnect interface" "connect interface" and "disconnect interface" cannot be combined to restart a dialer interface when the CLI session runs over that very interface, yet the command which does cover it was filed under an unrelated top-level node and was hard to find. It is now "reconnect interface ", mirroring the existing connect and disconnect commands. "reset connection" is removed, no alias is kept. The removed command was reachable through the REST API as a "reset" path. A dedicated "/reconnect" endpoint takes its place, so the operation stays available to API consumers. --- op-mode-definitions/reconnect.xml.in | 21 +++++++++++++++++++++ op-mode-definitions/reset-connection.xml.in | 18 ------------------ python/vyos/configsession.py | 5 +++++ src/services/api/rest/models.py | 14 ++++++++++++++ src/services/api/rest/routers.py | 23 +++++++++++++++++++++++ 5 files changed, 63 insertions(+), 18 deletions(-) create mode 100644 op-mode-definitions/reconnect.xml.in delete mode 100644 op-mode-definitions/reset-connection.xml.in (limited to 'src') diff --git a/op-mode-definitions/reconnect.xml.in b/op-mode-definitions/reconnect.xml.in new file mode 100644 index 000000000..ca47e0885 --- /dev/null +++ b/op-mode-definitions/reconnect.xml.in @@ -0,0 +1,21 @@ + + + + + Re-establish a connection + + + + + Bring a connection-oriented network interface down and up + + interfaces pppoe + interfaces sstpc + interfaces wwan + + + ${vyos_op_scripts_dir}/connect_disconnect.py --reconnect --interface "$3" + + + + diff --git a/op-mode-definitions/reset-connection.xml.in b/op-mode-definitions/reset-connection.xml.in deleted file mode 100644 index a3c45d421..000000000 --- a/op-mode-definitions/reset-connection.xml.in +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - Bring connection-oriented network interface down and up - - interfaces pppoe - interfaces sstpc - interfaces wwan - - - ${vyos_op_scripts_dir}/connect_disconnect.py --reconnect --interface "$3" - - - - diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 545b5abbc..6c9cd39ac 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -83,6 +83,7 @@ SET_DEFAULT_IMAGE = [ GENERATE = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'generate'] SHOW = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'show'] RESET = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reset'] +RECONNECT = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reconnect'] REBOOT = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reboot'] RENEW = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'renew'] POWEROFF = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'poweroff'] @@ -467,6 +468,10 @@ class ConfigSession(object): out = self.__run_command(RESET + path) return out + def reconnect(self, path): + out = self.__run_command(RECONNECT + path) + return out + def renew(self, path): out = self.__run_command(RENEW + path) return out diff --git a/src/services/api/rest/models.py b/src/services/api/rest/models.py index 8321ad2e3..8762c3bff 100644 --- a/src/services/api/rest/models.py +++ b/src/services/api/rest/models.py @@ -291,6 +291,20 @@ class ResetModel(ApiModel): } +class ReconnectModel(ApiModel): + op: StrictStr + path: List[StrictStr] + + class Config: + json_schema_extra = { + 'example': { + 'key': 'id_key', + 'op': 'reconnect', + 'path': ['op', 'mode', 'path'], + } + } + + class PoweroffModel(ApiModel): op: StrictStr path: List[StrictStr] diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py index 1808e5a5b..c2aa033bc 100644 --- a/src/services/api/rest/routers.py +++ b/src/services/api/rest/routers.py @@ -74,6 +74,7 @@ from .models import GenerateModel from .models import ShowModel from .models import RebootModel from .models import ResetModel +from .models import ReconnectModel from .models import RenewModel from .models import ImportPkiModel from .models import PingModel @@ -934,6 +935,28 @@ def reset_op(data: ResetModel): return success(res) +@router.post('/reconnect') +def reconnect_op(data: ReconnectModel): + state = SessionState() + session = state.session + + op = data.op + path = data.path + + try: + if op == 'reconnect': + res = session.reconnect(path) + else: + return error(400, f"'{op}' is not a valid operation") + except ConfigSessionError as e: + return error(400, str(e)) + except Exception: + LOG.critical(traceback.format_exc()) + return error(500, 'An internal error occurred. Check the logs for details.') + + return success(res) + + @router.post('/import-pki') def import_pki(data: ImportPkiModel): # pylint: disable=consider-using-with -- cgit v1.2.3