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/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 new file mode 100755 index 00000000..c588bdbd --- /dev/null +++ b/board/common/rootfs/usr/share/udhcpc/default6.script @@ -0,0 +1,111 @@ +#!/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 + + # 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/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 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 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 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/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/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. 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 diff --git a/test/infamy/iface.py b/test/infamy/iface.py index 6bfdb555..4b872ad3 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,42 +32,78 @@ 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 -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)""" 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 +111,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