From f481422d87fea451c7c67172d44f016d21b35b6b Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 14 Nov 2025 10:42:54 +0100 Subject: [PATCH 1/7] confd: add deterministic hostname management via /etc/hostname.d/ Resolve race condition between DHCP and configured hostname by introducing priority-based hostname management using /etc/hostname.d/ directory pattern. Priority order (highest wins): 90-dhcp- - DHCP assigned hostname 50-configured - YANG /system/hostname config 10-default - Bootstrap/factory default The new /usr/libexec/infix/hostname helper reads all sources and applies the highest priority hostname. It exits early if hostname unchanged, preventing unnecessary service restarts. Fixes #1112 Signed-off-by: Joachim Wiberg --- .../common/rootfs/usr/libexec/infix/hostname | 74 +++++++++++++++ .../usr/libexec/infix/init.d/05-hostname | 18 ++++ .../rootfs/usr/share/udhcpc/default.script | 11 ++- src/confd/src/ietf-system.c | 93 +++++-------------- 4 files changed, 121 insertions(+), 75 deletions(-) create mode 100755 board/common/rootfs/usr/libexec/infix/hostname create mode 100755 board/common/rootfs/usr/libexec/infix/init.d/05-hostname diff --git a/board/common/rootfs/usr/libexec/infix/hostname b/board/common/rootfs/usr/libexec/infix/hostname new file mode 100755 index 000000000..bfd1952b3 --- /dev/null +++ b/board/common/rootfs/usr/libexec/infix/hostname @@ -0,0 +1,74 @@ +#!/bin/sh +# Deterministically set system hostname from /etc/hostname.d/ +# +# Highest numbered file wins (lexicographic sort, 90-dhcp > 50-configured > 10-default) +# +# Priority scheme: +# 10-default - Bootstrap/factory default (%h-%m format) +# 50-configured - From confd /system/hostname +# 90-dhcp- - From DHCP clietn on interface (highest priority) + +HOSTNAME_D="/etc/hostname.d" + +# Ensure directory exists +mkdir -p "$HOSTNAME_D" + +# Find the highest priority file (reverse sort, take first) +hostname_file=$(ls -1 "$HOSTNAME_D" 2>/dev/null | sort -r | head -1) + +if [ -z "$hostname_file" ]; then + logger -it confd "No hostname sources found in $HOSTNAME_D" + exit 1 +fi + +# Read hostname from the file (first line only, strip whitespace) +new_hostname=$(cat "$HOSTNAME_D/$hostname_file" | head -1 | tr -d '\n\r\t ') +if [ -z "$new_hostname" ]; then + logger -it confd "Empty hostname in $hostname_file" + exit 1 +fi + +if [ ${#new_hostname} -gt 64 ]; then + logger -it confd "Hostname too long (${#new_hostname} > 64) in $hostname_file" + exit 1 +fi + +# Check if hostname has actually changed +current_hostname=$(hostname) +if [ "$new_hostname" = "$current_hostname" ]; then + # No change needed, exit silently + exit 0 +fi + +# Set the hostname +logger -it confd "Setting hostname to '$new_hostname' from $hostname_file" +hostname "$new_hostname" + +# Update /etc/hostname (for persistence across reboots) +echo "$new_hostname" > /etc/hostname + +# Update /etc/hosts (127.0.1.1 entry for proper name resolution) +if grep -q "^127\.0\.1\.1" /etc/hosts; then + sed -i -E "s/^(127\.0\.1\.1\s+).*/\1$new_hostname/" /etc/hosts +else + # Add entry if it doesn't exist + echo "127.0.1.1 $new_hostname" >> /etc/hosts +fi + +# Notify services of hostname change, skip while in bootstrap +initctl -nbq touch sysklogd +if ! runlevel >/dev/null 2>&1; then + exit 0 +fi + +initctl -bq status lldpd && lldpcli configure system hostname "$new_hostname" 2>/dev/null +initctl -bq status mdns && avahi-set-host-name "$new_hostname" 2>/dev/null +initctl -bq touch netbrowse 2>/dev/null + +# If called from dhcp script we need to reload to activate new name in syslogd +# Otherwise we're called from confd, which does the reload when all is done. +if [ -n "$1" ]; then + initctl -b reload +fi + +exit 0 diff --git a/board/common/rootfs/usr/libexec/infix/init.d/05-hostname b/board/common/rootfs/usr/libexec/infix/init.d/05-hostname new file mode 100755 index 000000000..2984ea3cc --- /dev/null +++ b/board/common/rootfs/usr/libexec/infix/init.d/05-hostname @@ -0,0 +1,18 @@ +#!/bin/sh +# Initialize default hostname for hostname.d pattern +# This runs very early in boot to set up the default hostname entry + +HOSTNAME_D="/etc/hostname.d" + +# Ensure directory exists +mkdir -p "$HOSTNAME_D" + +# If no default exists yet, create it from /etc/hostname (from squashfs) +if [ ! -f "$HOSTNAME_D/10-default" ] && [ -f /etc/hostname ]; then + cp /etc/hostname "$HOSTNAME_D/10-default" +fi + +# Apply hostname using the deterministic helper +if [ -x /usr/libexec/infix/hostname ]; then + /usr/libexec/infix/hostname +fi diff --git a/board/common/rootfs/usr/share/udhcpc/default.script b/board/common/rootfs/usr/share/udhcpc/default.script index 4ebb3ac58..660769527 100755 --- a/board/common/rootfs/usr/share/udhcpc/default.script +++ b/board/common/rootfs/usr/share/udhcpc/default.script @@ -109,6 +109,10 @@ case "$ACTION" in # drop info from this interface rm -f "$RESOLV_CONF" rm -f "$NTPFILE" + if [ -f "/etc/hostname.d/90-dhcp-${interface}" ]; then + rm -f "/etc/hostname.d/90-dhcp-${interface}" + /usr/libexec/infix/hostname dhcp + fi if [ -x /usr/sbin/avahi-autoipd ]; then /usr/sbin/avahi-autoipd -c $interface && /usr/sbin/avahi-autoipd -k $interface fi @@ -136,9 +140,10 @@ case "$ACTION" in # set hostname if given if [ -n "$hostname" ]; then - log "setting new hostname: $hostname" - hostname "$hostname" - sed -i -E "s/^(127\.0\.1\.1\s+).*/\1$hostname/" /etc/hosts + log "received DHCP hostname: $hostname" + mkdir -p /etc/hostname.d + echo "$hostname" > "/etc/hostname.d/90-dhcp-${interface}" + /usr/libexec/infix/hostname dhcp fi # drop info from this interface diff --git a/src/confd/src/ietf-system.c b/src/confd/src/ietf-system.c index 1a6b7eb57..eedd9427a 100644 --- a/src/confd/src/ietf-system.c +++ b/src/confd/src/ietf-system.c @@ -206,11 +206,6 @@ static int rpc_set_datetime(sr_session_ctx_t *session, uint32_t sub_id, return rc; } -static int sys_reload_services(void) -{ - return systemf("initctl -nbq touch sysklogd"); -} - #define TIMEZONE_CONF "/etc/timezone" #define TIMEZONE_PREV TIMEZONE_CONF "-" @@ -1558,11 +1553,10 @@ int hostnamefmt(struct confd *confd, const char *fmt, char *hostnm, size_t hostl static int change_hostname(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd) { - const char *hostip = "127.0.1.1"; char hostnm[65], domain[65]; - char buf[256], *fmt; - FILE *nfp, *fp; - int err, fd; + int rc = SR_ERR_OK; + char *fmt; + FILE *fp; if (event != SR_EV_DONE || !lydx_get_xpathf(diff, XPATH_HOSTNAME_)) return SR_ERR_OK; @@ -1572,82 +1566,36 @@ static int change_hostname(sr_session_ctx_t *session, struct lyd_node *config, s fmt = strdup(nm); if (hostnamefmt(confd, fmt, hostnm, sizeof(hostnm), domain, sizeof(domain))) { - err = SR_ERR_SYS; - goto err; - } - - err = sethostname(hostnm, strlen(hostnm)); - if (err) { - ERROR("failed setting hostname"); - err = SR_ERR_SYS; - goto err; + rc = SR_ERR_SYS; + goto failed; } - if (domain[0] && setdomainname(domain, strlen(domain))) { - ERROR("failed setting domain name"); - /* Not cause for failing this function */ - } + /* Use hostname.d for deterministic hostname management */ + systemf("mkdir -p /etc/hostname.d"); - fp = fopen(_PATH_HOSTNAME, "w"); - if (!fp) { - err = SR_ERR_INTERNAL; - goto err; - } + fp = fopen("/etc/hostname.d/50-configured", "w"); + if (!fp) + goto failed; fprintf(fp, "%s\n", hostnm); fclose(fp); - nfp = fopen(_PATH_HOSTS "+", "w"); - if (!nfp) { - err = SR_ERR_INTERNAL; - goto err; - } - fd = fileno(nfp); - if (fd == -1 || fchown(fd, 0, 0) || fchmod(fd, 0644)) { - fclose(nfp); - goto err; - } - - fp = fopen(_PATH_HOSTS, "r"); - if (!fp) { - err = SR_ERR_INTERNAL; - fclose(nfp); - goto err; + /* Handle domain name if present */ + if (domain[0] && setdomainname(domain, strlen(domain))) { + ERROR("failed setting domain name"); + /* Not cause for failing this function */ } - while (fgets(buf, sizeof(buf), fp)) { - if (!strncmp(buf, hostip, strlen(hostip))) { - if (domain[0]) - snprintf(buf, sizeof(buf), "%s\t%s.%s %s\n", hostip, hostnm, domain, hostnm); - else - snprintf(buf, sizeof(buf), "%s\t%s\n", hostip, hostnm); - } - fputs(buf, nfp); + if (systemf("/usr/libexec/infix/hostname")) { + failed: + ERROR("failed setting hostname"); + rc = SR_ERR_SYS; } - fclose(fp); - fclose(nfp); - if (rename(_PATH_HOSTS "+", _PATH_HOSTS)) - ERRNO("Failed activating changes to "_PATH_HOSTS); - - /* skip in bootstrap, lldpd and avahi have not started yet */ - if (systemf("runlevel >/dev/null 2>&1")) - goto err; - - /* Inform any running lldpd and avahi of the change ... */ - systemf("initctl -bq status lldpd && lldpcli configure system hostname %s", hostnm); - systemf("initctl -bq status mdns && avahi-set-host-name %s", hostnm); - systemf("initctl -bq touch netbrowse"); -err: if (fmt) free(fmt); - - if (err) { - ERROR("Failed activating changes."); - return err; - } - if (sys_reload_services()) - return SR_ERR_SYS; + if (rc) + return rc; return SR_ERR_OK; } @@ -1656,6 +1604,7 @@ static int change_hostname(sr_session_ctx_t *session, struct lyd_node *config, s int ietf_system_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd) { int rc = SR_ERR_OK; + if ((rc = change_auth(session, config, diff, event, confd))) return rc; if ((rc = change_ntp(session, config, diff, event, confd))) From dbd6831847150d5135589eb55bfa1368ed833f57 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 17 Nov 2025 14:59:12 +0100 Subject: [PATCH 2/7] board/common: only apply explicitly requested DHCP options in client Validate that DHCP options were requested in the parameter request list before applying them. This prevents malicious or misconfigured DHCP servers from forcing unwanted configuration changes. Validates: hostname (12), DNS (6), domain (15), search (119), router (3), static routes (121), and NTP (42). Fail-safe behavior: rejects options if config file unavailable. Signed-off-by: Joachim Wiberg --- .../rootfs/usr/share/udhcpc/default.script | 112 +++++++++++++----- 1 file changed, 82 insertions(+), 30 deletions(-) diff --git a/board/common/rootfs/usr/share/udhcpc/default.script b/board/common/rootfs/usr/share/udhcpc/default.script index 660769527..1ea929a93 100755 --- a/board/common/rootfs/usr/share/udhcpc/default.script +++ b/board/common/rootfs/usr/share/udhcpc/default.script @@ -50,6 +50,26 @@ wait_for_ipv6_default_route() err "Timed out waiting for IPv6 default route!" } +# Check if a DHCP option was requested in the parameter request list +# Returns: 0 if requested, 1 if not requested or config unavailable +was_option_requested() +{ + local opt_num="$1" + local config="/etc/finit.d/available/dhcp-client-${interface}.conf" + + if [ ! -f "$config" ]; then + dbg "config file not found: $config" + return 1 + fi + + # Extract udhcpc command line and check for -O + if grep -q -- "-O ${opt_num}\b" "$config"; then + return 0 + fi + + return 1 +} + # RFC3442: If the DHCP server returns both a Classless # Static Routes option and a Router option, the DHCP # client MUST ignore the Router option. @@ -57,17 +77,25 @@ set_dhcp_routes() { echo "! Generated by udhcpc" > "$NEXT" if [ -n "$staticroutes" ]; then - # format: dest1/mask gw1 ... destn/mask gwn - set -- $staticroutes - while [ -n "$1" -a -n "$2" ]; do - dbg "adding route $1 via $2 metric $metric tag 100" - echo "ip route $1 $2 $metric tag 100" >> "$NEXT" - shift 2 - done + if was_option_requested 121; then + # format: dest1/mask gw1 ... destn/mask gwn + set -- $staticroutes + while [ -n "$1" -a -n "$2" ]; do + dbg "adding route $1 via $2 metric $metric tag 100" + echo "ip route $1 $2 $metric tag 100" >> "$NEXT" + shift 2 + done + else + log "ignoring unrequested staticroutes (option 121)" + fi elif [ -n "$router" ] ; then - for i in $router ; do - echo "ip route 0.0.0.0/0 $i $metric tag 100" >> "$NEXT" - done + if was_option_requested 3; then + for i in $router ; do + echo "ip route 0.0.0.0/0 $i $metric tag 100" >> "$NEXT" + done + else + log "ignoring unrequested router (option 3)" + fi fi # Reduce changes needed by comparing with previous route(s) @@ -110,8 +138,9 @@ case "$ACTION" in rm -f "$RESOLV_CONF" rm -f "$NTPFILE" if [ -f "/etc/hostname.d/90-dhcp-${interface}" ]; then - rm -f "/etc/hostname.d/90-dhcp-${interface}" - /usr/libexec/infix/hostname dhcp + log "removing /etc/hostname.d/90-dhcp-${interface}" + rm -f "/etc/hostname.d/90-dhcp-${interface}" + /usr/libexec/infix/hostname dhcp fi if [ -x /usr/sbin/avahi-autoipd ]; then /usr/sbin/avahi-autoipd -c $interface && /usr/sbin/avahi-autoipd -k $interface @@ -138,22 +167,35 @@ case "$ACTION" in set_dhcp_routes - # set hostname if given + # set hostname if given and requested if [ -n "$hostname" ]; then - log "received DHCP hostname: $hostname" - mkdir -p /etc/hostname.d - echo "$hostname" > "/etc/hostname.d/90-dhcp-${interface}" - /usr/libexec/infix/hostname dhcp + if was_option_requested 12; then + log "received DHCP hostname: $hostname" + mkdir -p /etc/hostname.d + echo "$hostname" > "/etc/hostname.d/90-dhcp-${interface}" + /usr/libexec/infix/hostname dhcp + else + log "ignoring unrequested hostname (option 12): $hostname" + fi fi # drop info from this interface truncate -s 0 "$RESOLV_CONF" # prefer rfc3397 domain search list (option 119) if available + search_list="" if [ -n "$search" ]; then - search_list=$search + if was_option_requested 119; then + search_list=$search + else + log "ignoring unrequested search (option 119): $search" + fi elif [ -n "$domain" ]; then - search_list=$domain + if was_option_requested 15; then + search_list=$domain + else + log "ignoring unrequested domain (option 15): $domain" + fi fi if [ -n "$search_list" ]; then @@ -161,19 +203,29 @@ case "$ACTION" in echo "search $search_list # $interface" >> $RESOLV_CONF fi - for i in $dns ; do - dbg "adding dns $i" - echo "nameserver $i # $interface" >> $RESOLV_CONF - resolvconf -u - done + if [ -n "$dns" ]; then + if was_option_requested 6; then + for i in $dns ; do + dbg "adding dns $i" + echo "nameserver $i # $interface" >> $RESOLV_CONF + resolvconf -u + done + else + log "ignoring unrequested dns (option 6): $dns" + fi + fi if [ -n "$ntpsrv" ]; then - truncate -s 0 "$NTPFILE" - for srv in $ntpsrv; do - dbg "got NTP server $srv" - echo "server $srv iburst" >> "$NTPFILE" - done - chronyc reload sources >/dev/null + if was_option_requested 42; then + truncate -s 0 "$NTPFILE" + for srv in $ntpsrv; do + dbg "got NTP server $srv" + echo "server $srv iburst" >> "$NTPFILE" + done + chronyc reload sources >/dev/null + else + log "ignoring unrequested ntpsrv (option 42): $ntpsrv" + fi fi esac From ac285120d6de7d8a8b0d9760b528a5ab137f7879 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 17 Nov 2025 15:00:44 +0100 Subject: [PATCH 3/7] test/infamy: pep-8, minor whitespace Signed-off-by: Joachim Wiberg --- test/infamy/netns.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/infamy/netns.py b/test/infamy/netns.py index 317e006db..870c01056 100644 --- a/test/infamy/netns.py +++ b/test/infamy/netns.py @@ -301,10 +301,11 @@ class IsolatedMacVlan(IsolatedMacVlans): """ def __init__(self, parent, ifname="iface", lo=True, set_up=True): self._ifname = ifname - return super().__init__(ifmap={ parent: ifname }, lo=lo, set_up=set_up) + return super().__init__(ifmap={parent: ifname}, lo=lo, set_up=set_up) def addip(self, addr, prefix_length=24, proto="ipv4"): - return super().addip(ifname=self._ifname, addr=addr, prefix_length=prefix_length, proto=proto) + return super().addip(ifname=self._ifname, addr=addr, + prefix_length=prefix_length, proto=proto) def must_receive(self, expr, timeout=None, ifname=None, must=True): ifname = ifname if ifname else self._ifname From da2738e4b99a3c85cbd4edb6ed0b3b257cb3d64f Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 17 Nov 2025 16:37:16 +0100 Subject: [PATCH 4/7] test/infamy: allow more fine-grained control of macvlans This change exposes the macvlan mode to tests, unlocking support for running certain types of tests on systems with only a single Ethernet port. Signed-off-by: Joachim Wiberg --- test/infamy/netns.py | 50 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/test/infamy/netns.py b/test/infamy/netns.py index 870c01056..8ac0414dc 100644 --- a/test/infamy/netns.py +++ b/test/infamy/netns.py @@ -28,6 +28,17 @@ class IsolatedMacVlans: NOTE: For the simple case when only one interface needs to be mapped, see IsolatedMacVlan below. + Args: + ifmap: Dictionary mapping parent interface names to MACVLAN names + lo: Enable loopback interface in the namespace (default: True) + set_up: Automatically bring up the interfaces (default: True) + mode: MACVLAN mode to use (default: "passthru") + - "passthru": Exclusive access, single MACVLAN per parent. + Parent interface becomes promiscuous. + - "bridge": Shared access, allows multiple MACVLANs to + communicate. Required for layer-2 tests that + need full control of all frames. + Example: netns = IsolatedMacVlans({ "eth2": "a", "eth3": "b" }) @@ -46,9 +57,10 @@ def Cleanup(): for ns in list(IsolatedMacVlans.Instances): ns.stop() - def __init__(self, ifmap, lo=True, set_up=True): + def __init__(self, ifmap, lo=True, set_up=True, mode="passthru"): self.sleeper = None self.ifmap, self.lo, self.set_up = ifmap, lo, set_up + self.mode = mode self.ping_timeout = env.ENV.attr("ping_timeout", 5) def start(self): @@ -64,7 +76,8 @@ def start(self): "link", parent, "address", self._stable_mac(parent), "netns", str(self.sleeper.pid), - "type", "macvlan", "mode", "passthru"], check=True) + "type", "macvlan", "mode", self.mode], + check=True) self.runsh(f""" while ! ip link show dev {ifname}; do sleep 0.1 @@ -287,6 +300,17 @@ class IsolatedMacVlan(IsolatedMacVlans): moves that interface to a separate namespace, isolating it from all other interfaces. + Args: + parent: Name of the parent interface on the controller + ifname: Name of the MACVLAN interface in the namespace (default: "iface") + lo: Enable loopback interface in the namespace (default: True) + set_up: Automatically bring up the interface (default: True) + mode: MACVLAN mode to use (default: "passthru") + - "passthru": Exclusive access, single MACVLAN per parent. + - "bridge": Shared access, required for layer-2 tests that + need to communicate with other MACVLANs on the + same parent or control all frames. + Example: netns = IsolatedMacVlan("eth3") @@ -298,10 +322,15 @@ class IsolatedMacVlan(IsolatedMacVlans): | eth0 eth1 eth2 eth3 + Example with bridge mode: + + netns = IsolatedMacVlan("eth3", mode="bridge") + """ - def __init__(self, parent, ifname="iface", lo=True, set_up=True): + def __init__(self, parent, ifname="iface", lo=True, set_up=True, mode="passthru"): self._ifname = ifname - return super().__init__(ifmap={parent: ifname}, lo=lo, set_up=set_up) + return super().__init__(ifmap={parent: ifname}, lo=lo, set_up=set_up, + mode=mode) def addip(self, addr, prefix_length=24, proto="ipv4"): return super().addip(ifname=self._ifname, addr=addr, @@ -388,10 +417,19 @@ class TPMR(IsolatedMacVlans): This is useful to verify the correctness of fail-over behavior in various protocols. See ospf_bfd for a usage example. + + Args: + a: Name of the first parent interface on the controller + b: Name of the second parent interface on the controller + mode: MACVLAN mode to use (default: "passthru") + - "passthru": Exclusive access (default) + - "bridge": Shared access, allows communication between MACVLANs + and full control of all frames. May be required for + proper layer-2 relay functionality in some tests. """ - def __init__(self, a, b): - super().__init__(ifmap={ a: "a", b: "b" }, lo=False) + def __init__(self, a, b, mode="passthru"): + super().__init__(ifmap={ a: "a", b: "b" }, lo=False, mode=mode) def start(self, forward=True): ret = super().start() From 26fa61f4941d75ffd836696e8260ee6e20b7df91 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 14 Nov 2025 12:37:40 +0100 Subject: [PATCH 5/7] test: redirect python cache to /tmp to fix cleanup issues Set PYTHONPYCACHEDIR=/tmp/__pycache__ in test/env to avoid permission issues during git cleanup operations. When tests run in Docker containers as root, __pycache__ directories and .pyc files end up owned by root, preventing the CI user from cleaning them up. Signed-off-by: Joachim Wiberg --- test/env | 1 + 1 file changed, 1 insertion(+) diff --git a/test/env b/test/env index b5e651a5d..b89e21ea1 100755 --- a/test/env +++ b/test/env @@ -228,6 +228,7 @@ if [ "$containerize" ]; then --cap-add=NET_ADMIN \ --device=/dev/net/tun \ --env PYTHONHASHSEED=${PYTHONHASHSEED:-$(shuf -i 0-$(((1 << 32) - 1)) -n 1)} \ + --env PYTHONPYCACHEDIR=/tmp/__pycache__ \ --env VIRTUAL_ENV_DISABLE_PROMPT=yes \ --env INFAMY_ARGS="$INFAMY_ARGS" \ --env INFAMY_EXTRA_ARGS="$INFAMY_EXTRA_ARGS" \ From 5331be48c1f9cec439b2e8911d9b75d079d526a4 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 14 Nov 2025 10:47:32 +0100 Subject: [PATCH 6/7] test: new test, dhcp hostname priority test Verify deterministic hostname management via /etc/hostname.d/, rules: 1. Configured hostname takes precedence over default 2. DHCP hostname takes precedence over configured 3. Hostname reverts when DHCP lease ends Signed-off-by: Joachim Wiberg --- .../infix_dhcp/client_hostname/Readme.adoc | 1 + .../case/infix_dhcp/client_hostname/test.adoc | 26 +++++++ test/case/infix_dhcp/client_hostname/test.py | 73 +++++++++++++++++++ .../infix_dhcp/client_hostname/topology.dot | 22 ++++++ .../infix_dhcp/client_hostname/topology.svg | 33 +++++++++ test/case/infix_dhcp/dhcp_client.yaml | 3 + test/infamy/dhcp.py | 10 ++- 7 files changed, 165 insertions(+), 3 deletions(-) create mode 120000 test/case/infix_dhcp/client_hostname/Readme.adoc create mode 100644 test/case/infix_dhcp/client_hostname/test.adoc create mode 100755 test/case/infix_dhcp/client_hostname/test.py create mode 100644 test/case/infix_dhcp/client_hostname/topology.dot create mode 100644 test/case/infix_dhcp/client_hostname/topology.svg diff --git a/test/case/infix_dhcp/client_hostname/Readme.adoc b/test/case/infix_dhcp/client_hostname/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/infix_dhcp/client_hostname/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/infix_dhcp/client_hostname/test.adoc b/test/case/infix_dhcp/client_hostname/test.adoc new file mode 100644 index 000000000..55756a6c9 --- /dev/null +++ b/test/case/infix_dhcp/client_hostname/test.adoc @@ -0,0 +1,26 @@ +=== DHCP Hostname Priority + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_dhcp/client_hostname] + +==== Description + +Verify deterministic hostname management: a DHCP acquired hostname takes +precedence over a configured hostname. When a DHCP lease ends, or the +hostname option is removed, the system should revert to the configured +hostname. + +==== Topology + +image::topology.svg[DHCP Hostname Priority topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUT +. Configure static system hostname +. Verify configured hostname is set +. Enable DHCP client requesting hostname option +. Verify DHCP hostname takes precedence +. Drop hostname option from client request +. Verify hostname reverts to configured value + + diff --git a/test/case/infix_dhcp/client_hostname/test.py b/test/case/infix_dhcp/client_hostname/test.py new file mode 100755 index 000000000..003b15132 --- /dev/null +++ b/test/case/infix_dhcp/client_hostname/test.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""DHCP Hostname Priority + +Verify deterministic hostname management: a DHCP acquired hostname takes +precedence over a configured hostname. When a DHCP lease ends, or the +hostname option is removed, the system should revert to the configured +hostname. + +""" + +import infamy, infamy.dhcp +from infamy.util import until + + +def verify_hostname(node, expected): + """Verify operational hostname matches expected value""" + data = node.get_data("/ietf-system:system") + return data["system"]["hostname"] == expected + + +with infamy.Test() as test: + DHCP_HOSTNAME = "dhcp-assigned" + CONF_HOSTNAME = "configured-host" + + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + client = env.attach("client", "mgmt") + _, host = env.ltop.xlate("host", "mgmt") + _, port = env.ltop.xlate("client", "mgmt") + + with test.step("Configure static system hostname"): + client.put_config_dict("ietf-system", { + "system": { + "hostname": CONF_HOSTNAME + } + }) + + with test.step("Verify configured hostname is set"): + until(lambda: verify_hostname(client, CONF_HOSTNAME)) + + with infamy.IsolatedMacVlan(host, mode="private") as netns: + netns.addip("10.0.0.1") + with infamy.dhcp.Server(netns, ip="10.0.0.42", hostname=DHCP_HOSTNAME): + with test.step("Enable DHCP client requesting hostname option"): + client.put_config_dict("ietf-interfaces", { + "interfaces": { + "interface": [{ + "name": port, + "ipv4": { + "infix-dhcp-client:dhcp": { + "option": [ + {"id": "hostname"}, + {"id": "netmask"}, + {"id": "router"} + ] + } + } + }] + } + }) + + with test.step("Verify DHCP hostname takes precedence"): + until(lambda: verify_hostname(client, DHCP_HOSTNAME)) + + with test.step("Drop hostname option from client request"): + path = f"/ietf-interfaces:interfaces/interface[name='{port}']" \ + + "/ietf-ip:ipv4/infix-dhcp-client:dhcp/option[id='hostname']" + client.delete_xpath(path) + + with test.step("Verify hostname reverts to configured value"): + until(lambda: verify_hostname(client, CONF_HOSTNAME)) + + test.succeed() diff --git a/test/case/infix_dhcp/client_hostname/topology.dot b/test/case/infix_dhcp/client_hostname/topology.dot new file mode 100644 index 000000000..d2b1b2bcb --- /dev/null +++ b/test/case/infix_dhcp/client_hostname/topology.dot @@ -0,0 +1,22 @@ +graph "1x1" { + layout="neato"; + overlap="false"; + esep="+100"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt }", + pos="0,20!", + requires="controller", + ]; + + client [ + label="{ mgmt } | client", + pos="200,20!", + requires="infix", + ]; + + host:mgmt -- client:mgmt [requires="mgmt", color=lightgrey] +} diff --git a/test/case/infix_dhcp/client_hostname/topology.svg b/test/case/infix_dhcp/client_hostname/topology.svg new file mode 100644 index 000000000..20a632e9f --- /dev/null +++ b/test/case/infix_dhcp/client_hostname/topology.svg @@ -0,0 +1,33 @@ + + + + + + +1x1 + + + +host + +host + +mgmt + + + +client + +mgmt + +client + + + +host:mgmt--client:mgmt + + + + diff --git a/test/case/infix_dhcp/dhcp_client.yaml b/test/case/infix_dhcp/dhcp_client.yaml index d2244bad0..98bbe3a77 100644 --- a/test/case/infix_dhcp/dhcp_client.yaml +++ b/test/case/infix_dhcp/dhcp_client.yaml @@ -10,3 +10,6 @@ - name: DHCP option 121 vs option 3 case: client_routes/test.py + +- name: DHCP Hostname Priority + case: client_hostname/test.py diff --git a/test/infamy/dhcp.py b/test/infamy/dhcp.py index 7edaa41f8..62731e3c5 100644 --- a/test/infamy/dhcp.py +++ b/test/infamy/dhcp.py @@ -5,11 +5,13 @@ class Server: config_file = '/tmp/udhcpd.conf' leases_file = '/tmp/udhcpd.leases' - def __init__(self, netns, start='192.168.0.100', end='192.168.0.110', netmask='255.255.255.0', ip=None, router=None, prefix=None, iface="iface"): + def __init__(self, netns, start='192.168.0.100', end='192.168.0.110', + netmask='255.255.255.0', ip=None, router=None, prefix=None, + hostname=None, iface="iface"): self.process = None self.netns = netns self.iface = iface - self._create_files(start, end, netmask, ip, router, prefix) + self._create_files(start, end, netmask, ip, router, prefix, hostname) def __del__(self): #print(self.config_file) @@ -23,7 +25,7 @@ def __enter__(self): def __exit__(self, _, __, ___): self.stop() - def _create_files(self, start, end, netmask, ip, router, prefix): + def _create_files(self, start, end, netmask, ip, router, prefix, hostname): f = open(self.leases_file, "w") f.close() @@ -44,6 +46,8 @@ def _create_files(self, start, end, netmask, ip, router, prefix): f.write(f"option router {router}\n") if prefix and router: f.write(f"option staticroutes {prefix} {router}\n") + if hostname: + f.write(f"option hostname {hostname}\n") def get_pid(self): return self.process.pid From e8cf2f3f947bd2e128d81389a71490b02a8cdbb4 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 14 Nov 2025 13:12:56 +0100 Subject: [PATCH 7/7] doc: update ChangeLog, dhcp hostname fix and boot time Fixes #1255 Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 497268489..ed47340cb 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -48,11 +48,13 @@ All notable changes to the project are documented in this file. ### Fixes - Fix #855: User admin sometimes fails to be added to `wheel` group +- Fix #1112: setting hostname via DHCP client sometimes gets overridden by the + configured system hostname - Fix #1247: Prevent invalid configuration of OSPF backbone area (0.0.0.0) as stub or NSSA. The backbone must always be a normal area per RFC 2328. Any existing invalid configurations are automatically corrected during upgrade -- Fix serious regression in boot time, introduced in v25.10, delays the - boot step "Mounting filesystems ..." with up to 30 seconds! +- Fix #1255: serious regression in boot time, introduced in v25.10, delays the + boot step "Mounting filesystems ...", from 30 seconds up to five minutes! - Fix broken intra-document links in container and tunnel documentation [lastest-boot]: https://github.com/kernelkit/infix/releases/latest-boot