From 9a7d5b27cd908473b7cd9e16fc0d1bbe7e983d2e Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Fri, 4 Sep 2026 22:14:45 +0200 Subject: static: T9278: reconcile FRR config after every DHCP lease event The default route derived from "interfaces address dhcp" is rendered from the DHCP lease file, so it is only known at lease time. Until now the sole runtime path into staticd was the one-shot vtysh injection in dhclient-enter-hooks.d/03-vyos-ipwrapper. That injection races the FRR reload of the very commit which started the DHCP client: dhclient runs with "-nw", thus the commit does not wait for a lease, and when BOUND arrives mid-reload frr_alive() can report FRR as down. The route is then installed into the kernel only and FRR never learns about it. Nothing recovers afterwards, as FRRender.generate() short-circuits on an unchanged configuration dict. The self-healing re-render used by "protocols static route dhcp-interface" was gated on /tmp/static_dhcp_interfaces, which never lists plain "address dhcp" interfaces - protocols_static.py does not even run on an interface-only commit, as no config-mode dependency points to it. Derive the DHCP dependent interface list from the configuration dict itself, both for the default VRF and for every named VRF, and use it for FRR change detection. Drop the interface list gate in the dhclient exit hook so any lease event requests a re-render. Also poll for the lease and for the rendered route in the affected smoketests instead of relying on a fixed sleep. --- python/vyos/frrender.py | 44 +++++++++++++++++++++++++++++++++++++++++--- python/vyos/template.py | 8 ++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) (limited to 'python') diff --git a/python/vyos/frrender.py b/python/vyos/frrender.py index 266da3d3d..b3f2718ac 100644 --- a/python/vyos/frrender.py +++ b/python/vyos/frrender.py @@ -31,10 +31,8 @@ from vyos.config import config_dict_merge from vyos.configdict import get_dhcp_interfaces from vyos.configdict import get_pppoe_interfaces from vyos.defaults import frr_debug_enable -from vyos.defaults import static_route_dhcp_interfaces_path from vyos.utils.dict import dict_search from vyos.utils.dict import dict_set_nested -from vyos.utils.file import read_file from vyos.utils.file import write_file from vyos.utils.process import cmdl from vyos.utils.process import rc_cmd @@ -692,6 +690,41 @@ def get_frrender_dict(conf: Config, argv=None) -> dict: return dict +def get_dhcp_route_interfaces(config_dict) -> set: + """Collect all interfaces whose static route configuration is derived from a + DHCP lease. Two CLI constructs end up reading the DHCP lease file via the + "get_dhcp_router" Jinja filter and thus depend on the current lease: + + - "protocols static route dhcp-interface " + - the default route implied by "interfaces address dhcp" + + Both variants exist in the default VRF as well as inside any named VRF. + """ + interfaces = set() + + def _add_from_static(static_conf): + if not isinstance(static_conf, dict): + return + for prefix_options in static_conf.get('route', {}).values(): + interfaces.update(prefix_options.get('dhcp_interface', [])) + for ifname, if_config in static_conf.get('dhcp', {}).items(): + # An interface explicitly opting out of the default route does not + # contribute a route, thus a lease change is irrelevant for it + if dict_search('dhcp_options.no_default_route', if_config) != None: + continue + interfaces.add(ifname) + + if not isinstance(config_dict, dict): + return interfaces + + _add_from_static(config_dict.get('static')) + for vrf_name in dict_search('vrf.name', config_dict) or {}: + _add_from_static( + dict_search(f'vrf.name.{vrf_name}.protocols.static', config_dict) + ) + + return interfaces + class FRRender: cached_config_dict = {} cached_dhcp_gateways = {} @@ -707,9 +740,14 @@ class FRRender: tmp = type(config_dict) raise ValueError(f'Config must be of type "dict" and not "{tmp}"!') + # T8465: the rendered configuration embeds the current DHCP gateway, + # which changes independently of the CLI configuration. The interface + # list must be derived from the configuration itself - the DHCP hook + # list on disk is only written by protocols_static.py, which does not + # run on an interface-only commit. dhcp_gateways = { interface: get_dhcp_router(interface) - for interface in read_file(static_route_dhcp_interfaces_path, '').split() + for interface in get_dhcp_route_interfaces(config_dict) } if ( diff --git a/python/vyos/template.py b/python/vyos/template.py index 83805a73c..f5efdb7ec 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -431,6 +431,14 @@ def get_dhcp_router(interface): Returns None if no router is found, returns the IP address as string if a router is found. + + The file read here is not the dhclient lease database (dhclient_.leases) + but the per event dump written by + /etc/dhcp/dhclient-exit-hooks.d/03-vyos-dhclient-hook. It is intentionally + not removed when the DHCP client is stopped - the RELEASE/STOP event + rewrites it with an empty "new_routers", which is the signal that no router + is available. The file name is keyed on the interface only, a VRF assignment + does not change it. """ lease_file = directories['isc_dhclient_dir'] + f'/dhclient_{interface}.lease' if not os.path.exists(lease_file): -- cgit v1.2.3 From 3f1e103d6774159fb7835c3562a1c1ab834cfd7a Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Fri, 4 Sep 2026 22:26:37 +0200 Subject: static: T9278: honor dhcp-interface routes in dedicated routing tables get_dhcp_route_interfaces() only walked "protocols static route dhcp-interface", so routes configured below "protocols static table route dhcp-interface" never made it into the interface list. Both nodes share the same include/static/static-route.xml.i definition and both are rendered from the DHCP lease via the get_dhcp_router() filter in staticd.frr.j2, so they depend on the current lease in exactly the same way. As a result the affected interfaces were missing from the DHCP gateway cache used for FRR change detection. A lease event on an interface referenced only by a table based route would compare equal to the cached state and the re-render requested by the dhclient hook was skipped, leaving the stale gateway in the rendered configuration. Iterate the route sections of every table in addition to the top level ones. Only IPv4 routes are considered, as "dhcp-interface" does not exist for route6. Named VRFs have no table node, but the collection is shared and stays correct there as well. --- python/vyos/frrender.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) (limited to 'python') diff --git a/python/vyos/frrender.py b/python/vyos/frrender.py index b3f2718ac..1ea49abe4 100644 --- a/python/vyos/frrender.py +++ b/python/vyos/frrender.py @@ -695,18 +695,29 @@ def get_dhcp_route_interfaces(config_dict) -> set: DHCP lease. Two CLI constructs end up reading the DHCP lease file via the "get_dhcp_router" Jinja filter and thus depend on the current lease: - - "protocols static route dhcp-interface " + - "protocols static route dhcp-interface ", either directly + or below "protocols static table " - the default route implied by "interfaces address dhcp" Both variants exist in the default VRF as well as inside any named VRF. """ interfaces = set() + def _add_from_routes(route_conf): + if not isinstance(route_conf, dict): + return + for prefix_options in route_conf.values(): + interfaces.update(prefix_options.get('dhcp_interface', [])) + def _add_from_static(static_conf): if not isinstance(static_conf, dict): return - for prefix_options in static_conf.get('route', {}).values(): - interfaces.update(prefix_options.get('dhcp_interface', [])) + _add_from_routes(static_conf.get('route')) + # Routes in a dedicated routing table use the same "dhcp-interface" + # node, thus they depend on the DHCP lease in the very same way + for table_config in static_conf.get('table', {}).values(): + if isinstance(table_config, dict): + _add_from_routes(table_config.get('route')) for ifname, if_config in static_conf.get('dhcp', {}).items(): # An interface explicitly opting out of the default route does not # contribute a route, thus a lease change is irrelevant for it -- cgit v1.2.3 From 3fc7b6e052402dba9bf13bbff54ff6d99a87fd8e Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sun, 6 Sep 2026 14:16:37 +0000 Subject: dhcp: T9278: move DHCP client hook tracing to a gated debug level Each lease event emitted some fifteen daemon.info lines tracing internal steps rather than recording a change. Add a "debug" level to logmsg(), silenced unless /tmp/vyos.dhclient.debug exists, following the flag file convention used for FRR and ifconfig. Demoting alone would not help, as journald shows all priorities by default. Fifteen tracing messages move to debug; the sixteen recording an actual change stay at info. Also fix logmsg(): "warn" had no case arm although it is used, and the global LOG_PRIO leaked the preceding call's priority. It is now local. --- python/vyos/defaults.py | 1 + .../dhcp/dhclient-enter-hooks.d/01-vyos-logging | 29 +++++++++++++++++++--- .../dhclient-enter-hooks.d/02-vyos-stopdhclient | 2 +- .../dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper | 20 +++++++-------- .../dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf | 4 +-- src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup | 4 +-- src/init/vyos-router | 1 + 7 files changed, 42 insertions(+), 19 deletions(-) (limited to 'python') diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 854f95ff0..23243346d 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -64,6 +64,7 @@ config_files = { config_status = '/tmp/vyos-config-status' api_config_state = '/run/http-api-state' frr_debug_enable = '/tmp/vyos.frr.debug' +dhclient_debug_enable = '/tmp/vyos.dhclient.debug' static_route_dhcp_interfaces_path = '/tmp/static_dhcp_interfaces' vyos_configd_socket_path = 'ipc:///run/vyos-configd.sock' diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/01-vyos-logging b/src/etc/dhcp/dhclient-enter-hooks.d/01-vyos-logging index 121fb21be..01dd5f496 100644 --- a/src/etc/dhcp/dhclient-enter-hooks.d/01-vyos-logging +++ b/src/etc/dhcp/dhclient-enter-hooks.d/01-vyos-logging @@ -3,18 +3,39 @@ LOG_ENABLE="yes" LOG_STDERR="no" LOG_TAG="dhclient-script-vyos" +# Tracing the individual steps of a lease event is only of interest while +# debugging - it is silenced unless this flag file exists. Keep in sync with +# "dhclient_debug_enable" in python/vyos/defaults.py, the file is created by +# src/init/vyos-router when "vyos-debug" is present on the kernel command line. +LOG_DEBUG_ENABLE="/tmp/vyos.dhclient.debug" + function logmsg () { # log message to journal - case $1 in + local LOG_PRIO + local LOG_LEVEL=$1 + shift + + case ${LOG_LEVEL} in + debug) + # Do not even build the message when debugging is not requested + if [ ! -f "${LOG_DEBUG_ENABLE}" ] ; then + return 0 + fi + LOG_PRIO="daemon.debug" ;; + info) LOG_PRIO="daemon.info" ;; + warn) LOG_PRIO="daemon.warning" ;; error) LOG_PRIO="daemon.err" ;; - info) LOG_PRIO="daemon.info" ;; + *) + # Unknown level - do not lose the message, it was never a level + LOG_PRIO="daemon.info" + set -- "${LOG_LEVEL}" "$@" ;; esac if [ "${LOG_ENABLE}" == "yes" ] ; then if [ "${LOG_STDERR}" == "yes" ] ; then - /usr/bin/logger -e --id=$$ -s -p ${LOG_PRIO} -t ${LOG_TAG} "${@:2}" + /usr/bin/logger -e --id=$$ -s -p ${LOG_PRIO} -t ${LOG_TAG} "$@" else - /usr/bin/logger -e --id=$$ -p ${LOG_PRIO} -t ${LOG_TAG} "${@:2}" + /usr/bin/logger -e --id=$$ -p ${LOG_PRIO} -t ${LOG_TAG} "$@" fi fi } diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/02-vyos-stopdhclient b/src/etc/dhcp/dhclient-enter-hooks.d/02-vyos-stopdhclient index ae6bf9f16..2e92c0318 100644 --- a/src/etc/dhcp/dhclient-enter-hooks.d/02-vyos-stopdhclient +++ b/src/etc/dhcp/dhclient-enter-hooks.d/02-vyos-stopdhclient @@ -17,7 +17,7 @@ if [ -z ${CONTROLLED_STOP} ] ; then dhclients_pids=(`ps --no-headers --format pid,args -C dhclient | awk "{ if(match(\\$0, /\s${interface}(\s|$)/) && !match(\\$0, /\s-6\s/)) printf(\"%s\n\", \\$1) }"`) fi - logmsg info "Current dhclient PID: $current_dhclient, Parent PID: $master_dhclient, IP version: $ipversion_arg, All dhclients for interface $interface: ${dhclients_pids[@]}" + logmsg debug "Current dhclient PID: $current_dhclient, Parent PID: $master_dhclient, IP version: $ipversion_arg, All dhclients for interface $interface: ${dhclients_pids[@]}" # stop all dhclients for current interface, except current one for dhclient in ${dhclients_pids[@]}; do if ([ $dhclient -ne $current_dhclient ] && [ $dhclient -ne $master_dhclient ]); then diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper b/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper index bd111d282..780af30ba 100644 --- a/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper +++ b/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper @@ -19,7 +19,7 @@ VRF_OPTION=$(/usr/sbin/ip --json --detail link show ${interface} | jq -r '.[0] | _flush_dhcp_addrs() { local dev="${1:-$interface}" - logmsg info "Selectively flushing only dynamic (DHCP) addresses from ${dev}" + logmsg debug "Selectively flushing only dynamic (DHCP) addresses from ${dev}" local addrs addrs=$(/usr/sbin/ip -4 -j addr show dev "${dev}" 2>&1 | jq -r ' @@ -47,10 +47,10 @@ _flush_dhcp_addrs() { function frr_alive () { /usr/lib/frr/watchfrr.sh all_status if [ "$?" -eq "0" ] ; then - logmsg info "FRR status: running" + logmsg debug "FRR status: running" return 0 else - logmsg info "FRR status: not running" + logmsg debug "FRR status: not running" return 1 fi } @@ -97,12 +97,12 @@ function iptovtysh () { if [ "$VTYSH_ACTION" == "del" ] ; then VTYSH_CMD="no $VTYSH_CMD" fi - logmsg info "Converted vtysh command: \"$VTYSH_CMD\"" + logmsg debug "Converted vtysh command: \"$VTYSH_CMD\"" } # delete the same route from kernel before adding new one function delroute () { - logmsg info "Checking if the route presented in kernel: $@ $VRF_OPTION" + logmsg debug "Checking if the route presented in kernel: $@ $VRF_OPTION" if /usr/sbin/ip route show $@ $VRF_OPTION | grep -qx "$1 " ; then logmsg info "Deleting IP route: \"/usr/sbin/ip route del $@ $VRF_OPTION\"" /usr/sbin/ip route del $@ $VRF_OPTION @@ -114,10 +114,10 @@ function vtysh_conf () { # perform 10 attempts with 1 second delay for retries for i in {1..10} ; do if vtysh -c "conf t" -c "$1" ; then - logmsg info "Command was executed successfully via vtysh: \"$1\"" + logmsg debug "Command was executed successfully via vtysh: \"$1\"" return 0 else - logmsg info "Failed to send command to vtysh, retrying in 1 second" + logmsg warn "Failed to send command to vtysh, retrying in 1 second" sleep 1 fi done @@ -130,7 +130,7 @@ function ip () { # Intercept: ip -4 addr flush dev # to preserve static addresses (only remove dynamic/DHCP addresses) if [ "$1" = "-4" ] && [ "$2" = "addr" ] && [ "$3" = "flush" ]; then - logmsg info "Intercepting 'ip -4 addr flush' to preserve static addresses" + logmsg debug "Intercepting 'ip -4 addr flush' to preserve static addresses" shift 3 local dev="" while [ $# -gt 0 ]; do @@ -143,14 +143,14 @@ function ip () { # pass command to system `ip` if this is not related to routes change if [ "$2" != "route" ] ; then - logmsg info "Passing command to /usr/sbin/ip: \"$@\"" + logmsg debug "Passing command to /usr/sbin/ip: \"$@\"" /usr/sbin/ip $@ else # if we want to work with routes, try to use FRR first if frr_alive ; then delroute ${@:4} iptovtysh $@ - logmsg info "Sending command to vtysh" + logmsg debug "Sending command to vtysh" vtysh_conf "$VTYSH_CMD" else # add ip route to kernel diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf b/src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf index 9a8a53bfd..9aa3a725c 100644 --- a/src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf +++ b/src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf @@ -23,10 +23,10 @@ if /usr/bin/systemctl -q is-active vyos-hostsd; then fi if [ $hostsd_changes ]; then - logmsg info "Applying changes via vyos-hostsd-client" + logmsg debug "Applying changes via vyos-hostsd-client" $hostsd_client --apply else - logmsg info "No changes to apply via vyos-hostsd-client" + logmsg debug "No changes to apply via vyos-hostsd-client" fi } fi diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup b/src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup index da1bda137..803940ecc 100644 --- a/src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup +++ b/src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup @@ -108,8 +108,8 @@ if [[ $reason =~ ^(EXPIRE6|RELEASE6|STOP6)$ ]]; then fi if [ $hostsd_changes ]; then - logmsg info "Applying changes via vyos-hostsd-client" + logmsg debug "Applying changes via vyos-hostsd-client" $hostsd_client --apply else - logmsg info "No changes to apply via vyos-hostsd-client" + logmsg debug "No changes to apply via vyos-hostsd-client" fi diff --git a/src/init/vyos-router b/src/init/vyos-router index 8e0a671a6..8e270602e 100755 --- a/src/init/vyos-router +++ b/src/init/vyos-router @@ -590,6 +590,7 @@ start () touch /tmp/vyos.ifconfig.debug touch /tmp/vyos.container.debug touch /tmp/vyos.smoketest.debug + touch /tmp/vyos.dhclient.debug fi # Cleanup PKI CAs -- cgit v1.2.3