From a568f31309fbb8f6ffdd85e8e9b012e0ade7757f Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 9 Nov 2025 16:18:57 +0100 Subject: [PATCH 1/8] confd: add support for dhcpv6 client Basic DHCPv6 support as a per-interface ipv6 setting, not in line with the IETF YANG model, which has this as a root container. Note, this also fixes DHCPv4 option inference after the relocation of /dhcp-client to /interfaces, in 764bd8e. Fixes #1110 Signed-off-by: Joachim Wiberg --- board/common/rootfs/etc/udhcpc/default.script | 1 + .../common/rootfs/etc/udhcpc/default6.script | 1 + .../rootfs/usr/share/udhcpc/default6.script | 112 ++++++++ src/confd/src/Makefile.am | 1 + src/confd/src/core.c | 8 +- src/confd/src/core.h | 7 +- src/confd/src/ietf-interfaces.c | 4 + src/confd/src/ietf-interfaces.h | 3 + src/confd/src/infix-dhcp-client.c | 226 +--------------- src/confd/src/infix-dhcp-common.c | 242 ++++++++++++++++++ src/confd/src/infix-dhcp-common.h | 20 ++ src/confd/src/infix-dhcp-server.c | 3 +- src/confd/src/infix-dhcpv6-client.c | 167 ++++++++++++ src/confd/yang/confd.inc | 5 +- src/confd/yang/confd/infix-dhcp-client.yang | 6 +- ...yang => infix-dhcp-client@2025-11-09.yang} | 0 src/confd/yang/confd/infix-dhcp-common.yang | 73 ++++++ ...yang => infix-dhcp-common@2025-11-09.yang} | 0 src/confd/yang/confd/infix-dhcpv6-client.yang | 140 ++++++++++ .../confd/infix-dhcpv6-client@2025-11-09.yang | 1 + 20 files changed, 786 insertions(+), 234 deletions(-) create mode 120000 board/common/rootfs/etc/udhcpc/default.script create mode 120000 board/common/rootfs/etc/udhcpc/default6.script create mode 100755 board/common/rootfs/usr/share/udhcpc/default6.script create mode 100644 src/confd/src/infix-dhcp-common.h create mode 100644 src/confd/src/infix-dhcpv6-client.c rename src/confd/yang/confd/{infix-dhcp-client@2025-11-02.yang => infix-dhcp-client@2025-11-09.yang} (100%) rename src/confd/yang/confd/{infix-dhcp-common@2025-01-29.yang => infix-dhcp-common@2025-11-09.yang} (100%) create mode 100644 src/confd/yang/confd/infix-dhcpv6-client.yang create mode 120000 src/confd/yang/confd/infix-dhcpv6-client@2025-11-09.yang diff --git a/board/common/rootfs/etc/udhcpc/default.script b/board/common/rootfs/etc/udhcpc/default.script new file mode 120000 index 00000000..b633b25c --- /dev/null +++ b/board/common/rootfs/etc/udhcpc/default.script @@ -0,0 +1 @@ +/usr/share/udhcpc/default.script \ No newline at end of file diff --git a/board/common/rootfs/etc/udhcpc/default6.script b/board/common/rootfs/etc/udhcpc/default6.script new file mode 120000 index 00000000..85411a05 --- /dev/null +++ b/board/common/rootfs/etc/udhcpc/default6.script @@ -0,0 +1 @@ +/usr/share/udhcpc/default6.script \ No newline at end of file diff --git a/board/common/rootfs/usr/share/udhcpc/default6.script b/board/common/rootfs/usr/share/udhcpc/default6.script new file mode 100755 index 00000000..219d6a3e --- /dev/null +++ b/board/common/rootfs/usr/share/udhcpc/default6.script @@ -0,0 +1,112 @@ +#!/bin/sh +# This script expects a system with resolvconf (openresolv) and iproute2 + +[ -z "$1" ] && echo "Error: should be called from udhcpc6" && exit 1 + +ACTION="$1" +RESOLV_CONF="/run/resolvconf/interfaces/${interface}-ipv6.conf" +NTPFILE="/run/chrony/dhcp-sources.d/${interface}-ipv6.sources" + +[ -n "$metric" ] || metric=5 + +if [ -z "${IF_WAIT_DELAY}" ]; then + IF_WAIT_DELAY=10 +fi + +log() +{ + logger -I $$ -t udhcpc6 -p user.notice "${interface}: $*" +} + +dbg() +{ + logger -I $$ -t udhcpc6 -p user.debug "${interface}: $*" +} + +err() +{ + logger -I $$ -t udhcpc6 -p user.err "${interface}: $*" +} + +clr_dhcpv6_addresses() +{ + addrs=$(ip -j addr show dev $interface \ + | jq -c '.[0].addr_info[] | select(.family == "inet6") | select(.protocol == "dhcp")') + + for addr in $addrs; do + ip="$(echo "$addr" | jq -r '."local"')" + prefix="$(echo "$addr" | jq -r '."prefixlen"')" + log "removing $ip/$prefix" + ip addr del "$ip/$prefix" dev "$interface" + done +} + +log "action $ACTION" +case "$ACTION" in + deconfig) + clr_dhcpv6_addresses + /bin/ip link set dev $interface up + + # drop info from this interface + rm -f "$RESOLV_CONF" + rm -f "$NTPFILE" + ;; + + leasefail|nak) + # DHCPv6 lease failed - log it + err "DHCPv6 lease failed" + ;; + + renew|bound) + # Add IPv6 address if provided (stateful DHCPv6) + if [ -n "$ipv6" ]; then + if /bin/ip addr add dev $interface $ipv6 proto dhcp; then + log "assigned address $ipv6" + fi + fi + + # Handle delegated prefix (prefix delegation) + if [ -n "$ipv6prefix" ]; then + log "received delegated prefix $ipv6prefix" + # Prefix delegation handling could be added here + # For now, just log it - actual routing/distribution + # would need additional configuration + fi + + # drop info from this interface + truncate -s 0 "$RESOLV_CONF" + + # DHCPv6 domain search list (option 24) + if [ -n "$search" ]; then + dbg "adding search $search" + echo "search $search # $interface" >> $RESOLV_CONF + fi + + # DHCPv6 DNS servers (option 23) + if [ -n "$dns" ]; then + for i in $dns ; do + dbg "adding dns $i" + echo "nameserver $i # $interface" >> $RESOLV_CONF + done + resolvconf -u + fi + + # NTP servers (option 56) + 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 + fi + ;; +esac + +HOOK_DIR="$0.d" +for hook in "${HOOK_DIR}/"*; do + [ -f "${hook}" -a -x "${hook}" ] || continue + "${hook}" "$ACTION" +done + +exit 0 diff --git a/src/confd/src/Makefile.am b/src/confd/src/Makefile.am index 29466e99..3692de39 100644 --- a/src/confd/src/Makefile.am +++ b/src/confd/src/Makefile.am @@ -45,6 +45,7 @@ confd_plugin_la_SOURCES = \ ietf-routing.c \ infix-dhcp-common.c \ infix-dhcp-client.c \ + infix-dhcpv6-client.c \ infix-dhcp-server.c \ infix-factory.c \ infix-firewall.c \ diff --git a/src/confd/src/core.c b/src/confd/src/core.c index f452df35..dc3f270d 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -198,6 +198,10 @@ static int change_cb(sr_session_ctx_t *session, uint32_t sub_id, const char *mod if ((rc = infix_dhcp_client_change(session, config, diff, event, confd))) goto free_diff; + /* infix-dhcpv6-client*/ + if ((rc = infix_dhcpv6_client_change(session, config, diff, event, confd))) + goto free_diff; + /* ietf-keystore */ if ((rc = ietf_keystore_change(session, config, diff, event, confd))) goto free_diff; @@ -428,10 +432,6 @@ int sr_plugin_init_cb(sr_session_ctx_t *session, void **priv) goto err; rc = infix_dhcp_server_candidate_init(&confd); - if (rc) - goto err; - - rc = infix_dhcp_client_candidate_init(&confd); if (rc) goto err; /* YOUR_INIT GOES HERE */ diff --git a/src/confd/src/core.h b/src/confd/src/core.h index 87f59849..fc0e62d8 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -204,12 +204,11 @@ static inline int infix_containers_rpc_init(struct confd *confd) { return 0; } static inline int infix_containers_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd) { return 0; } #endif -/* infix-dhcp-common.c */ -int dhcp_option_lookup(const struct lyd_node *id); - /* infix-dhcp-client.c */ int infix_dhcp_client_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd); -int infix_dhcp_client_candidate_init(struct confd *confd); + +/* infix-dhcpv6-client.c */ +int infix_dhcpv6_client_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd); /* infix-dhcp-server.c */ int infix_dhcp_server_candidate_init(struct confd *confd); diff --git a/src/confd/src/ietf-interfaces.c b/src/confd/src/ietf-interfaces.c index db10e3f7..ee42a6a7 100644 --- a/src/confd/src/ietf-interfaces.c +++ b/src/confd/src/ietf-interfaces.c @@ -160,6 +160,10 @@ static int ifchange_cand(sr_session_ctx_t *session, uint32_t sub_id, const char if (err) break; + err = ifchange_cand_infer_dhcp(session, new->xpath); + if (err) + break; + err = cni_ifchange_cand_infer_type(session, new->xpath); if (err) break; diff --git a/src/confd/src/ietf-interfaces.h b/src/confd/src/ietf-interfaces.h index 655f6a83..a34accad 100644 --- a/src/confd/src/ietf-interfaces.h +++ b/src/confd/src/ietf-interfaces.h @@ -144,6 +144,9 @@ int ifchange_cand_infer_vlan(sr_session_ctx_t *session, const char *path); int vlan_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip); int vlan_add_deps(struct lyd_node *cif); +/* infix-dhcp-common.c */ +int ifchange_cand_infer_dhcp(sr_session_ctx_t *session, const char *path); + /* infix-if-vxlan.c */ int vxlan_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip); diff --git a/src/confd/src/infix-dhcp-client.c b/src/confd/src/infix-dhcp-client.c index c2da13b8..74a82dd6 100644 --- a/src/confd/src/infix-dhcp-client.c +++ b/src/confd/src/infix-dhcp-client.c @@ -12,6 +12,8 @@ #include #include "core.h" +#include "infix-dhcp-common.h" + #define ARPING_MSEC 1000 #define MODULE "infix-dhcp-client" #define XPATH "/ietf-interfaces:interfaces/interface/ietf-ip:ipv4/infix-dhcp-client:dhcp" @@ -48,140 +50,6 @@ static char *ip_cache(const char *ifname, char *str, size_t len) return str; } -static char *hostname(struct lyd_node *cfg, char *str, size_t len) -{ - struct lyd_node *node; - const char *hostname; - char *ptr; - - node = lydx_get_xpathf(cfg, "/ietf-system:system/hostname"); - if (!node) - return NULL; - - hostname = lyd_get_value(node); - if (!hostname || hostname[0] == 0) - return NULL; - - ptr = strchr(hostname, '.'); - if (ptr) - *ptr = 0; - - snprintf(str, len, "-x hostname:%s ", hostname); - - return str; -} - -static char *fqdn(const char *val, char *str, size_t len) -{ - snprintf(str, len, "-F \"%s\" ", val); - return str; -} - -static char *os_name_version(char *str, size_t len) -{ - char *val; - - if (!str || !len) - return NULL; - - str[0] = 0; - - val = fgetkey("/etc/os-release", "NAME"); - if (val) - snprintf(str, len, "-V \"%.32s ", val); - - val = fgetkey("/etc/os-release", "VERSION"); - if (val) { - strlcat(str, val, len); - strlcat(str, "\"", len); - } - - if (strlen(str) > 0 && str[strlen(str) - 1] != '"') { - str[0] = 0; - return NULL; - } - - return str; -} - -static char *compose_option(struct lyd_node *cfg, const char *ifname, struct lyd_node *id, - const char *val, const char *hex, char *option, size_t len) -{ - const char *name = lyd_get_value(id); - int num = dhcp_option_lookup(id); - - if (num == -1) { - ERROR("Failed looking up DHCP client %s option %s, skipping.", ifname, name); - return NULL; - } - - if (val || hex) { - switch (num) { - case 81: /* fqdn */ - if (!val) - return NULL; - return fqdn(val, option, len); - case 12: /* hostname */ - if (val && !strcmp(val, "auto")) - return hostname(cfg, option, len); - /* fallthrough */ - default: - if (hex) { - snprintf(option, len, "-x %d:", num); - strlcat(option, hex, len); - strlcat(option, " ", len); - } else { - /* string value */ - snprintf(option, len, "-x %d:'\"%s\"' ", num, val); - } - break; - } - } else { - struct { int num; char *(*cb)(const char *, char *, size_t); } opt[] = { - { 50, ip_cache }, /* address */ - { 81, NULL }, /* fqdn */ - }; - - for (size_t i = 0; i < NELEMS(opt); i++) { - if (num != opt[i].num) - continue; - - if (!opt[i].cb || !opt[i].cb(ifname, option, len)) - return NULL; - - return option; - } - - snprintf(option, len, "-O %d ", num); - } - - return option; -} - -static char *compose_options(struct lyd_node *cfg, const char *ifname, char **options, - struct lyd_node *id, const char *val, const char *hex) -{ - char opt[300]; - - if (!compose_option(cfg, ifname, id, val, hex, opt, sizeof(opt))) - return *options; - - if (*options) { - char *opts; - - opts = realloc(*options, strlen(*options) + strlen(opt) + 1); - if (!opts) { - ERROR("failed reallocating options: %s", strerror(errno)); - free(*options); - return NULL; - } - - *options = strcat(opts, opt); - } else - *options = strdup(opt); - - return *options; -} static char *fallback_options(const char *ifname) { @@ -206,7 +74,7 @@ static char *dhcp_options(const char *ifname, struct lyd_node *cfg) const char *val = lydx_get_cattr(option, "value"); const char *hex = lydx_get_cattr(option, "hex"); - options = compose_options(cfg, ifname, &options, id, val, hex); + options = dhcp_compose_options(cfg, ifname, &options, id, val, hex, ip_cache); } return options ?: fallback_options(ifname); @@ -244,7 +112,7 @@ static void add(const char *ifname, struct lyd_node *cfg) options = dhcp_options(ifname, cfg); - os_name_version(vendor, sizeof(vendor)); + dhcp_os_name_version(vendor, sizeof(vendor)); fp = fopenf("w", "/etc/finit.d/available/dhcp-client-%s.conf", ifname); if (!fp) { @@ -331,89 +199,3 @@ int infix_dhcp_client_change(sr_session_ctx_t *session, struct lyd_node *config, return err; } - -/* - * Default DHCP options for udhcpc, from networking/udhcp/common.c OPTION_REQ - */ -static void infer_options(sr_session_ctx_t *session, const char *xpath) -{ - const char *opt[] = { - "netmask", - "broadcast", - "router", - "domain", - "hostname", /* server may use this to register our current name */ - "dns-server", - "ntp-server" /* will not be activated unless ietf-system also is */ - }; - size_t i; - - for (i = 0; i < NELEMS(opt); i++) - srx_set_item(session, NULL, 0, "%s/option[id='%s']", xpath, opt[i]); -} - -static int cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module, - const char *xpath, sr_event_t event, unsigned request_id, void *priv) -{ - sr_change_iter_t *iter; - sr_change_oper_t op; - sr_val_t *old, *new; - sr_error_t err; - - switch (event) { - case SR_EV_UPDATE: - case SR_EV_CHANGE: - break; - default: - return SR_ERR_OK; - } - - err = sr_dup_changes_iter(session, XPATH "//*", &iter); - if (err) - return err; - - while (sr_get_change_next(session, iter, &op, &old, &new) == SR_ERR_OK) { - char *xpath, *ptr; - size_t cnt = 0; - - switch (op) { - case SR_OP_CREATED: - case SR_OP_MODIFIED: - break; - default: - continue; - } - - xpath = strdupa(new->xpath); - if (!xpath) { - ERRNO("Failed strdupa()"); - return SR_ERR_SYS; - } - - if ((ptr = strstr(xpath, "]/")) == NULL) - continue; - ptr[1] = 0; - - err = srx_nitems(session, &cnt, "%s/option", xpath); - if (err || cnt) { - continue; - } - - infer_options(session, xpath); - } - - sr_free_change_iter(iter); - return SR_ERR_OK; -} - -int infix_dhcp_client_candidate_init(struct confd *confd) -{ - int rc; - - REGISTER_CHANGE(confd->cand, MODULE, XPATH"//.", SR_SUBSCR_UPDATE, cand, confd, &confd->sub); - - return SR_ERR_OK; -fail: - ERROR("init failed: %s", sr_strerror(rc)); - return rc; -} diff --git a/src/confd/src/infix-dhcp-common.c b/src/confd/src/infix-dhcp-common.c index 841ff3e4..6782094b 100644 --- a/src/confd/src/infix-dhcp-common.c +++ b/src/confd/src/infix-dhcp-common.c @@ -1,6 +1,18 @@ /* SPDX-License-Identifier: BSD-3-Clause */ +#include +#include +#include +#include +#include + +#include +#include +#include + #include +#include "core.h" +#include "infix-dhcp-common.h" int dhcp_option_lookup(const struct lyd_node *id) { @@ -43,8 +55,238 @@ int dhcp_option_lookup(const struct lyd_node *id) val = strtol(name, &endptr, 10); if (*endptr == 0 && val > 0 && val < 255) return (int)val; + } else if (type->basetype == LY_TYPE_UINT16) { + char *endptr; + long val; + + val = strtol(name, &endptr, 10); + if (*endptr == 0 && val > 0 && val < 65536) + return (int)val; } } return -1; } + +char *dhcp_hostname(struct lyd_node *cfg, char *str, size_t len) +{ + struct lyd_node *node; + const char *hostname; + char *ptr; + + node = lydx_get_xpathf(cfg, "/ietf-system:system/hostname"); + if (!node) + return NULL; + + hostname = lyd_get_value(node); + if (!hostname || hostname[0] == 0) + return NULL; + + ptr = strchr(hostname, '.'); + if (ptr) + *ptr = 0; + + snprintf(str, len, "-x hostname:%s ", hostname); + + return str; +} + +char *dhcp_fqdn(const char *val, char *str, size_t len) +{ + snprintf(str, len, "-F \"%s\" ", val); + return str; +} + +char *dhcp_os_name_version(char *str, size_t len) +{ + char *val; + + if (!str || !len) + return NULL; + + str[0] = 0; + + val = fgetkey("/etc/os-release", "NAME"); + if (val) + snprintf(str, len, "-V \"%.32s ", val); + + val = fgetkey("/etc/os-release", "VERSION"); + if (val) { + strlcat(str, val, len); + strlcat(str, "\"", len); + } + + if (strlen(str) > 0 && str[strlen(str) - 1] != '"') { + str[0] = 0; + return NULL; + } + + return str; +} + +char *dhcp_compose_option(struct lyd_node *cfg, const char *ifname, struct lyd_node *id, + const char *val, const char *hex, char *option, size_t len, + char *(*ip_cache_cb)(const char *, char *, size_t)) +{ + const char *name = lyd_get_value(id); + int num = dhcp_option_lookup(id); + + if (num == -1) { + ERROR("Failed looking up DHCP client %s option %s, skipping.", ifname, name); + return NULL; + } + + if (val || hex) { + switch (num) { + case 81: /* fqdn */ + if (!val) + return NULL; + return dhcp_fqdn(val, option, len); + case 12: /* hostname */ + if (val && !strcmp(val, "auto")) + return dhcp_hostname(cfg, option, len); + /* fallthrough */ + default: + if (hex) { + snprintf(option, len, "-x %d:", num); + strlcat(option, hex, len); + strlcat(option, " ", len); + } else { + /* string value */ + snprintf(option, len, "-x %d:'\"%s\"' ", num, val); + } + break; + } + } else { + struct { int num; char *(*cb)(const char *, char *, size_t); } opt[] = { + { 50, ip_cache_cb }, /* address */ + { 81, NULL }, /* fqdn */ + }; + + for (size_t i = 0; i < NELEMS(opt); i++) { + if (num != opt[i].num) + continue; + + if (!opt[i].cb || !opt[i].cb(ifname, option, len)) + return NULL; + + return option; + } + + snprintf(option, len, "-O %d ", num); + } + + return option; +} + +char *dhcp_compose_options(struct lyd_node *cfg, const char *ifname, char **options, + struct lyd_node *id, const char *val, const char *hex, + char *(*ip_cache_cb)(const char *, char *, size_t)) +{ + char opt[300]; + + if (!dhcp_compose_option(cfg, ifname, id, val, hex, opt, sizeof(opt), ip_cache_cb)) + return *options; + + if (*options) { + char *opts; + + opts = realloc(*options, strlen(*options) + strlen(opt) + 1); + if (!opts) { + ERROR("failed reallocating options: %s", strerror(errno)); + free(*options); + return NULL; + } + + *options = strcat(opts, opt); + } else + *options = strdup(opt); + + return *options; +} + +/* + * Default DHCP options for udhcpc, from networking/udhcp/common.c OPTION_REQ + */ +static void infer_options_v4(sr_session_ctx_t *session, const char *xpath) +{ + const char *opt[] = { + "netmask", + "broadcast", + "router", + "domain", + "hostname", /* server may use this to register our current name */ + "dns-server", + "ntp-server" /* will not be activated unless ietf-system also is */ + }; + size_t i; + + for (i = 0; i < NELEMS(opt); i++) + srx_set_item(session, NULL, 0, "%s/option[id='%s']", xpath, opt[i]); +} + +/* + * Default DHCPv6 options + * Note: udhcpc6 only supports dns-server (dns) and domain-search (search) with string names. + * Other options use numeric codes, which dhcp_compose_option() handles automatically. + */ +static void infer_options_v6(sr_session_ctx_t *session, const char *xpath) +{ + const char *opt[] = { + "dns-server", /* option 23 - udhcpc6: -O dns */ + "domain-search", /* option 24 - udhcpc6: -O search */ + "client-fqdn", /* option 39 - udhcpc6: -O 39 (equivalent to DHCPv4 hostname) */ + "ntp-server" /* option 56 - udhcpc6: -O 56 */ + }; + size_t i; + + for (i = 0; i < NELEMS(opt); i++) + srx_set_item(session, NULL, 0, "%s/option[id='%s']", xpath, opt[i]); +} + +/* + * Called from ietf-interfaces.c ifchange_cand() to infer DHCP options + * for both DHCPv4 and DHCPv6 client configurations + */ +int ifchange_cand_infer_dhcp(sr_session_ctx_t *session, const char *xpath) +{ + sr_error_t err = SR_ERR_OK; + char *path, *ptr; + size_t cnt = 0; + + /* Extract path up to and including the dhcp container */ + path = strdup(xpath); + if (!path) + return SR_ERR_SYS; + + /* Find the dhcp container in the path */ + ptr = strstr(path, ":dhcp"); + if (!ptr) { + free(path); + return SR_ERR_OK; + } + + /* Move past ":dhcp" to find end of container name */ + ptr += 5; /* strlen(":dhcp") */ + + /* If there's more after dhcp (like /arping), truncate it */ + if (*ptr == '/') + *ptr = '\0'; + + /* Check if options already exist */ + err = srx_nitems(session, &cnt, "%s/option", path); + if (err || cnt) { + ERROR("%s(): no %s/options err %d cnt %zu", __func__, path, err, cnt); + goto out; + } + + /* Infer options based on IPv4 or IPv6 */ + if (strstr(path, ":ipv4/")) + infer_options_v4(session, path); + else if (strstr(path, ":ipv6/")) + infer_options_v6(session, path); + +out: + free(path); + return err; +} diff --git a/src/confd/src/infix-dhcp-common.h b/src/confd/src/infix-dhcp-common.h new file mode 100644 index 00000000..5eb36093 --- /dev/null +++ b/src/confd/src/infix-dhcp-common.h @@ -0,0 +1,20 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#ifndef INFIX_DHCP_COMMON_H_ +#define INFIX_DHCP_COMMON_H_ + +#include + +/* DHCP option lookup and composition helpers */ +int dhcp_option_lookup(const struct lyd_node *id); +char *dhcp_hostname(struct lyd_node *cfg, char *str, size_t len); +char *dhcp_fqdn(const char *val, char *str, size_t len); +char *dhcp_os_name_version(char *str, size_t len); +char *dhcp_compose_option(struct lyd_node *cfg, const char *ifname, struct lyd_node *id, + const char *val, const char *hex, char *option, size_t len, + char *(*ip_cache_cb)(const char *, char *, size_t)); +char *dhcp_compose_options(struct lyd_node *cfg, const char *ifname, char **options, + struct lyd_node *id, const char *val, const char *hex, + char *(*ip_cache_cb)(const char *, char *, size_t)); + +#endif /* INFIX_DHCP_COMMON_H_ */ diff --git a/src/confd/src/infix-dhcp-server.c b/src/confd/src/infix-dhcp-server.c index ffff1840..0e6bf373 100644 --- a/src/confd/src/infix-dhcp-server.c +++ b/src/confd/src/infix-dhcp-server.c @@ -2,13 +2,14 @@ #include #include +#include #include #include #include #include "core.h" -#include +#include "infix-dhcp-common.h" #define MODULE "infix-dhcp-server" #define ROOT_XPATH "/infix-dhcp-server:" diff --git a/src/confd/src/infix-dhcpv6-client.c b/src/confd/src/infix-dhcpv6-client.c new file mode 100644 index 00000000..a5c8807d --- /dev/null +++ b/src/confd/src/infix-dhcpv6-client.c @@ -0,0 +1,167 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "core.h" +#include "infix-dhcp-common.h" + +#define MODULE "infix-dhcpv6-client" +#define XPATH "/ietf-interfaces:interfaces/interface/ietf-ip:ipv6/infix-dhcpv6-client:dhcp" + +static char *fallback_options_v6(const char *ifname) +{ + /* dns-server, domain-search, client-fqdn, ntp-server */ + const char *defaults = "-O 23 -O 24 -O 39 -O 56 "; + + return strdup(defaults); +} + +static char *dhcp_options_v6(const char *ifname, struct lyd_node *cfg, bool *request_pd) +{ + struct lyd_node *option; + char *options = NULL; + + *request_pd = false; + + LYX_LIST_FOR_EACH(lyd_child(cfg), option, "option") { + struct lyd_node *id = lydx_get_child(option, "id"); + const char *val = lydx_get_cattr(option, "value"); + const char *hex = lydx_get_cattr(option, "hex"); + const char *name = lyd_get_value(id); + int num = dhcp_option_lookup(id); + + /* ia-pd option requires -d flag, not -O 25 */ + if (num == 25 || (name && !strcmp(name, "ia-pd"))) { + *request_pd = true; + continue; /* Don't add to options string */ + } + + options = dhcp_compose_options(cfg, ifname, &options, id, val, hex, NULL); + } + + return options ?: fallback_options_v6(ifname); +} + +static void add_v6(const char *ifname, struct lyd_node *cfg) +{ + const char *metric = lydx_get_cattr(cfg, "route-preference"); + const char *duid = lydx_get_cattr(cfg, "duid"); + char *client_duid = NULL, *options = NULL; + const char *action = "disable"; + char info_only[4] = { 0 }; + char prefix_del[4] = { 0 }; + bool request_pd = false; + FILE *fp; + + if (lydx_is_enabled(cfg, "information-only")) + strlcpy(info_only, "-l ", sizeof(info_only)); + + if (duid && duid[0]) { + size_t len = strlen(duid) + 32; + + client_duid = malloc(len); + if (!client_duid) + goto generr; + + snprintf(client_duid, len, "-x 1:%s ", duid); + } + + options = dhcp_options_v6(ifname, cfg, &request_pd); + if (request_pd) + strlcpy(prefix_del, "-d ", sizeof(prefix_del)); + + fp = fopenf("w", "/etc/finit.d/available/dhcpv6-client-%s.conf", ifname); + if (!fp) { + generr: + ERRNO("failed creating DHCPv6 client %s: %s", ifname, strerror(errno)); + goto err; + } + + fprintf(fp, "# Generated by Infix confd\n"); + fprintf(fp, "metric=%s\n", metric); + fprintf(fp, "service name:dhcpv6-client :%s \\\n" + " [2345] udhcpc6 -f -p /run/dhcpv6-client-%s.pid -t 3 -T 5 -A 30 -S -R \\\n" + " %s%s%s%s \\\n" + " -i %s %s \\\n" + " -- DHCPv6 client @%s\n", + ifname, ifname, ifname, + info_only, prefix_del, + options ? "-o " : "", options ?: "", + ifname, client_duid ?: "", ifname); + fclose(fp); + action = "enable"; +err: + systemf("initctl -bfqn %s dhcpv6-client-%s", action, ifname); + if (options) + free(options); + if (client_duid) + free(client_duid); +} + +static void del_v6(const char *ifname) +{ + systemf("initctl -bfq delete dhcpv6-client-%s", ifname); +} + +int infix_dhcpv6_client_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, + sr_event_t event, struct confd *confd) +{ + struct lyd_node *ifaces, *difaces, *iface, *diface, *ipv6, *dhcp, *ddhcp; + sr_error_t err = 0; + + if (event != SR_EV_DONE) + return SR_ERR_OK; + + ifaces = lydx_get_descendant(config, "interfaces", "interface", NULL); + difaces = lydx_get_descendant(diff, "interfaces", "interface", NULL); + + /* find the modified interfaces */ + LYX_LIST_FOR_EACH(difaces, diface, "interface") { + const char *ifname = lydx_get_cattr(diface, "name"); + struct lyd_node *dipv6; + + dipv6 = lydx_get_descendant(lyd_child(diface), "ipv6", NULL); + if (!dipv6) + continue; + + ddhcp = lydx_get_descendant(lyd_child(dipv6), "dhcp", NULL); + if (!ddhcp) + continue; + + /* Check if dhcp container was deleted */ + if (lydx_get_op(ddhcp) == LYDX_OP_DELETE) { + del_v6(ifname); + continue; + } + + /* Find corresponding interface in config to check if dhcp is present */ + LYX_LIST_FOR_EACH(ifaces, iface, "interface") { + if (strcmp(ifname, lydx_get_cattr(iface, "name"))) + continue; + + ipv6 = lydx_get_descendant(lyd_child(iface), "ipv6", NULL); + if (!ipv6) { + del_v6(ifname); + break; + } + + dhcp = lydx_get_descendant(lyd_child(ipv6), "dhcp", NULL); + if (!dhcp) + del_v6(ifname); + else + add_v6(ifname, dhcp); + break; + } + } + + return err; +} diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index ba92826e..09afa000 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -29,8 +29,9 @@ MODULES=( "infix-routing@2025-11-12.yang" "ieee802-dot1ab-lldp@2022-03-15.yang" "infix-lldp@2025-05-05.yang" - "infix-dhcp-common@2025-01-29.yang" - "infix-dhcp-client@2025-11-02.yang" + "infix-dhcp-common@2025-11-09.yang" + "infix-dhcp-client@2025-11-09.yang" + "infix-dhcpv6-client@2025-11-09.yang" "infix-dhcp-server@2025-10-28.yang" "infix-firewall@2025-04-26.yang" "infix-firewall-services@2025-04-26.yang" diff --git a/src/confd/yang/confd/infix-dhcp-client.yang b/src/confd/yang/confd/infix-dhcp-client.yang index 9abe21fa..1279d5e6 100644 --- a/src/confd/yang/confd/infix-dhcp-client.yang +++ b/src/confd/yang/confd/infix-dhcp-client.yang @@ -1,6 +1,6 @@ module infix-dhcp-client { yang-version 1.1; - namespace "urn:ietf:params:xml:ns:yang:dhcp-client"; + namespace "urn:infix:params:xml:ns:yang:dhcp-client"; prefix dhcp-client; import ietf-interfaces { @@ -16,6 +16,10 @@ module infix-dhcp-client { contact "kernelkit@googlegroups.com"; description "This module implements a DHCPv4 client"; + revision 2025-11-09 { + description "Fix namespace to use infix instead of ietf."; + reference "internal"; + } revision 2025-11-02 { description "Migrate DHCP client to ietf-ip augment. - Relocate from /dhcp-client to /interfaces/interface/ipv4/dhcp diff --git a/src/confd/yang/confd/infix-dhcp-client@2025-11-02.yang b/src/confd/yang/confd/infix-dhcp-client@2025-11-09.yang similarity index 100% rename from src/confd/yang/confd/infix-dhcp-client@2025-11-02.yang rename to src/confd/yang/confd/infix-dhcp-client@2025-11-09.yang diff --git a/src/confd/yang/confd/infix-dhcp-common.yang b/src/confd/yang/confd/infix-dhcp-common.yang index 839aac30..8a880e91 100644 --- a/src/confd/yang/confd/infix-dhcp-common.yang +++ b/src/confd/yang/confd/infix-dhcp-common.yang @@ -7,6 +7,10 @@ module infix-dhcp-common { contact "kernelkit@googlegroups.com"; description "Shared types between DHCP server and client."; + revision 2025-11-09 { + description "Add DHCPv6 options typedef."; + reference "internal"; + } revision 2025-01-29 { description "Initial revision."; reference "internal"; @@ -113,4 +117,73 @@ module infix-dhcp-common { pattern '([0-9a-fA-F]{2}:)*[0-9a-fA-F]{2}'; } } + + typedef duid { + description "DHCPv6 Unique Identifier (DUID), RFC 8415 section 11. + + A DUID consists of a two-octet type code and a variable length + identifier field. The minimum length is 4 octets (type + data). + + Common DUID types: + - DUID-LLT (type 1): Link-layer address plus time + - DUID-EN (type 2): Enterprise number + - DUID-LL (type 3): Link-layer address + - DUID-UUID (type 4): UUID + + Format: Colon-separated hex octets, minimum 4 octets. + Example: 00:03:00:01:08:00:27:fe:8c:2a (DUID-LL with Ethernet MAC)"; + type string { + pattern '([0-9a-fA-F]{2}:){3,}[0-9a-fA-F]{2}'; + /* Minimum 4 octets: ([0-9a-fA-F]{2}:){3,} matches at least 3 colons, + * plus final [0-9a-fA-F]{2} makes 4 octets total */ + } + } + + typedef options-v6 { + description "DHCPv6 options for client."; + type union { + type uint16 { + range "1..65535"; + } + type enumeration { + enum client-id { + value 1; + description "Client Identifier (DUID), RFC 8415. + Automatically generated by default."; + } + enum server-id { + value 2; + description "Server Identifier (DUID), RFC 8415."; + } + enum dns-server { + value 23; + description "DNS recursive name servers, RFC 3646."; + } + enum domain-search { + value 24; + description "Domain search list, RFC 3646."; + } + enum ia-pd { + value 25; + description "Identity Association for Prefix Delegation, RFC 8415."; + } + enum ntp-server { + value 56; + description "NTP time servers, RFC 5908."; + } + enum client-fqdn { + value 39; + description "Client Fully Qualified Domain Name, RFC 4704."; + } + enum information-refresh-time { + value 32; + description "Information refresh time for stateless DHCPv6, RFC 8415."; + } + enum sntp-server { + value 31; + description "Simple Network Time Protocol servers, RFC 4075."; + } + } + } + } } diff --git a/src/confd/yang/confd/infix-dhcp-common@2025-01-29.yang b/src/confd/yang/confd/infix-dhcp-common@2025-11-09.yang similarity index 100% rename from src/confd/yang/confd/infix-dhcp-common@2025-01-29.yang rename to src/confd/yang/confd/infix-dhcp-common@2025-11-09.yang diff --git a/src/confd/yang/confd/infix-dhcpv6-client.yang b/src/confd/yang/confd/infix-dhcpv6-client.yang new file mode 100644 index 00000000..690ecafb --- /dev/null +++ b/src/confd/yang/confd/infix-dhcpv6-client.yang @@ -0,0 +1,140 @@ +module infix-dhcpv6-client { + yang-version 1.1; + namespace "urn:infix:params:xml:ns:yang:dhcpv6-client"; + prefix dhcpv6-client; + + import ietf-interfaces { + prefix if; + } + import ietf-ip { + prefix ip; + } + import infix-dhcp-common { + prefix dhcp; + } + + organization "KernelKit"; + contact "kernelkit@googlegroups.com"; + description "This module implements a DHCPv6 client"; + + revision 2025-11-09 { + description "Initial revision: + - Augment for /interfaces/interface/ipv6/dhcp + - Support for DHCPv6 options, DUID, prefix delegation"; + reference "rfc8415"; + } + + /* + * Typedefs + */ + + typedef route-preference { + type uint32; + description "This type is used for selecting route preference (distance)."; + } + + /* + * DHCPv6 Client Configuration + */ + + augment "/if:interfaces/if:interface/ip:ipv6" { + container dhcp { + presence "Enable DHCPv6 client for this interface."; + description "DHCPv6 client configuration"; + + leaf duid { + type dhcp:duid; + description "Optional DHCPv6 Unique Identifier (DUID), option 1, RFC 8415. + + When omitted, the client automatically generates a DUID + based on link-layer address plus time (DUID-LLT) or link-layer + address only (DUID-LL), depending on the implementation. + + This setting allows manual configuration of the DUID in + hexadecimal format. The DUID type and content are encoded + as defined in RFC 8415 section 11. + + For full control of the DUID format, use the generic option + list instead, which supports entering raw HEX data. + + Example: 00:03:00:01:08:00:27:fe:8c:2a + (DUID-LL with Ethernet address)"; + } + + leaf information-only { + type boolean; + must "not(. = 'true') or ../../ip:autoconf/ip:create-global-addresses = 'true' " + + "or ../../ip:autoconf/ip:create-temporary-addresses = 'true'" { + error-message "IPv6 autoconf (SLAAC) must be enabled when information-only is true"; + error-app-tag "invalid-dhcpv6-config"; + } + description "Use stateless DHCPv6 (information-request mode). + + When enabled, the client requests configuration information + (like DNS servers) but does not request IPv6 addresses or + prefixes. This is used with SLAAC (Stateless Address + Autoconfiguration) when only additional configuration is + needed from DHCPv6."; + } + + list option { + key "id"; + description "List of DHCPv6 options to request (and accept). + + The default is an empty list, meaning all supported options. To + request specific options, add them here. For prefix delegation, + include the ia-pd option in the list."; + + must "not(id = 'client-fqdn' and hex)" { + error-message "Client FQDN option must use string format"; + error-app-tag "invalid-fqdn-format"; + } + + leaf id { + type dhcp:options-v6; + description "DHCPv6 option to request from, or inform server of."; + } + + choice value { + case value { + leaf value { + description "Optional string value to send to server. + + Example: + option:client-fqdn, value:host.example.com + + This will send option 39 with the specified FQDN, + requesting the server to update DNS records."; + type string; + } + } + case hex { + leaf hex { + description "Optional binary value in hexadecimal format. + + This allows full control over the option payload, + useful for vendor-specific options or custom data. + + Example: + option:client-id, hex:00:03:00:01:08:00:27:fe:8c:2a + + This would set a DUID-LL (type 3) with the specified + link-layer address."; + type dhcp:octet-string; + } + } + } + } + + leaf route-preference { + type route-preference; + default 5; + description + "The preference (administrative distance) that all DHCPv6 routes + are installed with. The default preference (5) is higher (less worth) + than static routes, but lower than those learned via dynamic routing + protocols, like OSPF."; + } + } + } +} diff --git a/src/confd/yang/confd/infix-dhcpv6-client@2025-11-09.yang b/src/confd/yang/confd/infix-dhcpv6-client@2025-11-09.yang new file mode 120000 index 00000000..12d2c890 --- /dev/null +++ b/src/confd/yang/confd/infix-dhcpv6-client@2025-11-09.yang @@ -0,0 +1 @@ +infix-dhcpv6-client.yang \ No newline at end of file From 9b83b827547d019226fac3608d01ba74bd55bd35 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 10 Nov 2025 16:30:10 +0100 Subject: [PATCH 2/8] test: add dnsmasq and dhcpd to infamy container Also, update docker/README, because after last confusing update I couldn't follow my own instructions. Signed-off-by: Joachim Wiberg --- test/.env | 2 +- test/docker/Dockerfile | 2 ++ test/docker/README.md | 56 +++++++++++++++++++++++++----------------- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/test/.env b/test/.env index b6968427..6ea67b8b 100644 --- a/test/.env +++ b/test/.env @@ -2,7 +2,7 @@ # shellcheck disable=SC2034,SC2154 # Current container image -INFIX_TEST=ghcr.io/kernelkit/infix-test:2.6 +INFIX_TEST=ghcr.io/kernelkit/infix-test:2.7 ixdir=$(readlink -f "$testdir/..") logdir=$(readlink -f "$testdir/.log") diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile index 78aa3a8b..7991c1d6 100644 --- a/test/docker/Dockerfile +++ b/test/docker/Dockerfile @@ -4,6 +4,8 @@ FROM alpine:3.18.0 RUN apk add --no-cache \ busybox-extras \ curl \ + dhcp-server-vanilla \ + dnsmasq \ e2fsprogs \ e2tools \ ethtool \ diff --git a/test/docker/README.md b/test/docker/README.md index c28b084c..fd04aafb 100644 --- a/test/docker/README.md +++ b/test/docker/README.md @@ -4,38 +4,48 @@ Checklist for Updating Infamy Docker Image This directory holds the Dockerfile and any extras needed to build and update the infix-test container image used for the test system. +> [!IMPORTANT] +> Remember the final `.` in the `docker build` command, otherwise the +> build directory will be test/docker/ and you will get COPY errors! + +Build Checklist +--------------- + The following is a checklist/reminder to maintainers for how to update -the image, e.g., with missing Alpine packages. +the image, e.g., with missing Alpine packages: - 1. Update the Dockerfile - - cd test/docker/ - edit Dockerfile - - 2. Build the new image version, for latest version, see released images - here: - in this example we use version 0.4: +1. Update the Dockerfile cd test/ - docker build -f docker/Dockerfile -t ghcr.io/kernelkit/infix-test:0.4 . + edit docker/Dockerfile - 3. Update the `test/.env` file to use the new version - 4. Verify your new image works properly (remember to remove your `~/.infix/venv`) - 5. Send PR to co-maintainer for review +2. Build the new image version, for latest version, see released images + here: + in this example we use version 2.7: + + docker build -f docker/Dockerfile -t ghcr.io/kernelkit/infix-test:2.7 . + +3. Update the `test/.env` file to use the new version +4. Verify your new image works properly (remember to remove your `~/.infix/venv`) +5. Send PR to co-maintainer for review + +Upload New Image +---------------- The co-maintainer should then verify themselves before approving the PR. A crucial step to remember is to: - 1. Push the new image version to . For details on how - to do this, see this excellent guide to the [GitHub Container - Registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry): - 2. Shorthand after setup (above): +1. Push the new image version to . For details on how + to do this, see this excellent guide to the [GitHub Container + Registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry): +2. Shorthand after setup (above): - echo $CR_PAT | docker login ghcr.io -u troglobit --password-stdin - docker push ghcr.io/kernelkit/infix-test:0.4 + echo $CR_PAT | docker login ghcr.io -u troglobit --password-stdin + docker push ghcr.io/kernelkit/infix-test:2.7 - 3. Merge the PR to the `main` branch. +3. Merge the PR to the `main` branch. -> **Note:** the co-maintainer may delegate the chore of uploading the -> new image to the one who prepared the PR (you), provided of course -> they have the access rights to do so. +> [!NOTE] +> The co-maintainer may delegate the chore of uploading the new image to +> the one who prepared the PR (you), provided of course they have the +> access rights to do so. From 9226a4ac0d63cc283b6fe6809ce265e0cf7ab21c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 11 Nov 2025 13:37:18 +0100 Subject: [PATCH 3/8] test/infamy: minor, whitespace + coding style Signed-off-by: Joachim Wiberg --- test/infamy/iface.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/test/infamy/iface.py b/test/infamy/iface.py index 6bfdb555..af6d6f98 100644 --- a/test/infamy/iface.py +++ b/test/infamy/iface.py @@ -2,13 +2,15 @@ Fetch interface status from remote device. """ + def get_xpath(iface, path=None): """Compose complete XPath to a YANG node in /ietf-interfaces""" - xpath=f"/ietf-interfaces:interfaces/interface[name='{iface}']" - if not path is None: - xpath=f"{xpath}/{path}" + xpath = f"/ietf-interfaces:interfaces/interface[name='{iface}']" + if path is not None: + xpath = f"{xpath}/{path}" return xpath + def _extract_param(json_content, param): """Returns (extracted) value for parameter 'param'""" interfaces = json_content.get('interfaces') @@ -22,6 +24,7 @@ def _extract_param(json_content, param): return None + def get_param(target, iface, param=None): """Fetch target dict for iface and extract param from JSON""" content = target.get_data(get_xpath(iface, param)) @@ -29,6 +32,7 @@ def get_param(target, iface, param=None): return None return _extract_param(content, param) + def exist(target, iface): """Verify that the target interface exists""" return get_param(target, iface, "name") is not None @@ -60,11 +64,13 @@ def get_phys_address(target, iface): """Fetch interface MAC address (operational status)""" return get_param(target, iface, "phys-address") + def exist_bridge_multicast_filter(target, group, iface, bridge): - # The interface array is different in restconf/netconf, netconf has a keyed list but - # restconf has a numbered list, i think i read that this was a bug in rousette, but - # have not found it. - interface=target.get_iface(bridge) + """Check if a bridge has a multicast filter for group with iface""" + # The interface array is different in restconf/netconf, netconf has + # a keyed list but restconf has a numbered list, i think i read that + # this was a bug in rousette, but have not found it. + interface = target.get_iface(bridge) if interface is None: raise "Interface not found" @@ -72,9 +78,9 @@ def exist_bridge_multicast_filter(target, group, iface, bridge): if brif is None: return False - for filter in brif.get("multicast-filters", {}).get("multicast-filter", {}): - if filter.get("group") == group: - for p in filter.get("ports"): + for f in brif.get("multicast-filters", {}).get("multicast-filter", {}): + if f.get("group") == group: + for p in f.get("ports"): if p["port"] == iface: return True From 987dd40e1fcf289bec4eefbbd3d2ea032367b45b Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 11 Nov 2025 13:37:40 +0100 Subject: [PATCH 4/8] test/infamy: support ipv6 addresses in iface.address_exist() Signed-off-by: Joachim Wiberg --- test/infamy/iface.py | 61 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/test/infamy/iface.py b/test/infamy/iface.py index af6d6f98..4b872ad3 100644 --- a/test/infamy/iface.py +++ b/test/infamy/iface.py @@ -37,28 +37,61 @@ def exist(target, iface): """Verify that the target interface exists""" return get_param(target, iface, "name") is not None -def address_exist(target, iface, address, prefix_length = 24, proto="dhcp"): + +def address_exist(target, iface, address, prefix_length=None, proto="dhcp"): """Check if 'address' is set on iface""" + if not prefix_length: + if ':' in address: + prefix_length = 64 + else: + prefix_length = 24 + addrs = get_ipv4_address(target, iface) - if not addrs: - return False - for addr in addrs: - if addr['origin'] == proto and addr['ip'] == address and addr['prefix-length'] == prefix_length: - return True + if addrs: + for addr in addrs: + if addr['origin'] == proto and addr['ip'] == address and\ + addr['prefix-length'] == prefix_length: + return True + + addrs = get_ipv6_address(target, iface) + if addrs: + for addr in addrs: + if addr['origin'] == proto and addr['ip'] == address and\ + addr['prefix-length'] == prefix_length: + return True + + return False + def get_ipv4_address(target, iface): - """Fetch interface IPv4 addresses from (operational status)""" - # The interface array is different in restconf/netconf, netconf has a keyed list but - # restconf has a numbered list, i think i read that this was a bug in rousette, but - # have not found it. - interface=target.get_iface(iface) + """Fetch interface IPv4 addresses from operational""" + # The interface array is different in restconf/netconf, netconf has + # a keyed list but restconf has a numbered list, i think i read that + # this was a bug in rousette, but have not found it. + interface = target.get_iface(iface) if interface is None: raise "Interface not found" - ipv4 = interface.get("ipv4") or interface.get("ietf-ip:ipv4") - if ipv4 is None or 'address' not in ipv4: + ip = interface.get("ipv4") or interface.get("ietf-ip:ipv4") + if ip is None or 'address' not in ip: return None - return ipv4['address'] + return ip['address'] + + +def get_ipv6_address(target, iface): + """Fetch interface IPv6 addresses from operational""" + # The interface array is different in restconf/netconf, netconf has + # a keyed list but restconf has a numbered list, i think i read that + # this was a bug in rousette, but have not found it. + interface = target.get_iface(iface) + if interface is None: + raise "Interface not found" + + ip = interface.get("ipv6") or interface.get("ietf-ip:ipv6") + if ip is None or 'address' not in ip: + return None + return ip['address'] + def get_phys_address(target, iface): """Fetch interface MAC address (operational status)""" From c91fc1f38b77702c66a73518ad352b296bd8ba7f Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 9 Nov 2025 16:20:48 +0100 Subject: [PATCH 5/8] test: add dhcpv6 client tests - Basic DHCPv6 client, request a lease and verify Linux installs a default route from the Router Advertisements (option not included in DHCPv6), hence the 'enable-ra' option to dnsmasq. Also verify DNS resolution over IPv6, including the resolvconf dance - Prefix delegation, verifies that the client can actually request and receives a prefix from a DHCPv6 server. We don't do anything with it today though Two new DHCP server implementations, the basic test relies on dnsmasq and the prefix delegation test requires an ISC based dhcp server with server side support for ipv6 prefix delegation. We could reuse the latter for both tests, but this way we get compatibility testing. Signed-off-by: Joachim Wiberg --- .../case/infix_dhcp/client6_basic/Readme.adoc | 1 + test/case/infix_dhcp/client6_basic/test.py | 66 +++++ .../infix_dhcp/client6_basic/topology.dot | 23 ++ .../client6_prefix_delegation/Readme.adoc | 1 + .../client6_prefix_delegation/test.py | 82 ++++++ .../client6_prefix_delegation/topology.dot | 23 ++ test/case/infix_dhcp/dhcp_client.yaml | 6 + test/infamy/dhcp.py | 276 ++++++++++++++++++ 8 files changed, 478 insertions(+) create mode 120000 test/case/infix_dhcp/client6_basic/Readme.adoc create mode 100755 test/case/infix_dhcp/client6_basic/test.py create mode 100644 test/case/infix_dhcp/client6_basic/topology.dot create mode 120000 test/case/infix_dhcp/client6_prefix_delegation/Readme.adoc create mode 100755 test/case/infix_dhcp/client6_prefix_delegation/test.py create mode 100644 test/case/infix_dhcp/client6_prefix_delegation/topology.dot diff --git a/test/case/infix_dhcp/client6_basic/Readme.adoc b/test/case/infix_dhcp/client6_basic/Readme.adoc new file mode 120000 index 00000000..ae32c841 --- /dev/null +++ b/test/case/infix_dhcp/client6_basic/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/infix_dhcp/client6_basic/test.py b/test/case/infix_dhcp/client6_basic/test.py new file mode 100755 index 00000000..ab28f9da --- /dev/null +++ b/test/case/infix_dhcp/client6_basic/test.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""DHCPv6 Basic + +Enable a DHCPv6 client and verify it requests an IPv6 lease from a +DHCPv6 server that is then set on the interface. + +""" + +import infamy +import infamy.dhcp +import infamy.iface as iface +import infamy.route as route +from infamy.util import until + +with infamy.Test() as test: + SERVER = '2001:db8::1' + CLIENT = '2001:db8::42' + DOMAIN = 'example.com' + VERIFY = 'server.' + DOMAIN + + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + client = env.attach("client", "mgmt") + tgtssh = env.attach("client", "mgmt", "ssh") + _, host = env.ltop.xlate("host", "data") + _, port = env.ltop.xlate("client", "data") + + with infamy.IsolatedMacVlan(host) as netns: + netns.addip(SERVER, prefix_length=64, proto="ipv6") + with infamy.dhcp.Server6Dnsmasq(netns, + start=CLIENT, + end=CLIENT, + dns=SERVER, + domain=DOMAIN, + address=SERVER): + + with test.step("Configure DHCPv6 client"): + config = { + "interfaces": { + "interface": [{ + "name": f"{port}", + "ipv6": { + "enabled": True, + "infix-dhcpv6-client:dhcp": { + "option": [ + {"id": "dns-server"}, + {"id": "domain-search"} + ] + } + } + }] + } + } + client.put_config_dict("ietf-interfaces", config) + + with test.step(f"Verify client lease for {CLIENT}"): + until(lambda: iface.address_exist(client, port, CLIENT, prefix_length=128)) + + with test.step("Verify client default route ::/0"): + until(lambda: route.ipv6_route_exist(client, "::/0"), attempts=20) + + with test.step("Verify client domain name resolution"): + rc = tgtssh.runsh(f"ping -6 -c1 -w20 {VERIFY}") + assert rc.returncode == 0, f"Client failed: ping {VERIFY}" + + test.succeed() diff --git a/test/case/infix_dhcp/client6_basic/topology.dot b/test/case/infix_dhcp/client6_basic/topology.dot new file mode 100644 index 00000000..068dfc61 --- /dev/null +++ b/test/case/infix_dhcp/client6_basic/topology.dot @@ -0,0 +1,23 @@ +graph "1x2" { + 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 | data }", + pos="0,20!", + requires="controller", + ]; + + client [ + label="{ mgmt | data } | client", + pos="200,20!", + requires="infix", + ]; + + host:mgmt -- client:mgmt [requires="mgmt", color=lightgrey] + host:data -- client:data [color=black, taillabel="2001:db8::1/64", headlabel="2001:db8::42/128"] +} diff --git a/test/case/infix_dhcp/client6_prefix_delegation/Readme.adoc b/test/case/infix_dhcp/client6_prefix_delegation/Readme.adoc new file mode 120000 index 00000000..ae32c841 --- /dev/null +++ b/test/case/infix_dhcp/client6_prefix_delegation/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/infix_dhcp/client6_prefix_delegation/test.py b/test/case/infix_dhcp/client6_prefix_delegation/test.py new file mode 100755 index 00000000..11600689 --- /dev/null +++ b/test/case/infix_dhcp/client6_prefix_delegation/test.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""DHCPv6 Prefix Delegation + +Verify DHCPv6 prefix delegation (IA_PD) where a client requests an IPv6 +prefix from a DHCPv6 server. This is commonly used on WAN interfaces of +routers to obtain a prefix for distribution to downstream networks. + +""" + +import infamy, infamy.dhcp +import infamy.iface as iface +from infamy.util import until +import time + + +def checkrun(dut): + """Check DUT is running DHCPv6 client""" + res = dut.runsh(f"pgrep -f 'udhcpc6.*{port}'") + # print(f"Checking for udhcpc6: {res.stdout}") + if res.stdout.strip() != "": + return True + return False + + +def checklog(dut): + """Check syslog for prefix delegation message""" + rc = dut.runsh("tail -10 /log/syslog | grep 'received delegated prefix'") + # print(f"DHCPv6 client logs:\n{res.stdout}") + if rc.stdout.strip() != "": + return True + return False + + +with infamy.Test() as test: + SERVER = '2001:db8::1' + CLIENT = '2001:db8::42' + PREFIX = '2001:db8:1::/48' + + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + client = env.attach("client", "mgmt") + tgtssh = env.attach("client", "mgmt", "ssh") + _, host = env.ltop.xlate("host", "data") + _, port = env.ltop.xlate("client", "data") + + with infamy.IsolatedMacVlan(host) as netns: + netns.addip(SERVER, prefix_length=48, proto="ipv6") + with infamy.dhcp.Server6Dhcpd(netns=netns, + start="2001:db8::100", + end="2001:db8::200", + prefix="2001:db8:100::", + prefix_len=64, + dns="2001:db8::1", + iface="iface", + subnet="2001:db8::/48"): + + with test.step("Configure DHCPv6 client w/ prefix delegation"): + config = { + "interfaces": { + "interface": [{ + "name": f"{port}", + "ipv6": { + "enabled": True, + "infix-dhcpv6-client:dhcp": { + "option": [ + {"id": "dns-server"}, + {"id": "ia-pd"} + ] + } + } + }] + } + } + client.put_config_dict("ietf-interfaces", config) + + with test.step("Verify DHCPv6 client is running"): + until(lambda: checkrun(tgtssh)) + + with test.step("Verify prefix delegation in logs"): + until(lambda: checklog(tgtssh)) + + test.succeed() diff --git a/test/case/infix_dhcp/client6_prefix_delegation/topology.dot b/test/case/infix_dhcp/client6_prefix_delegation/topology.dot new file mode 100644 index 00000000..61a5bce9 --- /dev/null +++ b/test/case/infix_dhcp/client6_prefix_delegation/topology.dot @@ -0,0 +1,23 @@ +graph "1x2" { + 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 | data }", + pos="0,20!", + requires="controller", + ]; + + client [ + label="{ mgmt | data } | client", + pos="200,20!", + requires="infix", + ]; + + host:mgmt -- client:mgmt [requires="mgmt", color=lightgrey] + host:data -- client:data [color=black, taillabel="2001:db8::1/64", headlabel="DHCPv6-PD: 2001:db8:1::/48"] +} diff --git a/test/case/infix_dhcp/dhcp_client.yaml b/test/case/infix_dhcp/dhcp_client.yaml index d2244bad..fc9dd57e 100644 --- a/test/case/infix_dhcp/dhcp_client.yaml +++ b/test/case/infix_dhcp/dhcp_client.yaml @@ -10,3 +10,9 @@ - name: DHCP option 121 vs option 3 case: client_routes/test.py + +- name: DHCPv6 Basic + case: client6_basic/test.py + +- name: DHCPv6 Prefix Delegation + case: client6_prefix_delegation/test.py diff --git a/test/infamy/dhcp.py b/test/infamy/dhcp.py index 7edaa41f..01158b9e 100644 --- a/test/infamy/dhcp.py +++ b/test/infamy/dhcp.py @@ -1,5 +1,8 @@ """Start a DHCP server in the background""" import os +import tempfile as tf +import subprocess + class Server: config_file = '/tmp/udhcpd.conf' @@ -59,3 +62,276 @@ option lease 864000 self.process.terminate() self.process.wait() self.process = None + + +class Server6Dnsmasq: + """DHCPv6 server using dnsmasq""" + + def __init__(self, netns, start=None, end=None, dns=None, domain=None, + iface="iface", address=None): + """Initialize DHCPv6 server + + Args: + netns: Network namespace to run server in + start: Starting IPv6 address for range (e.g., "2001:db8::100") + end: Ending IPv6 address for range (e.g., "2001:db8::200") + dns: DNS server addresses (list or string) + domain: DNS search domain + iface: Interface to listen on + """ + self.process = None + self.netns = netns + self.iface = iface + self.config_file = tf.NamedTemporaryFile(mode='w', prefix='dnsmasq6_', + suffix='.conf', delete=False) + self.config_path = self.config_file.name + self.leases_file = tf.NamedTemporaryFile(mode='w', prefix='dnsmasq6_', + suffix='.leases', delete=False) + self.leases_path = self.leases_file.name + self.leases_file.close() + + self.hosts_file = tf.NamedTemporaryFile(mode='w', delete=False) + self.hosts_path = self.hosts_file.name + + self._create_config(start, end, dns, domain, address) + + def __del__(self): + try: + if os.path.exists(self.config_path): + os.unlink(self.config_path) + if os.path.exists(self.leases_path): + os.unlink(self.leases_path) + if os.path.exists(self.hosts_path): + os.unlink(self.hosts_path) + except: + pass + + def __enter__(self): + self.start() + return self + + def __exit__(self, _, __, ___): + self.stop() + + def _create_config(self, start, end, dns, domain, address): + """Create dnsmasq configuration for DHCPv6""" + with self.hosts_file: + # NOTE: most basic tooling expect the server to reply with + # and A record, not just a AAAA record, so a client + # like ping, in dhcpv6_basic, will fail even if -6 + self.hosts_file.write("# Generated by Infamy DHCPv6\n") + self.hosts_file.write(f"10.10.0.1 server.{domain}\n") # dummy + self.hosts_file.write(f"{address} server.{domain}\n") + + with self.config_file: + self.config_file.write("# Generated by Infamy DHCPv6\n") + self.config_file.write(f"interface={self.iface}\n") + self.config_file.write(f"addn-hosts={self.hosts_path}\n") + self.config_file.write(f"domain={domain}\n") + self.config_file.write("enable-ra\n") + self.config_file.write(f"dhcp-leasefile={self.leases_path}\n") + self.config_file.write(f"dhcp-range={self.iface},") + + if start and end: + # Stateful DHCPv6 - assign addresses + self.config_file.write(f"{start},{end},64,3600\n") + else: + # Stateless DHCPv6 - only provide options + # TODO: needs more testing with ra-names + slaac + self.config_file.write(f"::10,::fff,constructor:{self.iface},") + self.config_file.write("64,3600\n") + + if dns: + if isinstance(dns, list): + dns_str = ','.join(dns) + else: + dns_str = dns + self.config_file.write(f"dhcp-option=option6:dns-server,{dns_str}\n") + + if domain: + self.config_file.write(f"dhcp-option=option6:domain-search,{domain}\n") + + self.config_file.write("log-debug\n") + self.config_file.write("log-dhcp\n") + + def start(self): + """Start the DHCPv6 server""" + if not os.path.exists(self.config_path): + raise Exception("Config file does not exist") + + # Drop DEVNULL redirec to debug + cmd = f"dnsmasq --conf-file={self.config_path} --no-daemon" + self.process = self.netns.popen(cmd.split(" "), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + def stop(self): + """Stop the DHCPv6 server""" + if self.process: + self.process.terminate() + self.process.wait() + self.process = None + +class Server6Dhcpd: + """DHCPv6 server using ISC dhcpd with prefix delegation support""" + + def __init__(self, netns, start=None, end=None, prefix=None, prefix_len=64, + dns=None, domain=None, iface="iface", address=None, subnet="2001:db8::/48"): + """Initialize DHCPv6 server with ISC dhcpd + + Args: + netns: Network namespace to run server in + start: Starting IPv6 address for IA_NA range (e.g., "2001:db8::100") + end: Ending IPv6 address for IA_NA range (e.g., "2001:db8::200") + prefix: IPv6 prefix pool for prefix delegation (e.g., "2001:db8:100::") + prefix_len: Length of delegated prefixes (e.g., 64 for /64 prefixes) + dns: DNS server addresses (list or string) + domain: DNS search domain + iface: Interface to listen on + address: Server address on the interface (for subnet config) + subnet: Subnet declaration (e.g., "2001:db8::/48") + """ + self.process = None + self.netns = netns + self.iface = iface + self.subnet = subnet + + self.config_file = tf.NamedTemporaryFile(mode='w', prefix='dhcpd6_', + suffix='.conf', delete=False) + self.config_path = self.config_file.name + + self.leases_file = tf.NamedTemporaryFile(mode='w', prefix='dhcpd6_', + suffix='.leases', delete=False) + self.leases_path = self.leases_file.name + self.leases_file.close() + + self.pid_file = tf.NamedTemporaryFile(mode='w', prefix='dhcpd6_', + suffix='.pid', delete=False) + self.pid_path = self.pid_file.name + self.pid_file.close() + + self._create_config(start, end, prefix, prefix_len, dns, domain) + + def __del__(self): + try: + if os.path.exists(self.config_path): + os.unlink(self.config_path) + if os.path.exists(self.leases_path): + os.unlink(self.leases_path) + if os.path.exists(self.pid_path): + os.unlink(self.pid_path) + except: + pass + + def __enter__(self): + self.start() + return self + + def __exit__(self, _, __, ___): + self.stop() + + def _create_config(self, start, end, prefix, prefix_len, dns, domain): + """Create ISC dhcpd configuration for DHCPv6 with prefix delegation""" + with self.config_file: + self.config_file.write("# Generated by Infamy DHCPv6\n") + + # Global options + self.config_file.write("default-lease-time 3600;\n") + self.config_file.write("max-lease-time 7200;\n") + self.config_file.write("log-facility local7;\n") + self.config_file.write("\n") + + # DNS options + if dns: + if isinstance(dns, list): + dns_str = ', '.join(dns) + else: + dns_str = dns + self.config_file.write(f"option dhcp6.name-servers {dns_str};\n") + + if domain: + self.config_file.write(f"option dhcp6.domain-search \"{domain}\";\n") + + self.config_file.write("\n") + + # Subnet declaration + self.config_file.write(f"subnet6 {self.subnet} {{\n") + + # Address range for IA_NA (regular address assignment) + if start and end: + self.config_file.write(f" range6 {start} {end};\n") + + # Prefix delegation pool + if prefix: + # Calculate end of prefix pool based on prefix_len + # For simplicity, we'll create a reasonable range + # e.g., if prefix is 2001:db8:100:: and prefix_len is 64, + # delegate /64 prefixes from 2001:db8:100:: to 2001:db8:1ff:: + prefix_base = prefix.rstrip(':') + # Simple approach: if prefix ends with ::, add range + if prefix.endswith('::'): + prefix_start = prefix + # Create end address by incrementing last hex digit before :: + # For 2001:db8:100::, end would be 2001:db8:1ff:: + parts = prefix_base.split(':') + if len(parts) >= 2: + last_part = parts[-2] if parts[-2] else parts[-3] + try: + last_val = int(last_part, 16) + end_val = last_val + 0xff + parts_copy = parts[:-1] + parts_copy[-1] = f"{end_val:x}" + prefix_end = ':'.join(parts_copy) + '::' + except: + prefix_end = prefix # Fallback + else: + prefix_end = prefix + else: + prefix_start = prefix + prefix_end = prefix + + self.config_file.write(f" prefix6 {prefix_start} {prefix_end} /{prefix_len};\n") + + self.config_file.write("}\n") + + def start(self): + """Start the DHCPv6 server""" + if not os.path.exists(self.config_path): + raise Exception("Config file does not exist") + + # Debug: show config and interface status + # self.netns.popen(f"cat {self.config_path}".split(" ")) + # self.netns.popen("ifconfig") + + # ISC dhcpd command for IPv6 + # -6: IPv6 mode + # -f: Stay in foreground (no daemon) + # -d: Debug/log to stderr + # -cf: Config file + # -lf: Lease file + # -pf: PID file + cmd = [ + "dhcpd", + "-6", # IPv6 mode + "-f", # Foreground + "-d", # Debug output + "-cf", self.config_path, + "-lf", self.leases_path, + "-pf", self.pid_path, + self.iface # Interface to listen on + ] + + self.process = self.netns.popen(cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + def stop(self): + """Stop the DHCPv6 server""" + if self.process: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + self.process = None From 9d0e9109f4a5431b6193271b3cf9a991c5ac5f77 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 13 Nov 2025 00:23:36 +0100 Subject: [PATCH 6/8] doc: update networking guide with dhcpv6 details Signed-off-by: Joachim Wiberg --- doc/networking.md | 106 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 5 deletions(-) diff --git a/doc/networking.md b/doc/networking.md index 79532f66..59d71fc2 100644 --- a/doc/networking.md +++ b/doc/networking.md @@ -959,16 +959,55 @@ Client Configuration](system.md#ntp-client-configuration) section. Multiple address assignment methods are available: -| **Type** | **Yang Model** | **Description** | -|:---------------- |:-------------- |:------------------------------------------------------------------------------------------------------------------------------------------------- | -| static | ietf-ip | Static assignment of IPv6 address, e.g., *2001:db8:0:1::1/64* | -| link-local | ietf-ip[^2] | (RFC4862) Auto-configured link-local IPv6 address (*fe80::0* prefix + interface identifier, e.g., *fe80::ccd2:82ff:fe52:728b/64*) | -| global auto-conf | ietf-ip | (RFC4862) Auto-configured (stateless) global IPv6 address (prefix from router + interface identifier, e.g., *2001:db8:0:1:ccd2:82ff:fe52:728b/64* | +| **Type** | **Yang Model** | **Description** | +|:---------------- |:-------------------- |:------------------------------------------------------------------------------------------------------------------------------------------------- | +| static | ietf-ip | Static assignment of IPv6 address, e.g., *2001:db8:0:1::1/64* | +| link-local | ietf-ip[^2] | (RFC4862) Auto-configured link-local IPv6 address (*fe80::0* prefix + interface identifier, e.g., *fe80::ccd2:82ff:fe52:728b/64*) | +| global auto-conf | ietf-ip | (RFC4862) Auto-configured (stateless) global IPv6 address (prefix from router + interface identifier, e.g., *2001:db8:0:1:ccd2:82ff:fe52:728b/64* | +| dhcp | infix-dhcpv6-client | Assignment of IPv6 address by DHCPv6 server, e.g., *2001:db8::42/128* | Both for *link-local* and *global auto-configuration*, it is possible to auto-configure using a random suffix instead of the interface identifier. +> [!NOTE] +> The DHCPv6 address method is only available for *LAN* interfaces +> (Ethernet, virtual Ethernet (veth), bridge, link aggregates, etc.) + +Supported DHCPv6 (request) options, configurability (Cfg) and defaults, +are listed below. Configurable options can be disabled on a per client +interface basis, some options, like `client-id` and `client-fqdn`, are +possible to set the value of as well. + +| **Opt** | **Name** | **Cfg** | **Description** | +|---------|----------------------------|---------|--------------------------------------------------------| +| 1 | `client-id` | Yes | Client identifier (DUID), auto-generated by default | +| 2 | `server-id` | Yes | Server identifier (DUID) | +| 23 | `dns-server` | Yes | DNS recursive name servers, static ones take precedence| +| 24 | `domain-search` | Yes | Domain search list | +| 25 | `ia-pd` | Yes | Prefix delegation for downstream networks | +| 31 | `sntp-server` | Yes | Simple Network Time Protocol servers | +| 32 | `information-refresh-time` | Yes | Refresh time for stateless DHCPv6 | +| 39 | `client-fqdn` | Yes | Client FQDN, request DNS update from server | +| 56 | `ntp-server` | Yes | NTP time servers, static ones take precedence | +| | | | | + +**Default:** `dns-server`, `domain-search`, `ntp-server` + +DHCPv6 supports both **stateful** (address assignment) and **stateless** +(information-only) modes: + +- **Stateful DHCPv6**: The server assigns IPv6 addresses to clients. This is + the default mode when enabling the DHCPv6 client. +- **Stateless DHCPv6**: Used with SLAAC (Stateless Address Autoconfiguration) + when only configuration information (DNS, NTP, etc.) is needed. Enable with + the `information-only` setting. + +When configuring a DHCPv6 client, ensure that the NTP client is enabled +for the `ntp-server` DHCPv6 option to be processed correctly. If the +NTP client is not enabled, any NTP servers provided by the DHCPv6 server +will be ignored. For details on how to enable the NTP client, see the +[NTP Client Configuration](system.md#ntp-client-configuration) section. ### Examples @@ -1080,6 +1119,63 @@ Other useful DHCP options include: For advanced usage with vendor-specific options, see the YANG model. +#### Use of DHCPv6 for IPv6 address assignment + +![Using DHCPv6 for IPv6 address assignment](img/ip-address-example-ipv6-dhcp.svg) + + admin@example:/> configure + admin@example:/config/> edit interface eth0 ipv6 + admin@example:/config/interface/eth0/ipv6/> set dhcp + admin@example:/config/interface/eth0/ipv6/> leave + admin@example:/> show interface + INTERFACE PROTOCOL STATE DATA + eth0 ethernet UP 02:00:00:00:00:00 + ipv6 2001:db8::42/128 (dhcp) + ipv6 fe80::ff:fe00:0/64 (link-layer) + lo ethernet UP 00:00:00:00:00:00 + ipv4 127.0.0.1/8 (static) + ipv6 ::1/128 (static) + admin@example:/> + +The resulting address (2001:db8::42/128) is of type *dhcp*. + +To configure DHCPv6 client options, such as requesting prefix delegation +for downstream networks, you can specify options: + +``` +admin@example:/> configure +admin@example:/config/> edit interface eth0 ipv6 dhcp +admin@example:/config/interface/eth0/ipv6/dhcp/> set option ia-pd +admin@example:/config/interface/eth0/ipv6/dhcp/> set option dns-server +admin@example:/config/interface/eth0/ipv6/dhcp/> show +option dns-server; +option ia-pd; +admin@example:/config/interface/eth0/ipv6/dhcp/> leave +admin@example:/> +``` + +For stateless DHCPv6 (used with SLAAC to get only configuration information): + +``` +admin@example:/> configure +admin@example:/config/> edit interface eth0 ipv6 dhcp +admin@example:/config/interface/eth0/ipv6/dhcp/> set information-only true +admin@example:/config/interface/eth0/ipv6/dhcp/> show +information-only true; +option dns-server; +option domain-search; +admin@example:/config/interface/eth0/ipv6/dhcp/> leave +admin@example:/> +``` + +Other useful DHCPv6 options include: + +- `duid` - Set a specific DHCPv6 Unique Identifier (auto-generated by default) +- `client-fqdn` - Request the server to update DNS records with client's FQDN +- `route-preference` - Set the administrative distance for DHCPv6-learned routes (default: 5) + +For advanced usage with vendor-specific options, see the YANG model. + #### Disabling IPv6 link-local address(es) The (only) way to disable IPv6 link-local addresses is by disabling IPv6 From 635ebeb29b91cc908021bee46e6bc44e0a3dbbda Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 13 Nov 2025 00:23:56 +0100 Subject: [PATCH 7/8] doc: update ChangeLog, DHCPv6 client support Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 49726848..55c78165 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -31,6 +31,10 @@ All notable changes to the project are documented in this file. now enabled using a presence container instead of a separate `enabled` leaf - The `enabled` nore for IPv4 autoconf (ZeroConf) has been dropped, `autoconf` is now a presence container. Configuration automatically migrated on upgrade +- Add DHCPv6 client support for per-interface IPv6 configuration, augmenting + `/interfaces/interface[name]/ipv6/infix-dhcpv6-client:dhcp`, issue #1110 +- Fix namespace for DHCPv4 client YANG module from `urn:ietf:params:xml:ns:yang` + to `urn:infix:params:xml:ns:yang` to properly reflect custom implementation - Improvements to `sdcard.img` generation, useful for developers mostly: - The NanoPi R2S bootloader is now automatically built and uploaded to the [`latest-boot` release][lastest-boot] tag From 548e0c48819141e303a7ce1f09b7828525d9eece Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 18 Nov 2025 12:59:25 +0100 Subject: [PATCH 8/8] board/common: don't change link status of interface After some discussion we agreed that this operation is the domain of confd (or dagger) and any DHCP client should not mess with the admin state of the interface it runs on. Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/share/udhcpc/default.script | 1 - board/common/rootfs/usr/share/udhcpc/default6.script | 1 - 2 files changed, 2 deletions(-) diff --git a/board/common/rootfs/usr/share/udhcpc/default.script b/board/common/rootfs/usr/share/udhcpc/default.script index 4ebb3ac5..5fb87935 100755 --- a/board/common/rootfs/usr/share/udhcpc/default.script +++ b/board/common/rootfs/usr/share/udhcpc/default.script @@ -104,7 +104,6 @@ case "$ACTION" in deconfig) clr_dhcp_addresses clr_dhcp_routes - /bin/ip link set dev $interface up # drop info from this interface rm -f "$RESOLV_CONF" diff --git a/board/common/rootfs/usr/share/udhcpc/default6.script b/board/common/rootfs/usr/share/udhcpc/default6.script index 219d6a3e..c588bdbd 100755 --- a/board/common/rootfs/usr/share/udhcpc/default6.script +++ b/board/common/rootfs/usr/share/udhcpc/default6.script @@ -45,7 +45,6 @@ log "action $ACTION" case "$ACTION" in deconfig) clr_dhcpv6_addresses - /bin/ip link set dev $interface up # drop info from this interface rm -f "$RESOLV_CONF"