From b84bdbf8dd25995aa564081632de6251ecf5ad4e Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Fri, 7 Nov 2025 22:05:59 +0100 Subject: vyos-netlinkd: T8047: replace netplugd Implementing a daemon that listens for netlink messages in Python was discussed for many years. This is a proof-of-concept implementation how we can listen for netlink messages and process them in Python. Python 3.10 minimum is required due to the use of case statements which mimics C-style switch/case instructions. Add example: set interfaces ethernet eth1 vif 21 address dhcp set interfaces ethernet eth1 vif 21 address dhcpv6 commit If network cable is unplugged: vyos-netlinkd[12681]: RTM_NEWLINK -> eth3.10, state=DOWN, mac=00:50:56:b3:9d:8e vyos-netlinkd[12681]: Stopping dhclient@eth3.10.service... vyos-netlinkd[12681]: Stopping dhcp6c@eth3.10.service... If cable is plugged back in: vyos-netlinkd[12681]: RTM_NEWLINK -> eth3.10, state=DOWN, mac=00:50:56:b3:9d:8e vyos-netlinkd[12681]: RTM_NEWLINK -> eth3.10, state=UP, mac=00:50:56:b3:9d:8e vyos-netlinkd[12681]: Restarting dhclient@eth3.10.service... vyos-netlinkd[12681]: Restarting dhcp6c@eth3.10.service... --- src/etc/netplug/linkup.d/vyos-python-helper | 4 - src/etc/netplug/netplug | 41 ------- src/etc/netplug/netplugd.conf | 4 - src/etc/netplug/vyos-netplug-dhcp-client | 79 ------------- src/init/vyos-router | 8 +- src/services/vyos-netlinkd | 169 ++++++++++++++++++++++++++++ src/systemd/netplug.service | 9 -- src/systemd/vyos-netlinkd.service | 25 ++++ 8 files changed, 200 insertions(+), 139 deletions(-) delete mode 100755 src/etc/netplug/linkup.d/vyos-python-helper delete mode 100755 src/etc/netplug/netplug delete mode 100644 src/etc/netplug/netplugd.conf delete mode 100755 src/etc/netplug/vyos-netplug-dhcp-client create mode 100755 src/services/vyos-netlinkd delete mode 100644 src/systemd/netplug.service create mode 100644 src/systemd/vyos-netlinkd.service (limited to 'src') diff --git a/src/etc/netplug/linkup.d/vyos-python-helper b/src/etc/netplug/linkup.d/vyos-python-helper deleted file mode 100755 index 9c59c58ad..000000000 --- a/src/etc/netplug/linkup.d/vyos-python-helper +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -PYTHON3=$(which python3) -# Call the real python script and forward commandline arguments -$PYTHON3 /etc/netplug/vyos-netplug-dhcp-client "${@:1}" diff --git a/src/etc/netplug/netplug b/src/etc/netplug/netplug deleted file mode 100755 index 9f6ab74cc..000000000 --- a/src/etc/netplug/netplug +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/sh -# -# Copyright VyOS maintainers and contributors -# -# 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 . - -dev="$1" -action="$2" - -case "$action" in -in) - run-parts --arg $dev --arg in /etc/netplug/linkup.d - ;; -out) - run-parts --arg $dev --arg out /etc/netplug/linkdown.d - ;; - -# probe loads and initialises the driver for the interface and brings the -# interface into the "up" state, so that it can generate netlink(7) events. -# This interferes with "admin down" for an interface. Thus, commented out. An -# "admin up" is treated as a "link up" and thus, "link up" action is executed. -# To execute "link down" action on "admin down", run appropriate script in -# /etc/netplug/linkdown.d -#probe) -# ;; - -*) - exit 1 - ;; -esac diff --git a/src/etc/netplug/netplugd.conf b/src/etc/netplug/netplugd.conf deleted file mode 100644 index 7da3c67e8..000000000 --- a/src/etc/netplug/netplugd.conf +++ /dev/null @@ -1,4 +0,0 @@ -eth* -br* -bond* -wlan* diff --git a/src/etc/netplug/vyos-netplug-dhcp-client b/src/etc/netplug/vyos-netplug-dhcp-client deleted file mode 100755 index d538f8b52..000000000 --- a/src/etc/netplug/vyos-netplug-dhcp-client +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright VyOS maintainers and contributors -# -# 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 . - -import sys - -from time import sleep - -from vyos.config import Config -from vyos.ifconfig import Section -from vyos.utils.boot import boot_configuration_complete -from vyos.utils.process import cmd -from vyos.utils.process import is_systemd_service_active -from vyos.utils.commit import commit_in_progress -from vyos import airbag - -airbag.enable() - -if len(sys.argv) < 3: - airbag.noteworthy('Must specify both interface and link status!') - sys.exit(1) - -if not boot_configuration_complete(): - airbag.noteworthy('System bootup not yet finished...') - sys.exit(1) - -interface = sys.argv[1] - -while commit_in_progress(): - sleep(0.250) - -in_out = sys.argv[2] -config = Config() - -interface_path = ['interfaces'] + Section.get_config_path(interface).split() - -systemdV4_service = f'dhclient@{interface}.service' -systemdV6_service = f'dhcp6c@{interface}.service' -if in_out == 'out': - # Interface moved state to down - if is_systemd_service_active(systemdV4_service): - cmd(f'systemctl stop {systemdV4_service}') - if is_systemd_service_active(systemdV6_service): - cmd(f'systemctl stop {systemdV6_service}') -elif in_out == 'in': - v6_restart = False - - if config.exists_effective(interface_path + ['address']): - tmp = config.return_effective_values(interface_path + ['address']) - # Always (re-)start the DHCP(v6) client service. If the DHCP(v6) client - # is already running - which could happen if the interface is re- - # configured in operational down state, it will have a backoff - # time increasing while not receiving a DHCP(v6) reply. - # - # To make the interface instantly available, and as for a DHCP(v6) lease - # we will re-start the service and thus cancel the backoff time. - if 'dhcp' in tmp: - cmd(f'systemctl restart {systemdV4_service}') - if 'dhcpv6' in tmp: - v6_restart = True - - if config.exists_effective(interface_path + ['dhcpv6-options', 'pd']): - v6_restart = True - - if v6_restart: - cmd(f'systemctl restart {systemdV6_service}') diff --git a/src/init/vyos-router b/src/init/vyos-router index 550c916a1..de3cc071a 100755 --- a/src/init/vyos-router +++ b/src/init/vyos-router @@ -681,13 +681,18 @@ start () fi # Start netplug daemon - systemctl start netplug.service + systemctl start vyos-netlinkd.service } stop() { local -i status=0 log_daemon_msg "Stopping VyOS router" + + # As vyos-netlinkd consumes a configuration, we need to stop it prior + # unmounting /config + systemctl stop vyos-netlinkd.service + for ((i=${#sub_inits[@]} - 1; i >= 0; i--)) ; do s=${subinit[$i]} log_progress_msg $s @@ -699,7 +704,6 @@ stop() umount ${vyatta_configdir} log_action_end_msg $? - systemctl stop netplug.service systemctl stop vyconfd.service systemctl stop frr.service diff --git a/src/services/vyos-netlinkd b/src/services/vyos-netlinkd new file mode 100755 index 000000000..647241276 --- /dev/null +++ b/src/services/vyos-netlinkd @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import re +import sys +import syslog +import signal +import select + +from pyroute2 import IPRoute # pylint: disable = no-name-in-module +from pyroute2 import NetlinkError # pylint: disable = no-name-in-module +from time import sleep +from typing import Optional + +from vyos.configquery import op_mode_config_dict +from vyos.ifconfig import Section +from vyos.utils.boot import boot_configuration_complete +from vyos.utils.commit import commit_in_progress +from vyos.utils.dict import dict_search +from vyos.utils.process import cmd +from vyos.utils.process import is_systemd_service_active + +running = True + +# compile regex once during startup for fast match +IFACE_RE = re.compile(r"^(?:eth|br|bond|wlan)") + +def match_iface(ifname: str) -> bool: + """ Helper function returning true if interface name is a match for further + processing (e.g. restart of DHCP(v6) client) + """ + return IFACE_RE.match(ifname) is not None + +def sigterm_handler(signo, frame): + global running + running = False + sig = signal.Signals(signo) + syslog.syslog(syslog.LOG_INFO, f'Received signal {sig.name} - shutting down...') + +def _handle_dhcp_events(config_dict: dict, operstate: Optional[str], ifname: str) -> None: + systemdV4_service = f'dhclient@{ifname}.service' + systemdV6_service = f'dhcp6c@{ifname}.service' + + # Only handle explicit UP/DOWN state transitions; ignore other kernel states. + if operstate not in ['UP', 'DOWN']: + return None + + if operstate == 'DOWN': + # Interface moved state to down + if is_systemd_service_active(systemdV4_service): + syslog.syslog(syslog.LOG_DEBUG, f'Stopping {systemdV4_service}...') + cmd(f'systemctl stop {systemdV4_service}') + if is_systemd_service_active(systemdV6_service): + syslog.syslog(syslog.LOG_DEBUG, f'Stopping {systemdV6_service}...') + cmd(f'systemctl stop {systemdV6_service}') + + elif operstate == 'UP': + v6_restart = False + interface_path = Section.get_config_path(ifname, delimiter='.') + if tmp := dict_search(f'{interface_path}.address', config_dict): + # Always (re-)start the DHCP(v6) client service. If the DHCP(v6) client + # is already running - which could happen if the interface is re- + # configured in operational down state, it will have an exponential backoff + # time increasing while not receiving a DHCP(v6) reply. + # + # To make the interface instantly available, and as for a DHCP(v6) lease + # we will re-start the service and thus cancel the backoff time. + if 'dhcp' in tmp: + syslog.syslog(syslog.LOG_DEBUG, f'Restarting {systemdV4_service}...') + cmd(f'systemctl restart {systemdV4_service}') + if 'dhcpv6' in tmp: + v6_restart = True + + if dict_search(f'{interface_path}.dhcpv6_options.pd', config_dict): + v6_restart = True + + if v6_restart: + syslog.syslog(syslog.LOG_DEBUG, f'Restarting {systemdV6_service}...') + cmd(f'systemctl restart {systemdV6_service}') + + return None + +def main(): + syslog.openlog(ident="vyos-netlinkd", + logoption=syslog.LOG_PID, + facility=syslog.LOG_DAEMON) + syslog.syslog(syslog.LOG_INFO, "VyOS Netlink listener daemon started.") + + ipr = IPRoute() + ipr.bind() + fd = ipr.fileno() + + global running + while running: + if not boot_configuration_complete(): + syslog.syslog(syslog.LOG_INFO, 'System bootup not yet finished...') + sleep(5) + continue + + try: + # Wait for up to 1 second for a netlink message + rlist, _, _ = select.select([fd], [], [], 1.0) + if not rlist: + # timeout - retry + continue + + # Receive and process any messages + for message in ipr.get(): + # Wait for commit to complete + while commit_in_progress(): + sleep(0.250) + + config_dict = op_mode_config_dict(['interfaces'], + key_mangling=('-', '_'), + get_first_key=True) + + # Parse NETLINK message + match message['event']: + # Message received during interface creation or modification + # e.g. link up/down. + case 'RTM_NEWLINK': + attrs = dict(message.get('attrs', [])) + ifname = attrs.get('IFLA_IFNAME', None) + mac = attrs.get('IFLA_ADDRESS', '') + operstate = attrs.get('IFLA_OPERSTATE', None) + syslog.syslog(syslog.LOG_DEBUG, + f'RTM_NEWLINK -> {ifname}, state={operstate}, mac={mac}') + + # Bail out early - no interface name in the message + if not ifname: + continue + # Bail out early - not interested in interface type + if not match_iface(ifname): + continue + + _handle_dhcp_events(config_dict, operstate, ifname) + + # Deletion of a network link which has been previously added to the kernel + case 'RTM_DELLINK': + pass + + except NetlinkError as e: + syslog.syslog(syslog.LOG_ERR, f'Netlink error: {e}') + except Exception as e: + syslog.syslog(syslog.LOG_ERR, f'Unhandled exception: {e}') + except KeyboardInterrupt: + break + + ipr.close() + syslog.syslog(syslog.LOG_INFO, 'Netlink listener daemon stopped.') + sys.exit(0) + +if __name__ == "__main__": + signal.signal(signal.SIGTERM, sigterm_handler) + signal.signal(signal.SIGINT, sigterm_handler) + main() diff --git a/src/systemd/netplug.service b/src/systemd/netplug.service deleted file mode 100644 index 928c553e8..000000000 --- a/src/systemd/netplug.service +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=Network cable hotplug management daemon -Documentation=man:netplugd(8) -After=vyos-router.service - -[Service] -Type=forking -PIDFile=/run/netplugd.pid -ExecStart=/sbin/netplugd -c /etc/netplug/netplugd.conf -p /run/netplugd.pid diff --git a/src/systemd/vyos-netlinkd.service b/src/systemd/vyos-netlinkd.service new file mode 100644 index 000000000..6c8a80878 --- /dev/null +++ b/src/systemd/vyos-netlinkd.service @@ -0,0 +1,25 @@ +[Unit] +Description=VyOS netlink daemon + +# Without this option, lots of default dependencies are added, +# among them network.target, which creates a dependency cycle +DefaultDependencies=no + +# Seemingly sensible way to say "as early as the system is ready" +# All vyos-netlinkd needs is read/write mounted root +After=systemd-remount-fs.service vyos-router.service + +[Service] +ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-netlinkd +Type=simple + +SyslogIdentifier=vyos-netlinkd +SyslogFacility=daemon + +Restart=on-failure + +User=root +Group=vyattacfg + +[Install] +WantedBy=vyos.target -- cgit v1.2.3