Merge pull request #1251 from kernelkit/dhcpv6-client

Add DHCPv6 client support
This commit is contained in:
Joachim Wiberg
2025-11-18 19:04:14 +01:00
committed by GitHub
35 changed files with 1467 additions and 288 deletions
+1
View File
@@ -0,0 +1 @@
/usr/share/udhcpc/default.script
+1
View File
@@ -0,0 +1 @@
/usr/share/udhcpc/default6.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"
+111
View File
@@ -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
+4
View File
@@ -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
+101 -5
View File
@@ -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
+1
View File
@@ -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 \
+4 -4
View File
@@ -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 */
+3 -4
View File
@@ -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);
+4
View File
@@ -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;
+3
View File
@@ -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);
+4 -222
View File
@@ -12,6 +12,8 @@
#include <srx/srx_val.h>
#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;
}
+242
View File
@@ -1,6 +1,18 @@
/* SPDX-License-Identifier: BSD-3-Clause */
#include <ctype.h>
#include <pwd.h>
#include <sys/utsname.h>
#include <sys/sysinfo.h>
#include <sys/types.h>
#include <srx/common.h>
#include <srx/lyx.h>
#include <srx/srx_val.h>
#include <libyang/libyang.h>
#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;
}
+20
View File
@@ -0,0 +1,20 @@
/* SPDX-License-Identifier: BSD-3-Clause */
#ifndef INFIX_DHCP_COMMON_H_
#define INFIX_DHCP_COMMON_H_
#include <libyang/libyang.h>
/* 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_ */
+2 -1
View File
@@ -2,13 +2,14 @@
#include <assert.h>
#include <ctype.h>
#include <ifaddrs.h>
#include <srx/common.h>
#include <srx/lyx.h>
#include <srx/srx_val.h>
#include "core.h"
#include <ifaddrs.h>
#include "infix-dhcp-common.h"
#define MODULE "infix-dhcp-server"
#define ROOT_XPATH "/infix-dhcp-server:"
+167
View File
@@ -0,0 +1,167 @@
/* SPDX-License-Identifier: BSD-3-Clause */
#include <assert.h>
#include <ctype.h>
#include <pwd.h>
#include <sys/utsname.h>
#include <sys/sysinfo.h>
#include <sys/types.h>
#include <srx/common.h>
#include <srx/lyx.h>
#include <srx/srx_val.h>
#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 <net/%s/running> \\\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;
}
+3 -2
View File
@@ -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"
+5 -1
View File
@@ -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
@@ -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.";
}
}
}
}
}
@@ -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.";
}
}
}
}
@@ -0,0 +1 @@
infix-dhcpv6-client.yang
+1 -1
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
test.adoc
+66
View File
@@ -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()
@@ -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> mgmt | <data> data }",
pos="0,20!",
requires="controller",
];
client [
label="{ <mgmt> mgmt | <data> 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"]
}
@@ -0,0 +1 @@
test.adoc
+82
View File
@@ -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()
@@ -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> mgmt | <data> data }",
pos="0,20!",
requires="controller",
];
client [
label="{ <mgmt> mgmt | <data> 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"]
}
+6
View File
@@ -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
+2
View File
@@ -4,6 +4,8 @@ FROM alpine:3.18.0
RUN apk add --no-cache \
busybox-extras \
curl \
dhcp-server-vanilla \
dnsmasq \
e2fsprogs \
e2tools \
ethtool \
+33 -23
View File
@@ -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: <https://github.com/kernelkit/infix/pkgs/container/infix-test>
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: <https://github.com/kernelkit/infix/pkgs/container/infix-test>
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 <https://ghcr.io>. 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 <https://ghcr.io>. 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.
+276
View File
@@ -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
+63 -24
View File
@@ -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