From c61f436b8752478164fb5d261a9582f09082ff5d Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 09:41:33 +0200 Subject: [PATCH 01/14] test: drop "combination" from the DHCP NTP DNS use case Every use case test combines features, so the word adds nothing to the name of this one. Signed-off-by: Joachim Wiberg --- test/case/use_case/Readme.adoc | 2 +- test/case/use_case/all.yaml | 4 ++-- .../{dhcp_ntp_dns_combination => dhcp_ntp_dns}/Readme.adoc | 0 .../{dhcp_ntp_dns_combination => dhcp_ntp_dns}/test.adoc | 6 +++--- .../{dhcp_ntp_dns_combination => dhcp_ntp_dns}/test.py | 0 .../{dhcp_ntp_dns_combination => dhcp_ntp_dns}/topology.dot | 2 +- .../{dhcp_ntp_dns_combination => dhcp_ntp_dns}/topology.svg | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) rename test/case/use_case/{dhcp_ntp_dns_combination => dhcp_ntp_dns}/Readme.adoc (100%) rename test/case/use_case/{dhcp_ntp_dns_combination => dhcp_ntp_dns}/test.adoc (76%) rename test/case/use_case/{dhcp_ntp_dns_combination => dhcp_ntp_dns}/test.py (100%) rename test/case/use_case/{dhcp_ntp_dns_combination => dhcp_ntp_dns}/topology.dot (95%) rename test/case/use_case/{dhcp_ntp_dns_combination => dhcp_ntp_dns}/topology.svg (97%) diff --git a/test/case/use_case/Readme.adoc b/test/case/use_case/Readme.adoc index 0060c17bb..beed1d66d 100644 --- a/test/case/use_case/Readme.adoc +++ b/test/case/use_case/Readme.adoc @@ -7,5 +7,5 @@ together: - OSPF routing with containerized applications and network segmentation - Combined static and DHCP-provided DNS and NTP servers -include::dhcp_ntp_dns_combination/Readme.adoc[] +include::dhcp_ntp_dns/Readme.adoc[] include::ospf_container/Readme.adoc[] diff --git a/test/case/use_case/all.yaml b/test/case/use_case/all.yaml index fb7988419..a160deb90 100644 --- a/test/case/use_case/all.yaml +++ b/test/case/use_case/all.yaml @@ -1,6 +1,6 @@ --- -- name: DHCP NTP DNS Combination - case: dhcp_ntp_dns_combination/test.py +- name: DHCP NTP DNS + case: dhcp_ntp_dns/test.py - name: OSPF Container case: ospf_container/test.py diff --git a/test/case/use_case/dhcp_ntp_dns_combination/Readme.adoc b/test/case/use_case/dhcp_ntp_dns/Readme.adoc similarity index 100% rename from test/case/use_case/dhcp_ntp_dns_combination/Readme.adoc rename to test/case/use_case/dhcp_ntp_dns/Readme.adoc diff --git a/test/case/use_case/dhcp_ntp_dns_combination/test.adoc b/test/case/use_case/dhcp_ntp_dns/test.adoc similarity index 76% rename from test/case/use_case/dhcp_ntp_dns_combination/test.adoc rename to test/case/use_case/dhcp_ntp_dns/test.adoc index 007ffa766..69ee46809 100644 --- a/test/case/use_case/dhcp_ntp_dns_combination/test.adoc +++ b/test/case/use_case/dhcp_ntp_dns/test.adoc @@ -1,6 +1,6 @@ -=== DHCP NTP DNS Combination +=== DHCP NTP DNS -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/use_case/dhcp_ntp_dns_combination] +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/use_case/dhcp_ntp_dns] ==== Description @@ -9,7 +9,7 @@ servers from a DHCP server. ==== Topology -image::topology.svg[DHCP NTP DNS Combination topology, align=center, scaledwidth=75%] +image::topology.svg[DHCP NTP DNS topology, align=center, scaledwidth=75%] ==== Sequence diff --git a/test/case/use_case/dhcp_ntp_dns_combination/test.py b/test/case/use_case/dhcp_ntp_dns/test.py similarity index 100% rename from test/case/use_case/dhcp_ntp_dns_combination/test.py rename to test/case/use_case/dhcp_ntp_dns/test.py diff --git a/test/case/use_case/dhcp_ntp_dns_combination/topology.dot b/test/case/use_case/dhcp_ntp_dns/topology.dot similarity index 95% rename from test/case/use_case/dhcp_ntp_dns_combination/topology.dot rename to test/case/use_case/dhcp_ntp_dns/topology.dot index ba6cdd5af..decea2d25 100644 --- a/test/case/use_case/dhcp_ntp_dns_combination/topology.dot +++ b/test/case/use_case/dhcp_ntp_dns/topology.dot @@ -1,4 +1,4 @@ -graph "dhcp_ntp_dns_combination" { +graph "dhcp_ntp_dns" { layout="neato"; overlap="false"; esep="+40"; diff --git a/test/case/use_case/dhcp_ntp_dns_combination/topology.svg b/test/case/use_case/dhcp_ntp_dns/topology.svg similarity index 97% rename from test/case/use_case/dhcp_ntp_dns_combination/topology.svg rename to test/case/use_case/dhcp_ntp_dns/topology.svg index 205a9e9c8..56295de48 100644 --- a/test/case/use_case/dhcp_ntp_dns_combination/topology.svg +++ b/test/case/use_case/dhcp_ntp_dns/topology.svg @@ -2,11 +2,11 @@ - + -dhcp_ntp_dns_combination +dhcp_ntp_dns From 25e22a3b98bef712871d1f38ec3b94673384c252 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 09:43:15 +0200 Subject: [PATCH 02/14] .github: build libwdog for the Coverity scan confd now subscribes to watchdogd while it loads the startup config, so it needs libwdog to build, and the scan stopped at configure. Signed-off-by: Joachim Wiberg --- .github/workflows/coverity.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coverity.yml b/.github/workflows/coverity.yml index 609d6f3f9..9f8ee457b 100644 --- a/.github/workflows/coverity.yml +++ b/.github/workflows/coverity.yml @@ -11,6 +11,7 @@ env: LIBYANG_VERSION: 4.2.2 SYSREPO_VERSION: 4.2.10 SYSKLOGD_VERSION: 2.7.2 + WATCHDOGD_VERSION: '4.0' jobs: coverity: @@ -58,7 +59,7 @@ jobs: sudo apt-get -y update sudo apt-get -y install pkg-config libjansson-dev libev-dev \ libcrypt-dev libglib2.0-dev libpcre2-dev \ - libuev-dev libavahi-client-dev + libuev-dev libavahi-client-dev libconfuse-dev - name: Build dependencies run: | @@ -82,6 +83,10 @@ jobs: git clone -b v${SYSKLOGD_VERSION} --depth 1 https://github.com/troglobit/sysklogd.git (cd sysklogd && ./autogen.sh && ./configure --without-logger --without-systemd \ && make && sudo make install) + + git clone -b ${WATCHDOGD_VERSION} --depth 1 https://github.com/troglobit/watchdogd.git + (cd watchdogd && ./autogen.sh && ./configure --without-systemd \ + && make && sudo make install) make dep - name: Build applications for Coverity From 2bf7d2225a8877f4aeec8b280bbffd3ae9df5640 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 10:02:53 +0200 Subject: [PATCH 03/14] netd: initialize the route dump request The kernel backend left the control fields of the route dump message uninitialized, so whether the dump worked depended on what was on the stack. When it failed, with ENOBUFS, netd saw none of the routes it had installed and never removed or replaced any of them. Signed-off-by: Joachim Wiberg --- src/netd/src/linux_backend.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/netd/src/linux_backend.c b/src/netd/src/linux_backend.c index 271642a13..3f0cd234a 100644 --- a/src/netd/src/linux_backend.c +++ b/src/netd/src/linux_backend.c @@ -289,7 +289,7 @@ static int kernel_read_routes(struct route_head *routes, int family) struct sockaddr_nl sa = { .nl_family = AF_NETLINK }; struct nlmsghdr *nlh; struct rtattr *rta; - struct msghdr msg; + struct msghdr msg = { 0 }; struct rtmsg *rtm; struct iovec iov; struct route *r; From 78a7106a9315891f0e672d2ed0da02b8d9c1900c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 10:03:05 +0200 Subject: [PATCH 04/14] netd: let a route name its interface and source prefix A gateway learned from a router advertisement is link-local, so it is only reachable on a given interface, and a route through it may apply only to traffic from a delegated prefix. netd could express neither, which is why DHCPv6 routes bypassed it. Signed-off-by: Joachim Wiberg --- src/netd/README.md | 4 ++++ src/netd/src/config.c | 28 +++++++++++++++++++++- src/netd/src/frrconf_backend.c | 6 +++++ src/netd/src/json_builder.c | 39 +++++++++++++++++++++++++----- src/netd/src/linux_backend.c | 43 ++++++++++++++++++++++++++-------- src/netd/src/netd.h | 5 +++- src/netd/src/vtysh_backend.c | 6 +++++ 7 files changed, 113 insertions(+), 18 deletions(-) diff --git a/src/netd/README.md b/src/netd/README.md index aa0aca358..ddab47a19 100644 --- a/src/netd/README.md +++ b/src/netd/README.md @@ -102,6 +102,10 @@ route { - IP address: `"192.168.1.1"` or `"fe80::1"` - Interface name: `"eth0"` - Blackhole: `"blackhole"`, `"reject"`, or `"Null0"` +- `interface` (optional) - Interface for an IP address next hop, required + for a link-local gateway, e.g., `"fe80::1"` on `"eth0"` +- `source` (optional) - IPv6 source prefix, the route then only applies + to traffic from that prefix (dst-src routing) - `distance` (optional, default: 1) - Administrative distance (1-255) - `tag` (optional, default: 0) - Route tag (0-4294967295), used for route filtering/redistribution diff --git a/src/netd/src/config.c b/src/netd/src/config.c index a002fd492..943190930 100644 --- a/src/netd/src/config.c +++ b/src/netd/src/config.c @@ -11,7 +11,7 @@ */ static int parse_route_section(cfg_t *cfg_route, struct route_head *head) { - const char *prefix_str, *nexthop_str; + const char *prefix_str, *nexthop_str, *ifname, *source; char prefix_copy[128]; struct in6_addr a6; struct in_addr a4; @@ -21,6 +21,8 @@ static int parse_route_section(cfg_t *cfg_route, struct route_head *head) prefix_str = cfg_getstr(cfg_route, "prefix"); nexthop_str = cfg_getstr(cfg_route, "nexthop"); + ifname = cfg_getstr(cfg_route, "interface"); + source = cfg_getstr(cfg_route, "source"); distance = cfg_getint(cfg_route, "distance"); if (!prefix_str || !nexthop_str) { @@ -89,6 +91,28 @@ static int parse_route_section(cfg_t *cfg_route, struct route_head *head) snprintf(r->ifname, sizeof(r->ifname), "%s", nexthop_str); } + /* A link-local gateway is only reachable on a given interface */ + if (r->nh_type == NH_ADDR && ifname) + snprintf(r->ifname, sizeof(r->ifname), "%s", ifname); + + /* Source-specific (dst-src) route, IPv6 only */ + if (source) { + snprintf(prefix_copy, sizeof(prefix_copy), "%s", source); + slash = strchr(prefix_copy, '/'); + if (r->family != AF_INET6 || !slash) { + ERROR("Invalid route source: %s", source); + free(r); + return -1; + } + *slash = '\0'; + r->srclen = (uint8_t)atoi(slash + 1); + if (inet_pton(AF_INET6, prefix_copy, &r->src) != 1) { + ERROR("Invalid route source: %s", source); + free(r); + return -1; + } + } + TAILQ_INSERT_TAIL(head, r, entries); return 0; } @@ -291,6 +315,8 @@ static int config_parse_file(const char *path, struct route_head *routes, cfg_opt_t route_opts[] = { CFG_STR("prefix", NULL, CFGF_NONE), CFG_STR("nexthop", NULL, CFGF_NONE), + CFG_STR("interface", NULL, CFGF_NONE), + CFG_STR("source", NULL, CFGF_NONE), CFG_INT("distance", 1, CFGF_NONE), CFG_INT("tag", 0, CFGF_NONE), CFG_END() diff --git a/src/netd/src/frrconf_backend.c b/src/netd/src/frrconf_backend.c index 1afe0a809..3d41de0d4 100644 --- a/src/netd/src/frrconf_backend.c +++ b/src/netd/src/frrconf_backend.c @@ -59,6 +59,10 @@ static void write_static_routes(FILE *fp, struct route_head *routes) } fprintf(fp, "%s route %s/%u ", cmd, prefix_str, r->prefixlen); + if (r->srclen) { + inet_ntop(AF_INET6, &r->src, gw_str, sizeof(gw_str)); + fprintf(fp, "from %s/%u ", gw_str, r->srclen); + } switch (r->nh_type) { case NH_ADDR: @@ -67,6 +71,8 @@ static void write_static_routes(FILE *fp, struct route_head *routes) else inet_ntop(AF_INET6, &r->gateway.gw6, gw_str, sizeof(gw_str)); fputs(gw_str, fp); + if (r->ifname[0]) + fprintf(fp, " %s", r->ifname); break; case NH_IFNAME: fputs(r->ifname, fp); diff --git a/src/netd/src/json_builder.c b/src/netd/src/json_builder.c index 87ea31f70..c5b21b3c2 100644 --- a/src/netd/src/json_builder.c +++ b/src/netd/src/json_builder.c @@ -30,12 +30,35 @@ static bool same_prefix(const struct route *a, const struct route *b) if (a->family != b->family || a->prefixlen != b->prefixlen) return false; + if (a->srclen != b->srclen || memcmp(&a->src, &b->src, sizeof(a->src))) + return false; + if (a->family == AF_INET) return memcmp(&a->prefix.ip4, &b->prefix.ip4, sizeof(a->prefix.ip4)) == 0; else return memcmp(&a->prefix.ip6, &b->prefix.ip6, sizeof(a->prefix.ip6)) == 0; } +/* Source prefix of a dst-src route, "::/0" for any */ +static const char *src_prefix(const struct route *r, char *buf, size_t len) +{ + char addr[INET6_ADDRSTRLEN]; + + inet_ntop(AF_INET6, &r->src, addr, sizeof(addr)); + snprintf(buf, len, "%s/%u", addr, r->srclen); + + return buf; +} + +/* frr-nexthop type for a gateway, with or without an interface */ +static const char *nh_type_addr(const struct route *r) +{ + if (r->family == AF_INET) + return r->ifname[0] ? "ip4-ifindex" : "ip4"; + + return r->ifname[0] ? "ip6-ifindex" : "ip6"; +} + /* * Build JSON configuration for staticd following FRR's YANG model. * Groups routes with the same prefix into a single route-list entry @@ -44,6 +67,7 @@ static bool same_prefix(const struct route *a, const struct route *b) const char *build_staticd_json(struct route_head *routes) { char prefix_str[INET6_ADDRSTRLEN + 4]; + char src_str[INET6_ADDRSTRLEN + 4]; char addr_buf[INET6_ADDRSTRLEN]; json_t *control_plane_protocols; json_t *control_plane_protocol; @@ -86,7 +110,8 @@ const char *build_staticd_json(struct route_head *routes) route_entry = json_object(); json_object_set_new(route_entry, "prefix", json_string(prefix_str)); - json_object_set_new(route_entry, "src-prefix", json_string("::/0")); + json_object_set_new(route_entry, "src-prefix", + json_string(src_prefix(r, src_str, sizeof(src_str)))); json_object_set_new(route_entry, "afi-safi", json_string(afi)); json_t *path_list = json_array(); @@ -110,10 +135,10 @@ const char *build_staticd_json(struct route_head *routes) else inet_ntop(AF_INET6, &curr->gateway.gw6, addr_buf, sizeof(addr_buf)); - json_object_set_new(nexthop, "nh-type", json_string(curr->family == AF_INET ? "ip4" : "ip6")); + json_object_set_new(nexthop, "nh-type", json_string(nh_type_addr(curr))); json_object_set_new(nexthop, "vrf", json_string("default")); json_object_set_new(nexthop, "gateway", json_string(addr_buf)); - json_object_set_new(nexthop, "interface", json_string("")); + json_object_set_new(nexthop, "interface", json_string(curr->ifname)); break; case NH_IFNAME: @@ -331,6 +356,7 @@ const char *build_rip_json(struct rip_config *rip_cfg) const char *build_routing_json(struct route_head *routes, struct rip_config *rip_cfg) { char prefix_str[INET6_ADDRSTRLEN + 4]; + char src_str[INET6_ADDRSTRLEN + 4]; char addr_buf[INET6_ADDRSTRLEN]; struct route *r, *curr; json_t *root; @@ -369,7 +395,8 @@ const char *build_routing_json(struct route_head *routes, struct rip_config *rip json_t *route_entry = json_object(); json_object_set_new(route_entry, "prefix", json_string(prefix_str)); - json_object_set_new(route_entry, "src-prefix", json_string("::/0")); + json_object_set_new(route_entry, "src-prefix", + json_string(src_prefix(r, src_str, sizeof(src_str)))); json_object_set_new(route_entry, "afi-safi", json_string(afi)); json_t *path_list = json_array(); @@ -391,10 +418,10 @@ const char *build_routing_json(struct route_head *routes, struct rip_config *rip else inet_ntop(AF_INET6, &curr->gateway.gw6, addr_buf, sizeof(addr_buf)); - json_object_set_new(nexthop, "nh-type", json_string(curr->family == AF_INET ? "ip4" : "ip6")); + json_object_set_new(nexthop, "nh-type", json_string(nh_type_addr(curr))); json_object_set_new(nexthop, "vrf", json_string("default")); json_object_set_new(nexthop, "gateway", json_string(addr_buf)); - json_object_set_new(nexthop, "interface", json_string("")); + json_object_set_new(nexthop, "interface", json_string(curr->ifname)); break; case NH_IFNAME: diff --git a/src/netd/src/linux_backend.c b/src/netd/src/linux_backend.c index 3f0cd234a..77a5d5759 100644 --- a/src/netd/src/linux_backend.c +++ b/src/netd/src/linux_backend.c @@ -117,6 +117,12 @@ static int netlink_route_op(const struct route *r, int cmd) else rta_add(nlh, sizeof(buf), RTA_DST, &r->prefix.ip6, sizeof(r->prefix.ip6)); + /* Source prefix, dst-src routing */ + if (r->srclen) { + rtm->rtm_src_len = r->srclen; + rta_add(nlh, sizeof(buf), RTA_SRC, &r->src, sizeof(r->src)); + } + /* Nexthop */ switch (r->nh_type) { case NH_ADDR: @@ -133,13 +139,6 @@ static int netlink_route_op(const struct route *r, int cmd) break; case NH_IFNAME: - /* Output interface */ - ifindex = if_nametoindex(r->ifname); - if (!ifindex) { - ERROR("netlink: interface %s not found", r->ifname); - return -1; - } - rta_add(nlh, sizeof(buf), RTA_OIF, &ifindex, sizeof(ifindex)); DEBUG("netlink: %s route dev %s", cmd == RTM_NEWROUTE ? "add" : "del", r->ifname); break; @@ -162,6 +161,16 @@ static int netlink_route_op(const struct route *r, int cmd) break; } + /* Output interface, also needed for a link-local gateway */ + if (r->nh_type != NH_BLACKHOLE && r->ifname[0]) { + ifindex = if_nametoindex(r->ifname); + if (!ifindex) { + ERROR("netlink: interface %s not found", r->ifname); + return -1; + } + rta_add(nlh, sizeof(buf), RTA_OIF, &ifindex, sizeof(ifindex)); + } + /* Priority (metric/distance) - kernel expects 32-bit value */ if (r->distance) { uint32_t priority = r->distance; @@ -241,6 +250,8 @@ static int route_exists(struct route_head *list, const struct route *needle) continue; if (r->prefixlen != needle->prefixlen) continue; + if (r->srclen != needle->srclen || memcmp(&r->src, &needle->src, sizeof(r->src))) + continue; /* Compare prefix */ if (r->family == AF_INET) { @@ -265,6 +276,9 @@ static int route_exists(struct route_head *list, const struct route *needle) if (memcmp(&r->gateway.gw6, &needle->gateway.gw6, sizeof(r->gateway.gw6))) continue; } + /* The kernel always reports the interface, config may not */ + if (r->ifname[0] && needle->ifname[0] && strcmp(r->ifname, needle->ifname)) + continue; break; case NH_IFNAME: if (strcmp(r->ifname, needle->ifname)) @@ -294,7 +308,7 @@ static int kernel_read_routes(struct route_head *routes, int family) struct iovec iov; struct route *r; char buf[8192]; - int rta_len; + int rta_len, has_gw; int ret; msg.msg_name = &sa; @@ -357,10 +371,12 @@ static int kernel_read_routes(struct route_head *routes, int family) r->family = rtm->rtm_family; r->prefixlen = rtm->rtm_dst_len; + r->srclen = rtm->rtm_src_len; /* Parse attributes */ rta = RTM_RTA(rtm); rta_len = RTM_PAYLOAD(nlh); + has_gw = 0; for (; RTA_OK(rta, rta_len); rta = RTA_NEXT(rta, rta_len)) { switch (rta->rta_type) { @@ -371,8 +387,13 @@ static int kernel_read_routes(struct route_head *routes, int family) memcpy(&r->prefix.ip6, RTA_DATA(rta), sizeof(r->prefix.ip6)); break; + case RTA_SRC: + if (r->family == AF_INET6) + memcpy(&r->src, RTA_DATA(rta), sizeof(r->src)); + break; + case RTA_GATEWAY: - r->nh_type = NH_ADDR; + has_gw = 1; if (r->family == AF_INET) memcpy(&r->gateway.gw4, RTA_DATA(rta), sizeof(r->gateway.gw4)); else @@ -380,7 +401,6 @@ static int kernel_read_routes(struct route_head *routes, int family) break; case RTA_OIF: - r->nh_type = NH_IFNAME; if_indextoname(*(uint32_t *)RTA_DATA(rta), r->ifname); break; @@ -390,6 +410,9 @@ static int kernel_read_routes(struct route_head *routes, int family) } } + /* A gateway route carries its interface as well */ + r->nh_type = has_gw ? NH_ADDR : NH_IFNAME; + /* Detect blackhole routes */ if (rtm->rtm_type == RTN_BLACKHOLE || rtm->rtm_type == RTN_UNREACHABLE) { r->nh_type = NH_BLACKHOLE; diff --git a/src/netd/src/netd.h b/src/netd/src/netd.h index 80df72b4b..fc3d5bbf3 100644 --- a/src/netd/src/netd.h +++ b/src/netd/src/netd.h @@ -57,7 +57,10 @@ struct route { struct in6_addr gw6; } gateway; /* For NH_ADDR */ - char ifname[IFNAMSIZ]; /* For NH_IFNAME */ + char ifname[IFNAMSIZ]; /* For NH_IFNAME, or NH_ADDR on a link */ + + uint8_t srclen; /* IPv6 source prefix length, 0 for any */ + struct in6_addr src; /* IPv6 source prefix, dst-src routing */ TAILQ_ENTRY(route) entries; }; diff --git a/src/netd/src/vtysh_backend.c b/src/netd/src/vtysh_backend.c index 7fb3fb299..1eab66ad2 100644 --- a/src/netd/src/vtysh_backend.c +++ b/src/netd/src/vtysh_backend.c @@ -61,6 +61,10 @@ static void write_route(FILE *fp, struct route *r) } fprintf(fp, "%s route %s/%u ", cmd, prefix_str, r->prefixlen); + if (r->srclen) { + inet_ntop(AF_INET6, &r->src, gw_str, sizeof(gw_str)); + fprintf(fp, "from %s/%u ", gw_str, r->srclen); + } switch (r->nh_type) { case NH_ADDR: @@ -69,6 +73,8 @@ static void write_route(FILE *fp, struct route *r) else inet_ntop(AF_INET6, &r->gateway.gw6, gw_str, sizeof(gw_str)); fputs(gw_str, fp); + if (r->ifname[0]) + fprintf(fp, " %s", r->ifname); break; case NH_IFNAME: fputs(r->ifname, fp); From 688f57a413b4ba0cdf6c26b55ff12d9e3579f576 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 10:03:06 +0200 Subject: [PATCH 05/14] dhcpv6: send client routes through netd Routes from DHCPv6 and router advertisements went straight into the kernel, so they showed up as kernel routes, ignored the DHCPv6 route preference, and FRR could neither see nor redistribute them. odhcp6c now hands them to netd, the way the DHCPv4 client does, and like that client it starts only once netd runs. Fixes #1423 Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/libexec/odhcp6c.sh | 86 +++++++++++++++------- doc/ChangeLog.md | 6 ++ src/confd/src/dhcpv6-client.c | 2 +- 3 files changed, 67 insertions(+), 27 deletions(-) diff --git a/board/common/rootfs/usr/libexec/odhcp6c.sh b/board/common/rootfs/usr/libexec/odhcp6c.sh index 21ca00464..44dedfa8b 100755 --- a/board/common/rootfs/usr/libexec/odhcp6c.sh +++ b/board/common/rootfs/usr/libexec/odhcp6c.sh @@ -8,6 +8,8 @@ interface="$1" state="$2" RESOLV_CONF="/run/resolvconf/interfaces/${interface}-ipv6.conf" NTPFILE="/run/chrony/dhcp-sources.d/${interface}-ipv6.sources" +NAME="/etc/net.d/${interface}-dhcpv6.conf" +NEXT="/run/odhcp6c-${interface}.conf" # outside of netd's watched dir [ -n "$metric" ] || metric=5 @@ -28,10 +30,64 @@ err() teardown_interface() { - ip -6 route flush dev "$interface" ip -6 address flush dev "$interface" scope global } +# Routes go to netd, like DHCPv4 routes, so they are static routes with +# the configured route preference ($metric) as distance. The kernel +# metric from the RA is not used. +# +# add_route PREFIX NEXTHOP [INTERFACE] [SOURCE] +add_route() +{ + { + echo "route {" + echo " prefix = \"$1\"" + echo " nexthop = \"$2\"" + [ -n "$3" ] && echo " interface = \"$3\"" + [ -n "$4" ] && echo " source = \"$4\"" + echo " distance = $metric" + echo " tag = 100" + echo "}" + } >> "$NEXT" +} + +set_routes() +{ + echo "# Generated by odhcp6c" > "$NEXT" + + # $RA_ROUTES format: "prefix/len,gateway,valid,metric ..." + for entry in $RA_ROUTES; do + addr="${entry%%,*}" + entry="${entry#*,}" + gw="${entry%%,*}" + + if [ -z "$gw" ]; then + add_route "$addr" "$interface" + continue + fi + + add_route "$addr" "$gw" "$interface" + + # Same route for traffic sourced from each delegated prefix + for prefix in $PREFIXES; do + add_route "$addr" "$gw" "$interface" "${prefix%%,*}" + done + done + + # Unreachable route for delegated prefixes, prevents routing loops + for entry in $PREFIXES; do + add_route "${entry%%,*}" reject + done + + # Only touch netd's config when the routes changed + if cmp -s "$NAME" "$NEXT"; then + rm -f "$NEXT" + else + mv "$NEXT" "$NAME" + fi +} + setup_interface() { # Merge RA addresses with DHCP addresses @@ -57,28 +113,7 @@ setup_interface() log "assigned address $addr (preferred=$preferred, valid=$valid)" done - # Add routes from RA - for entry in $RA_ROUTES; do - addr="${entry%%,*}" - entry="${entry#*,}" - gw="${entry%%,*}" - entry="${entry#*,}" - valid="${entry%%,*}" - entry="${entry#*,}" - metric="${entry%%,*}" - - if [ -n "$gw" ]; then - ip -6 route add "$addr" via "$gw" metric "$metric" dev "$interface" from "::/128" - else - ip -6 route add "$addr" metric "$metric" dev "$interface" - fi - - # Add routes for delegated prefixes - for prefix in $PREFIXES; do - paddr="${prefix%%,*}" - [ -n "$gw" ] && ip -6 route add "$addr" via "$gw" metric "$metric" dev "$interface" from "$paddr" - done - done + set_routes } handle_prefixes() @@ -93,9 +128,6 @@ handle_prefixes() log "received delegated prefix $addr (preferred=$preferred, valid=$valid)" - # Add unreachable route to prevent routing loops - ip -6 route add unreachable "$addr" 2>/dev/null - # Future: Distribute to downstream interfaces done } @@ -200,6 +232,7 @@ log "state: $state" case "$state" in started) # Initial state - clean up any stale config + rm -f "$NAME" teardown_interface ;; @@ -222,6 +255,7 @@ log "state: $state" unbound|stopped) # Lost server or client stopped + rm -f "$NAME" teardown_interface rm -f "$RESOLV_CONF" rm -f "$NTPFILE" diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 837e15d2e..6d5fb9128 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -12,6 +12,12 @@ All notable changes to the project are documented in this file. ietf-rip and ietf-ospf models, selected per control-plane-protocol by the `ripng`/`ospfv3` type and the IPv6 address-family +### Fixes + +- Fix #1423: the default route from a DHCPv6 client, learned from router + advertisements, is now a static route with the DHCPv6 route preference, + like a DHCPv4 route. Before, the route preference setting was ignored + [v26.09.0][] - 2026-09-30 ------------------------- diff --git a/src/confd/src/dhcpv6-client.c b/src/confd/src/dhcpv6-client.c index 69746acf3..fc3ad9bae 100644 --- a/src/confd/src/dhcpv6-client.c +++ b/src/confd/src/dhcpv6-client.c @@ -116,7 +116,7 @@ static void add_v6(const char *ifname, struct lyd_node *cfg) fprintf(fp, "# Generated by Infix confd\n"); fprintf(fp, "metric=%s\n", metric); - fprintf(fp, "service name:dhcpv6-client :%s \\\n" + fprintf(fp, "service name:dhcpv6-client :%s \\\n" " [2345] odhcp6c -e -p /run/dhcpv6-client-%s.pid \\\n" " -s /usr/libexec/odhcp6c.sh \\\n" " %s %s%s%s \\\n" From 49b4f2f0e066470cd8fd7f017f734bd98990786e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 10:03:07 +0200 Subject: [PATCH 06/14] test: verify DHCPv6 routes are static routes The DHCPv6 tests only checked that a default route existed, which also held while it bypassed netd as a kernel route. Check that it is a static route with the DHCPv6 route preference, that a new preference reaches it, that it goes away with the client, and that a delegated prefix gets its unreachable route. Signed-off-by: Joachim Wiberg --- test/case/dhcp/client6_basic/test.adoc | 11 +++++++- test/case/dhcp/client6_basic/test.py | 26 +++++++++++++++++-- .../dhcp/client6_prefix_delegation/test.adoc | 4 +++ .../dhcp/client6_prefix_delegation/test.py | 22 ++++++++++++---- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/test/case/dhcp/client6_basic/test.adoc b/test/case/dhcp/client6_basic/test.adoc index f325230aa..092f2c430 100644 --- a/test/case/dhcp/client6_basic/test.adoc +++ b/test/case/dhcp/client6_basic/test.adoc @@ -7,6 +7,11 @@ ifdef::topdoc[:imagesdir: {topdoc}../../test/case/dhcp/client6_basic] Enable a DHCPv6 client and verify it requests an IPv6 lease from a DHCPv6 server that is then set on the interface. +The default route, learned from the router advertisement, must be a +static route with the DHCPv6 route preference, the same as a route +from a DHCPv4 server. A changed route preference must reach the route, +and the route must be removed with the client. + ==== Topology image::topology.svg[DHCPv6 Basic topology, align=center, scaledwidth=75%] @@ -17,7 +22,11 @@ image::topology.svg[DHCPv6 Basic topology, align=center, scaledwidth=75%] . Configure DHCPv6 client . Verify DHCPv6 client is running . Verify client lease for {CLIENT} -. Verify client default route ::/0 +. Verify client default route ::/0 is static with preference 5 . Verify client domain name resolution +. Set DHCPv6 route preference 20 +. Verify client default route ::/0 has preference 20 +. Remove DHCPv6 client +. Verify client default route ::/0 is removed diff --git a/test/case/dhcp/client6_basic/test.py b/test/case/dhcp/client6_basic/test.py index 45818bf0a..6e5874255 100755 --- a/test/case/dhcp/client6_basic/test.py +++ b/test/case/dhcp/client6_basic/test.py @@ -4,6 +4,11 @@ Enable a DHCPv6 client and verify it requests an IPv6 lease from a DHCPv6 server that is then set on the interface. +The default route, learned from the router advertisement, must be a +static route with the DHCPv6 route preference, the same as a route +from a DHCPv4 server. A changed route preference must reach the route, +and the route must be removed with the client. + """ import infamy @@ -73,11 +78,28 @@ def check_dns_resolution(): with test.step(f"Verify client lease for {CLIENT}"): until(lambda: iface.address_exist(client, port, CLIENT, prefix_length=128), attempts=30) - with test.step("Verify client default route ::/0"): - until(lambda: route.ipv6_route_exist(client, "::/0"), attempts=20) + with test.step("Verify client default route ::/0 is static with preference 5"): + until(lambda: route.ipv6_route_exist(client, "::/0", proto="ietf-routing:static", + pref=5, active_check=True), attempts=20) with test.step("Verify client domain name resolution"): # DNS configuration may take a moment, especially on ARM hardware until(check_dns_resolution, attempts=20) + with test.step("Set DHCPv6 route preference 20"): + config["interfaces"]["interface"][0]["ipv6"]["infix-dhcpv6-client:dhcp"]["route-preference"] = 20 + client.put_config_dicts({"ietf-interfaces": config}) + + with test.step("Verify client default route ::/0 has preference 20"): + until(lambda: route.ipv6_route_exist(client, "::/0", proto="ietf-routing:static", + pref=20, active_check=True), attempts=30) + + with test.step("Remove DHCPv6 client"): + client.delete_xpath(f"/ietf-interfaces:interfaces/interface[name='{port}']" + "/ietf-ip:ipv6/infix-dhcpv6-client:dhcp") + + with test.step("Verify client default route ::/0 is removed"): + until(lambda: not route.ipv6_route_exist(client, "::/0", proto="ietf-routing:static"), + attempts=30) + test.succeed() diff --git a/test/case/dhcp/client6_prefix_delegation/test.adoc b/test/case/dhcp/client6_prefix_delegation/test.adoc index 763c6a974..91a6aa332 100644 --- a/test/case/dhcp/client6_prefix_delegation/test.adoc +++ b/test/case/dhcp/client6_prefix_delegation/test.adoc @@ -8,6 +8,9 @@ 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. +The client must install an unreachable route for the delegated prefix, +as a static route, to prevent routing loops. + ==== Topology image::topology.svg[DHCPv6 Prefix Delegation topology, align=center, scaledwidth=75%] @@ -18,5 +21,6 @@ image::topology.svg[DHCPv6 Prefix Delegation topology, align=center, scaledwidth . Configure DHCPv6 client w/ prefix delegation . Verify DHCPv6 client is running . Verify prefix delegation in logs +. Verify unreachable route for the delegated prefix diff --git a/test/case/dhcp/client6_prefix_delegation/test.py b/test/case/dhcp/client6_prefix_delegation/test.py index c89cb287e..4d593970d 100755 --- a/test/case/dhcp/client6_prefix_delegation/test.py +++ b/test/case/dhcp/client6_prefix_delegation/test.py @@ -5,10 +5,14 @@ prefix from a DHCPv6 server. This is commonly used on WAN interfaces of routers to obtain a prefix for distribution to downstream networks. +The client must install an unreachable route for the delegated prefix, +as a static route, to prevent routing loops. + """ import infamy, infamy.dhcp import infamy.iface as iface +import infamy.route as route from infamy.util import parallel, until import time @@ -22,13 +26,16 @@ def checkrun(dut): return False +def delegated_prefix(dut): + """Delegated prefix from the client's log, or None""" + rc = dut.runsh("tail -50 /log/syslog | grep -o 'received delegated prefix [^ ]*'") + words = rc.stdout.split() + return words[-1] if words else None + + def checklog(dut): """Check syslog for prefix delegation message""" - rc = dut.runsh("tail -50 /log/syslog | grep 'received delegated prefix'") - # print(f"DHCPv6 client logs:\n{rc.stdout}") - if rc.stdout.strip() != "": - return True - return False + return delegated_prefix(dut) is not None with infamy.Test() as test: @@ -80,4 +87,9 @@ def checklog(dut): # Prefix delegation may take longer on ARM hardware until(lambda: checklog(tgtssh), attempts=30) + with test.step("Verify unreachable route for the delegated prefix"): + pd = delegated_prefix(tgtssh) + until(lambda: route.ipv6_route_exist(client, pd, proto="ietf-routing:static"), + attempts=30) + test.succeed() From 5a7161f6c4f67f5a30fd2cf0af81c128608adcbf Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 18:44:39 +0200 Subject: [PATCH 07/14] dhcpv6: keep the kernel's RA default route out of the way Infix accepts router advertisements on all interfaces, so the kernel adds a default route of its own next to the one the DHCPv6 client hands to netd. The kernel route always wins, and the DHCPv6 route preference never takes effect. While the client runs, it owns the default route on its interface. Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/libexec/odhcp6c.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/board/common/rootfs/usr/libexec/odhcp6c.sh b/board/common/rootfs/usr/libexec/odhcp6c.sh index 44dedfa8b..c5c7d4d64 100755 --- a/board/common/rootfs/usr/libexec/odhcp6c.sh +++ b/board/common/rootfs/usr/libexec/odhcp6c.sh @@ -231,8 +231,13 @@ log "state: $state" flock 9 case "$state" in started) - # Initial state - clean up any stale config + # Initial state - clean up any stale config. Our + # routes go through netd with the configured route + # preference, so the kernel must not add its own + # default route from router advertisements as well. rm -f "$NAME" + sysctl -w "net.ipv6.conf.$interface.accept_ra_defrtr=0" >/dev/null + ip -6 route flush dev "$interface" proto ra teardown_interface ;; From 3559d639de8c92d485a422d1893a7259b6d28b76 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 18:44:49 +0200 Subject: [PATCH 08/14] dhcpv6: drop the client's routes when it is removed odhcp6c runs its stopped hook in the background and exits, so the hook does not survive the service being stopped. The client's routes stayed, and the kernel no longer learned a default route from router advertisements on that interface. Signed-off-by: Joachim Wiberg --- src/confd/src/dhcpv6-client.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/confd/src/dhcpv6-client.c b/src/confd/src/dhcpv6-client.c index fc3ad9bae..c30300a8e 100644 --- a/src/confd/src/dhcpv6-client.c +++ b/src/confd/src/dhcpv6-client.c @@ -140,6 +140,15 @@ static void add_v6(const char *ifname, struct lyd_node *cfg) static void del_v6(const char *ifname) { finit_deletef("dhcpv6-client-%s", ifname); + + /* + * odhcp6c runs its "stopped" hook in the background and exits, + * so the hook does not survive the service being stopped. Drop + * the client's routes here, and let the kernel learn the default + * route from router advertisements again. + */ + erasef("/etc/net.d/%s-dhcpv6.conf", ifname); + writesf("1", "w", "/proc/sys/net/ipv6/conf/%s/accept_ra_defrtr", ifname); } int dhcpv6_client_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, From 248c64e4509ed3a21dca5e0c4754e0a637072394 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 18:44:49 +0200 Subject: [PATCH 09/14] board: enable PPP and PPPoE, add pppd to the images The PPPoE client needs both. pppd's own PPPoE plugin is the client, rp-pppoe is only needed for a server. The minimal images get it too, a small switch on a PPPoE uplink is a likely use. Signed-off-by: Joachim Wiberg --- board/aarch64/linux_defconfig | 2 ++ board/arm/linux_defconfig | 2 ++ board/riscv64/linux_defconfig | 2 ++ board/x86_64/linux_defconfig | 2 ++ configs/aarch64_defconfig | 1 + configs/aarch64_minimal_defconfig | 1 + configs/arm_defconfig | 1 + configs/arm_minimal_defconfig | 1 + configs/riscv64_defconfig | 1 + configs/x86_64_defconfig | 1 + configs/x86_64_minimal_defconfig | 1 + 11 files changed, 15 insertions(+) diff --git a/board/aarch64/linux_defconfig b/board/aarch64/linux_defconfig index b7c4fe051..43dedc1cc 100644 --- a/board/aarch64/linux_defconfig +++ b/board/aarch64/linux_defconfig @@ -397,6 +397,8 @@ CONFIG_MDIO_BITBANG=y CONFIG_MDIO_MVUSB=m CONFIG_MDIO_MSCC_MIIM=y CONFIG_MDIO_BUS_MUX_MMIOREG=y +CONFIG_PPP=m +CONFIG_PPPOE=m CONFIG_USB_RTL8150=m CONFIG_USB_RTL8152=m CONFIG_USB_LAN78XX=m diff --git a/board/arm/linux_defconfig b/board/arm/linux_defconfig index 3c0639ad7..411d9ff21 100644 --- a/board/arm/linux_defconfig +++ b/board/arm/linux_defconfig @@ -299,6 +299,8 @@ CONFIG_VETH=m CONFIG_VIRTIO_NET=y CONFIG_NLMON=y CONFIG_NET_VRF=y +CONFIG_PPP=m +CONFIG_PPPOE=m CONFIG_USB_USBNET=y CONFIG_INPUT_EVDEV=y # CONFIG_LEGACY_PTYS is not set diff --git a/board/riscv64/linux_defconfig b/board/riscv64/linux_defconfig index 080eca13e..05f3b4922 100644 --- a/board/riscv64/linux_defconfig +++ b/board/riscv64/linux_defconfig @@ -278,6 +278,8 @@ CONFIG_DWMAC_DWC_QOS_ETH=y CONFIG_DWMAC_STARFIVE=y CONFIG_MICROCHIP_PHY=y CONFIG_MOTORCOMM_PHY=y +CONFIG_PPP=m +CONFIG_PPPOE=m CONFIG_USB_RTL8150=m CONFIG_USB_RTL8152=m CONFIG_USB_LAN78XX=m diff --git a/board/x86_64/linux_defconfig b/board/x86_64/linux_defconfig index 4b9f5d050..f0e062714 100644 --- a/board/x86_64/linux_defconfig +++ b/board/x86_64/linux_defconfig @@ -275,6 +275,8 @@ CONFIG_E1000=y CONFIG_NE2K_PCI=y CONFIG_8139CP=y CONFIG_ROCKER=y +CONFIG_PPP=m +CONFIG_PPPOE=m # CONFIG_WLAN is not set CONFIG_INPUT_EVDEV=y CONFIG_SERIAL_8250=y diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig index 6d0d399e0..c99348609 100644 --- a/configs/aarch64_defconfig +++ b/configs/aarch64_defconfig @@ -92,6 +92,7 @@ BR2_PACKAGE_NMAP_NPING=y BR2_PACKAGE_ODHCP6C=y BR2_PACKAGE_OPENRESOLV=y BR2_PACKAGE_OPENSSH=y +BR2_PACKAGE_PPPD=y BR2_PACKAGE_SOCAT=y BR2_PACKAGE_TCPDUMP=y BR2_PACKAGE_TRACEROUTE=y diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig index 7bdd3f652..d3fb3d56c 100644 --- a/configs/aarch64_minimal_defconfig +++ b/configs/aarch64_minimal_defconfig @@ -77,6 +77,7 @@ BR2_PACKAGE_NGINX_HTTP_V2_MODULE=y BR2_PACKAGE_ODHCP6C=y BR2_PACKAGE_OPENRESOLV=y BR2_PACKAGE_OPENSSH=y +BR2_PACKAGE_PPPD=y BR2_PACKAGE_SOCAT=y BR2_PACKAGE_TCPDUMP=y BR2_PACKAGE_WHOIS=y diff --git a/configs/arm_defconfig b/configs/arm_defconfig index a48ad1138..0671a02d1 100644 --- a/configs/arm_defconfig +++ b/configs/arm_defconfig @@ -92,6 +92,7 @@ BR2_PACKAGE_NMAP_NPING=y BR2_PACKAGE_ODHCP6C=y BR2_PACKAGE_OPENRESOLV=y BR2_PACKAGE_OPENSSH=y +BR2_PACKAGE_PPPD=y BR2_PACKAGE_SOCAT=y BR2_PACKAGE_TCPDUMP=y BR2_PACKAGE_TRACEROUTE=y diff --git a/configs/arm_minimal_defconfig b/configs/arm_minimal_defconfig index 91cbef4b4..97827e3dc 100644 --- a/configs/arm_minimal_defconfig +++ b/configs/arm_minimal_defconfig @@ -79,6 +79,7 @@ BR2_PACKAGE_NGINX_HTTP_V2_MODULE=y BR2_PACKAGE_ODHCP6C=y BR2_PACKAGE_OPENRESOLV=y BR2_PACKAGE_OPENSSH=y +BR2_PACKAGE_PPPD=y BR2_PACKAGE_SOCAT=y BR2_PACKAGE_TCPDUMP=y BR2_PACKAGE_WHOIS=y diff --git a/configs/riscv64_defconfig b/configs/riscv64_defconfig index d2e3478bb..4b83addff 100644 --- a/configs/riscv64_defconfig +++ b/configs/riscv64_defconfig @@ -102,6 +102,7 @@ BR2_PACKAGE_NMAP_NPING=y BR2_PACKAGE_ODHCP6C=y BR2_PACKAGE_OPENRESOLV=y BR2_PACKAGE_OPENSSH=y +BR2_PACKAGE_PPPD=y BR2_PACKAGE_SOCAT=y BR2_PACKAGE_TCPDUMP=y BR2_PACKAGE_TRACEROUTE=y diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig index 5b58a3a36..dce7b64fa 100644 --- a/configs/x86_64_defconfig +++ b/configs/x86_64_defconfig @@ -91,6 +91,7 @@ BR2_PACKAGE_NMAP_NPING=y BR2_PACKAGE_ODHCP6C=y BR2_PACKAGE_OPENRESOLV=y BR2_PACKAGE_OPENSSH=y +BR2_PACKAGE_PPPD=y BR2_PACKAGE_SOCAT=y BR2_PACKAGE_TCPDUMP=y BR2_PACKAGE_TRACEROUTE=y diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig index e39d0eb15..3e46d7697 100644 --- a/configs/x86_64_minimal_defconfig +++ b/configs/x86_64_minimal_defconfig @@ -76,6 +76,7 @@ BR2_PACKAGE_NGINX_HTTP_V2_MODULE=y BR2_PACKAGE_ODHCP6C=y BR2_PACKAGE_OPENRESOLV=y BR2_PACKAGE_OPENSSH=y +BR2_PACKAGE_PPPD=y BR2_PACKAGE_SOCAT=y BR2_PACKAGE_TCPDUMP=y BR2_PACKAGE_WHOIS=y From 18954a850a829c39d19daa66817fb0bab89cea3c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 18:44:50 +0200 Subject: [PATCH 10/14] package/pppd: add attach-unit option Infix sets up routes, firewall rules, and interface settings when the configuration is applied, before any traffic flows. pppd creates its ppp interface when a session comes up and removes it when the session ends, so there is nothing to apply them to until then, and they are lost on every reconnect. With attach-unit, another process creates and owns the interface, and pppd attaches to it for each session. Signed-off-by: Joachim Wiberg --- .../0001-pppd-add-attach-unit-option.patch | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 patches/pppd/2.5.2/0001-pppd-add-attach-unit-option.patch diff --git a/patches/pppd/2.5.2/0001-pppd-add-attach-unit-option.patch b/patches/pppd/2.5.2/0001-pppd-add-attach-unit-option.patch new file mode 100644 index 000000000..a6a4c889a --- /dev/null +++ b/patches/pppd/2.5.2/0001-pppd-add-attach-unit-option.patch @@ -0,0 +1,132 @@ +From 20ce59191e695e4803b6965791759f95a37b69e4 Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Sat, 3 Oct 2026 21:58:08 +0200 +Subject: [PATCH] pppd: add attach-unit option +Organization: Wires + +A system that sets up routes, firewall rules, and interface settings +before traffic flows needs the interface to exist first, and to stay +across reconnects. pppd creates the ppp interface when a session comes +up and removes it when the session ends, so neither holds. + +The kernel removes a ppp interface when the file that created it is +closed, but any process can attach to an existing unit. With +attach-unit, another process creates and owns the interface, and pppd +attaches to it for each session instead of creating its own. The +interface then lives as long as its owner, not the session. + +--- + pppd/options.c | 5 +++++ + pppd/pppd-private.h | 1 + + pppd/pppd.8 | 8 ++++++++ + pppd/sys-linux.c | 17 +++++++++++++++++ + pppd/sys-solaris.c | 4 ++++ + 5 files changed, 35 insertions(+) + +diff --git a/pppd/options.c b/pppd/options.c +index 879223d..f6bedcf 100644 +--- a/pppd/options.c ++++ b/pppd/options.c +@@ -121,6 +121,7 @@ char linkname[MAXPATHLEN]; /* logical name for link */ + bool tune_kernel; /* may alter kernel settings */ + int connect_delay = 1000; /* wait this many ms after connect script */ + int req_unit = -1; /* requested interface unit */ ++int attach_unit = -1; /* existing interface unit to attach to */ + char path_net_init[MAXPATHLEN]; /* pathname of net-init script */ + char path_net_preup[MAXPATHLEN];/* pathname of net-pre-up script */ + char path_net_down[MAXPATHLEN]; /* pathname of net-down script */ +@@ -311,6 +312,10 @@ struct option general_options[] = { + "PPP interface unit number to use if possible", + OPT_PRIO | OPT_LLIMIT, 0, 0 }, + ++ { "attach-unit", o_int, &attach_unit, ++ "Attach to existing PPP interface unit, created by another process", ++ OPT_PRIO | OPT_LLIMIT, 0, 0 }, ++ + { "ifname", o_string, req_ifname, + "Set PPP interface name", + OPT_PRIO | OPT_PRIV | OPT_STATIC, NULL, IFNAMSIZ }, +diff --git a/pppd/pppd-private.h b/pppd/pppd-private.h +index d8ec443..fea9342 100644 +--- a/pppd/pppd-private.h ++++ b/pppd/pppd-private.h +@@ -196,6 +196,7 @@ extern bool tune_kernel; /* May alter kernel settings as necessary */ + extern int connect_delay; /* Time to delay after connect script */ + extern int max_data_rate; /* max bytes/sec through charshunt */ + extern int req_unit; /* interface unit number to use */ ++extern int attach_unit; /* existing interface unit to attach to */ + extern char path_net_init[]; /* pathname of net-init script */ + extern char path_net_preup[];/* pathname of net-pre-up script */ + extern char path_net_down[]; /* pathname of net-down script */ +diff --git a/pppd/pppd.8 b/pppd/pppd.8 +index 3a787a0..9bc8bb1 100644 +--- a/pppd/pppd.8 ++++ b/pppd/pppd.8 +@@ -1157,6 +1157,14 @@ name. Respective values allowed for this option is: \fInone\fR, \fIsubject\fR, + (EAP-TLS, or PEAP) Enables examination of peer certificate's purpose, and + extended key usage attributes. + .TP ++.B attach-unit \fInum ++Use the existing ppp interface with unit number \fInum\fR, created and owned ++by another process, instead of creating one. The interface then outlives the ++connection and pppd, so another process can create it and set up routes, ++firewall rules, and interface settings before the link comes up. Use with the ++\fIifname\fR option to give scripts the interface name. Cannot be combined ++with \fIdemand\fR or \fImultilink\fR. Only supported on Linux. ++.TP + .B unit \fInum + Sets the ppp unit number (for a ppp0 or ppp1 etc interface name) for outbound + connections. If the unit is already in use a dynamically allocated number will +diff --git a/pppd/sys-linux.c b/pppd/sys-linux.c +index b94f3ec..107bd66 100644 +--- a/pppd/sys-linux.c ++++ b/pppd/sys-linux.c +@@ -898,6 +898,18 @@ static int make_ppp_unit(void) + || fcntl(ppp_dev_fd, F_SETFL, flags | O_NONBLOCK) == -1) + warn("Couldn't set /dev/ppp to nonblock: %m"); + ++ /* ++ * The interface was created by another process, which owns it. ++ * Attach to it instead, it stays when we let go of it. ++ */ ++ if (attach_unit >= 0) { ++ ifunit = attach_unit; ++ x = ioctl(ppp_dev_fd, PPPIOCATTACH, &ifunit); ++ if (x < 0) ++ error("Couldn't attach to PPP unit %d: %m", ifunit); ++ return x; ++ } ++ + /* + * Via rtnetlink it is possible to create ppp network interface with + * custom ifname atomically. But it is not possible to specify custom +@@ -3705,6 +3717,11 @@ sys_check_options(void) + warn("Warning: multilink is not supported by the kernel driver"); + multilink = 0; + } ++ if (attach_unit >= 0 && (!new_style_driver || demand || multilink)) { ++ ppp_option_error("attach-unit cannot be used with demand, multilink, " ++ "or this kernel driver"); ++ return 0; ++ } + return 1; + } + +diff --git a/pppd/sys-solaris.c b/pppd/sys-solaris.c +index e442108..9f95f89 100644 +--- a/pppd/sys-solaris.c ++++ b/pppd/sys-solaris.c +@@ -638,6 +638,10 @@ ppp_sys_close(void) + int + sys_check_options(void) + { ++ if (attach_unit >= 0) { ++ ppp_option_error("attach-unit is not supported on this system"); ++ return 0; ++ } + return 1; + } + +-- +2.43.0 + From 6f9ea375120bee5ab1a4df066e61297c0aab28f3 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 18:44:50 +0200 Subject: [PATCH 11/14] test: add a PPPoE server for testing PPPoE clients A real PPPoE server needs /dev/ppp and PPP support in the kernel the test container runs on, which a rootless container never has. This one speaks just enough PPPoE and PPP from userspace, over a raw socket. Signed-off-by: Joachim Wiberg --- test/infamy/pppoe.py | 409 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 test/infamy/pppoe.py diff --git a/test/infamy/pppoe.py b/test/infamy/pppoe.py new file mode 100644 index 000000000..021581907 --- /dev/null +++ b/test/infamy/pppoe.py @@ -0,0 +1,409 @@ +"""Minimal PPPoE access concentrator for testing PPPoE clients + +A real PPPoE server needs /dev/ppp and PPP support in the kernel the +test container runs on, which a rootless container never gets. This +one speaks just enough PPPoE and PPP from userspace, over a raw socket +in an isolated network namespace, for a client to bring up a session: + + - PPPoE discovery: PADI/PADO, PADR/PADS, and PADT both ways + - LCP: configuration, echo, and termination + - PAP or CHAP-MD5 authentication against one user name and password + - IPCP: the client's address and primary DNS server + - IPv4: answers ICMP echo requests sent to the server's address, and + records the MSS of TCP SYNs, see Server.syn_mss() + +Anything else in a session is rejected with an LCP Protocol-Reject. + +Usage, in a test: + + with infamy.IsolatedMacVlan(port) as ns: + with infamy.pppoe.Server(ns, user="user", password="secret"): + ... + +The server serves one session at a time, a new PADR replaces it. +""" +import argparse +import hashlib +import os +import signal +import socket +import subprocess +import sys +import tempfile + +from scapy.layers.inet import IP, ICMP, TCP +from scapy.layers.l2 import Ether +from scapy.layers.ppp import PPPoED, PPPoED_Tags, PPPoETag, PPPoE, PPP, \ + PPP_LCP, PPP_LCP_Configure, PPP_LCP_Echo, PPP_LCP_Auth_Protocol_Option, \ + PPP_LCP_Magic_Number_Option, PPP_PAP_Request, PPP_PAP_Response, \ + PPP_CHAP, PPP_CHAP_ChallengeResponse, PPP_IPCP, PPP_IPCP_Option_IPAddress + +ETH_P_PPPOE_DISC = 0x8863 +ETH_P_PPPOE_SESS = 0x8864 + +PADI, PADO, PADR, PADS, PADT = 0x09, 0x07, 0x19, 0x65, 0xa7 +TAG_SERVICE_NAME, TAG_AC_NAME, TAG_HOST_UNIQ = 0x0101, 0x0102, 0x0103 + +PROTO_IPV4, PROTO_IPCP = 0x0021, 0x8021 +PROTO_LCP, PROTO_PAP, PROTO_CHAP = 0xc021, 0xc023, 0xc223 +RESEND = 1 # seconds between CHAP challenges + +CONF_REQ, CONF_ACK, CONF_NAK, CONF_REJ = 1, 2, 3, 4 +TERM_REQ, TERM_ACK, PROTO_REJ, ECHO_REQ, ECHO_REP = 5, 6, 8, 9, 10 + +IPCP_ADDR, IPCP_DNS1 = 3, 129 + + +class Server: + """Run the access concentrator in netns until stopped""" + + def __init__(self, netns, iface="iface", user="user", password="secret", + auth="pap", local="10.0.0.1", peer="10.0.0.100", + dns="192.0.2.53", ac_name="infamy"): + self.netns = netns + self.process = None + fd, self.stats = tempfile.mkstemp(prefix="pppoe-ac-") + os.close(fd) + self.args = [sys.executable, os.path.abspath(__file__), + "--iface", iface, "--user", user, "--password", password, + "--auth", auth, "--local", local, "--peer", peer, + "--dns", dns, "--ac-name", ac_name, "--stats", self.stats] + + def syn_mss(self): + """MSS of the latest TCP SYN received over the session, or None""" + with open(self.stats, encoding="utf-8") as f: + data = f.read().strip() + return int(data) if data else None + + def __enter__(self): + self.start() + return self + + def __exit__(self, _, __, ___): + self.stop() + + def start(self): + self.process = self.netns.popen(self.args) + + def stop(self): + """Stop the server, it sends PADT to end an open session""" + if self.process: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + self.process = None + + def __del__(self): + try: + os.unlink(self.stats) + except OSError: + pass + + +class AccessConcentrator: + """PPPoE and PPP state for one session""" + + SESSION_ID = 0x1234 + MAGIC = 0x1a2b3c4d + + def __init__(self, args): + self.args = args + self.sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, + socket.htons(0x0003)) + self.sock.bind((args.iface, 0)) + self.mac = self.sock.getsockname()[4] + self.ident = 0 + self.challenge = os.urandom(16) + self.reset() + + def reset(self): + self.client = None + self.lcp_up = False + self.authed = False + self.ipcp_acked = False + + def log(self, msg): + """Log as a TAP comment, the output ends up in the test's output""" + print(f"# pppoe-ac: {msg}", file=sys.stderr, flush=True) + + def next_id(self): + self.ident = (self.ident + 1) & 0xff + return self.ident + + # Discovery stage + + def send_disc(self, dst, code, tags, sessionid=0): + tag_list = [PPPoETag(tag_type=t, tag_value=v) for t, v in tags] + pkt = Ether(dst=dst, src=self.mac, type=ETH_P_PPPOE_DISC) / \ + PPPoED(code=code, sessionid=sessionid) / \ + PPPoED_Tags(tag_list=tag_list) + self.sock.send(bytes(pkt)) + + def discovery(self, pkt): + disc = pkt[PPPoED] + tags = [] + if disc.haslayer(PPPoED_Tags): + tags = [(t.tag_type, bytes(t.tag_value or b"")) + for t in disc[PPPoED_Tags].tag_list] + + # Echo the client's service name and host-uniq, add our AC-Name + reply = [(TAG_AC_NAME, self.args.ac_name.encode())] + reply += [t for t in tags if t[0] in (TAG_SERVICE_NAME, TAG_HOST_UNIQ)] + if not any(t[0] == TAG_SERVICE_NAME for t in reply): + reply.append((TAG_SERVICE_NAME, b"")) + + if disc.code == PADI: + self.log(f"PADI from {pkt.src}, sending PADO") + self.send_disc(pkt.src, PADO, reply) + elif disc.code == PADR: + self.log(f"PADR from {pkt.src}, session {self.SESSION_ID:#x} up") + self.reset() + self.client = pkt.src + self.send_disc(pkt.src, PADS, reply, self.SESSION_ID) + self.send_lcp_conf_req() + elif disc.code == PADT and pkt.src == self.client: + self.log("PADT from client, session down") + self.reset() + + def padt(self): + if self.client: + self.send_disc(self.client, PADT, [], self.SESSION_ID) + self.reset() + + # Session stage + + def send_ppp(self, proto, payload): + pkt = Ether(dst=self.client, src=self.mac, type=ETH_P_PPPOE_SESS) / \ + PPPoE(sessionid=self.SESSION_ID) / PPP(proto=proto) / payload + self.sock.send(bytes(pkt)) + + def send_lcp_conf_req(self): + if self.args.auth == "chap": + auth = PPP_LCP_Auth_Protocol_Option(auth_protocol=PROTO_CHAP, + algorithm=5) + else: + auth = PPP_LCP_Auth_Protocol_Option(auth_protocol=PROTO_PAP) + + opts = [auth, PPP_LCP_Magic_Number_Option(magic_number=self.MAGIC)] + self.send_ppp(PROTO_LCP, PPP_LCP_Configure(code=CONF_REQ, + id=self.next_id(), + options=opts)) + + def lcp(self, raw): + code, ident = raw[0], raw[1] + if code == CONF_REQ: + # Accept whatever the client asks for, echo it back as an Ack + ack = bytes([CONF_ACK]) + raw[1:] + self.send_ppp(PROTO_LCP, PPP_LCP(ack)) + # Our request may have gone out before the client listened, + # send it again until the client acknowledges it + if not self.lcp_up: + self.send_lcp_conf_req() + elif code == CONF_ACK: + if self.lcp_up: + return + self.lcp_up = True + self.log(f"LCP up, authenticating with {self.args.auth.upper()}") + if self.args.auth == "chap": + self.chap_id = self.next_id() + self.send_chap_challenge() + elif code in (CONF_NAK, CONF_REJ): + self.send_lcp_conf_req() + elif code == ECHO_REQ: + echo = PPP_LCP_Echo(code=ECHO_REP, id=ident, + magic_number=self.MAGIC, data=raw[8:]) + self.send_ppp(PROTO_LCP, echo) + elif code == TERM_REQ: + self.log("LCP Terminate-Request from client") + self.send_ppp(PROTO_LCP, PPP_LCP(bytes([TERM_ACK, ident, 0, 4]))) + self.lcp_up = self.authed = False + + def auth_result(self, ok, proto, ident): + # A client that missed our reply asks again, answer it again + # without starting over + repeat = ok and self.authed + msg = b"Welcome" if ok else b"Go away" + if proto == PROTO_PAP: + pkt = PPP_PAP_Response(code=2 if ok else 3, id=ident, + message=msg) + else: + pkt = PPP_CHAP(code=3 if ok else 4, id=ident, data=msg) + self.send_ppp(proto, pkt) + + if repeat: + return + if ok: + self.log("authenticated, starting IPCP") + self.authed = True + self.send_ipcp_conf_req() + else: + self.log("authentication failed, terminating") + self.send_ppp(PROTO_LCP, bytes([TERM_REQ, self.next_id(), 0, 4])) + + def pap(self, raw): + if raw[0] != 1: + return + req = PPP_PAP_Request(raw) + user = bytes(req.username).decode(errors="replace") + password = bytes(req.password).decode(errors="replace") + ok = user == self.args.user and password == self.args.password + if not self.authed: + self.log(f"PAP from {user}: {'ok' if ok else 'wrong credentials'}") + self.auth_result(ok, PROTO_PAP, req.id) + + def send_chap_challenge(self): + self.send_ppp(PROTO_CHAP, PPP_CHAP_ChallengeResponse( + code=1, id=self.chap_id, value=self.challenge, + optional_name=self.args.ac_name.encode())) + + def chap(self, raw): + if raw[0] != 2: + return + resp = PPP_CHAP_ChallengeResponse(raw) + user = bytes(resp.optional_name).decode(errors="replace") + want = hashlib.md5(bytes([resp.id]) + self.args.password.encode() + + self.challenge).digest() + ok = user == self.args.user and bytes(resp.value) == want + if not self.authed: + self.log(f"CHAP from {user}: {'ok' if ok else 'wrong credentials'}") + self.auth_result(ok, PROTO_CHAP, resp.id) + + def send_ipcp_conf_req(self): + opts = [PPP_IPCP_Option_IPAddress(data=self.args.local)] + self.send_ppp(PROTO_IPCP, PPP_IPCP(code=CONF_REQ, id=self.next_id(), + options=opts)) + + def ipcp(self, raw): + if not self.authed: + return + + code, ident = raw[0], raw[1] + if code != CONF_REQ: + if code == CONF_ACK and not self.ipcp_acked: + self.ipcp_acked = True + self.log("IPCP: our address acknowledged") + return + + # Like for LCP, our request may have been sent too early + if not self.ipcp_acked: + self.send_ipcp_conf_req() + + want = {IPCP_ADDR: socket.inet_aton(self.args.peer), + IPCP_DNS1: socket.inet_aton(self.args.dns)} + reject, nak = b"", b"" + opts = raw[4:int.from_bytes(raw[2:4], "big")] + while len(opts) >= 2: + typ, length = opts[0], opts[1] + if length < 2: + break + opt, opts = opts[:length], opts[length:] + if typ not in want: + reject += opt + elif opt[2:] != want[typ]: + nak += bytes([typ, 6]) + want[typ] + + if reject: + reply = bytes([CONF_REJ, ident]) + (4 + len(reject)).to_bytes(2, "big") + reject + elif nak: + reply = bytes([CONF_NAK, ident]) + (4 + len(nak)).to_bytes(2, "big") + nak + else: + if self.ipcp_acked: + self.log(f"IPCP up, client {self.args.peer}, DNS {self.args.dns}") + reply = bytes([CONF_ACK]) + raw[1:] + self.send_ppp(PROTO_IPCP, reply) + + def tcp_syn(self, tcp): + for opt, val in tcp.options: + if opt == "MSS": + self.log(f"TCP SYN to port {tcp.dport}, MSS {val}") + with open(self.args.stats, "w", encoding="utf-8") as f: + f.write(f"{val}\n") + + def ipv4(self, raw): + ip = IP(raw) + if ip.haslayer(TCP) and ip[TCP].flags.S: + self.tcp_syn(ip[TCP]) + return + if ip.dst != self.args.local or not ip.haslayer(ICMP) or ip[ICMP].type != 8: + return + icmp = ip[ICMP] + reply = IP(src=ip.dst, dst=ip.src) / \ + ICMP(type=0, id=icmp.id, seq=icmp.seq) / bytes(icmp.payload) + self.send_ppp(PROTO_IPV4, reply) + + def session(self, pkt): + if pkt.src != self.client or pkt[PPPoE].sessionid != self.SESSION_ID: + return + + raw = bytes(pkt[PPPoE].payload)[:pkt[PPPoE].len] + if len(raw) < 2: + return + proto, data = int.from_bytes(raw[:2], "big"), raw[2:] + + if proto == PROTO_LCP: + self.lcp(data) + elif proto == PROTO_PAP: + self.pap(data) + elif proto == PROTO_CHAP: + self.chap(data) + elif proto == PROTO_IPCP: + self.ipcp(data) + elif proto == PROTO_IPV4: + self.ipv4(data) + elif self.lcp_up: + rej = bytes([PROTO_REJ, self.next_id()]) + \ + (6 + len(data)).to_bytes(2, "big") + raw[:2] + data + self.send_ppp(PROTO_LCP, rej) + + def run(self): + self.log(f"serving on {self.args.iface}, {self.args.auth.upper()} " + f"user {self.args.user}") + self.sock.settimeout(RESEND) + while True: + try: + frame, addr = self.sock.recvfrom(2048) + except socket.timeout: + # The authenticator resends the CHAP challenge, the + # client may not have been listening for the first one + if self.lcp_up and not self.authed and self.args.auth == "chap": + self.send_chap_challenge() + continue + etype = int.from_bytes(frame[12:14], "big") + if addr[2] == socket.PACKET_OUTGOING or \ + etype not in (ETH_P_PPPOE_DISC, ETH_P_PPPOE_SESS): + continue + pkt = Ether(frame) + if pkt.type == ETH_P_PPPOE_DISC and pkt.haslayer(PPPoED): + self.discovery(pkt) + elif pkt.type == ETH_P_PPPOE_SESS and pkt.haslayer(PPPoE): + self.session(pkt) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--iface", required=True) + parser.add_argument("--user", required=True) + parser.add_argument("--password", required=True) + parser.add_argument("--auth", choices=["pap", "chap"], required=True) + parser.add_argument("--local", required=True) + parser.add_argument("--peer", required=True) + parser.add_argument("--dns", required=True) + parser.add_argument("--ac-name", required=True) + parser.add_argument("--stats", default=os.devnull, + help="File to write the MSS of received TCP SYNs to") + ac = AccessConcentrator(parser.parse_args()) + + def stop(*_): + ac.padt() + sys.exit(0) + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + ac.run() + + +if __name__ == "__main__": + main() From 1e566c18bb86f6a17a27ea4d34b04f586c2d86b5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 18:44:50 +0200 Subject: [PATCH 12/14] confd: add PPPoE client Many ISPs still connect their customers with PPPoE. A session is an interface of type pppoe on top of the interface facing the provider. The type derives from IANA ppp, so other PPP transports can later share the common ppp settings. The kernel removes a ppp interface when the file that created it is closed, so the interface needs a process to own it. dagger creates it with pppmon, which only holds it, and pppd runs as a Finit service that attaches to it. The interface then exists as soon as it is configured, is set up like any other, and stays across reconnects, while Finit still supervises pppd. The default route and DNS servers go through netd and resolvconf like those from DHCP. The TCP MSS of forwarded connections is clamped to the session MTU, since PPPoE's lower MTU otherwise stalls TCP wherever ICMP is blocked. Fixes #1569 Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 7 + doc/iface.md | 1 + doc/keystore.md | 2 +- doc/networking.md | 1 + doc/pppoe.md | 95 ++++++ mkdocs.yml | 1 + package/confd/confd.mk | 16 + package/confd/ppp/ip-down | 10 + package/confd/ppp/ip-up | 34 ++ package/confd/ppp/modules.conf | 3 + package/confd/ppp/pppd@.conf | 4 + src/confd/bin/Makefile.am | 4 + src/confd/bin/pppmon.c | 229 +++++++++++++ src/confd/src/Makefile.am | 1 + src/confd/src/core.c | 19 ++ src/confd/src/if-ppp.c | 304 ++++++++++++++++++ src/confd/src/interfaces.c | 25 +- src/confd/src/interfaces.h | 8 + src/confd/yang/confd.inc | 4 +- src/confd/yang/confd/infix-if-ppp.yang | 147 +++++++++ .../yang/confd/infix-if-ppp@2026-10-03.yang | 1 + src/confd/yang/confd/infix-if-type.yang | 15 + ...-07.yang => infix-if-type@2026-10-03.yang} | 0 src/confd/yang/confd/infix-interfaces.yang | 6 + ....yang => infix-interfaces@2026-10-03.yang} | 0 src/confd/yang/ppp.inc | 5 + .../python/yanger/ietf_interfaces/link.py | 3 + 27 files changed, 939 insertions(+), 6 deletions(-) create mode 100644 doc/pppoe.md create mode 100755 package/confd/ppp/ip-down create mode 100755 package/confd/ppp/ip-up create mode 100644 package/confd/ppp/modules.conf create mode 100644 package/confd/ppp/pppd@.conf create mode 100644 src/confd/bin/pppmon.c create mode 100644 src/confd/src/if-ppp.c create mode 100644 src/confd/yang/confd/infix-if-ppp.yang create mode 120000 src/confd/yang/confd/infix-if-ppp@2026-10-03.yang rename src/confd/yang/confd/{infix-if-type@2026-01-07.yang => infix-if-type@2026-10-03.yang} (100%) rename src/confd/yang/confd/{infix-interfaces@2026-09-28.yang => infix-interfaces@2026-10-03.yang} (100%) create mode 100644 src/confd/yang/ppp.inc diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 6d5fb9128..32fa9417a 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -11,6 +11,11 @@ All notable changes to the project are documented in this file. - Add IPv6 dynamic routing: RIPng and OSPFv3. Both reuse the existing ietf-rip and ietf-ospf models, selected per control-plane-protocol by the `ripng`/`ospfv3` type and the IPv6 address-family +- Add PPPoE client, issue #1569. A PPPoE session is an interface of type + `pppoe` on top of the interface facing the provider, with the password in + the keystore. Its default route and DNS servers are used like those from + a DHCP server, and the TCP MSS of forwarded connections is clamped to the + session MTU, see [PPPoE Client][pppoe] ### Fixes @@ -18,6 +23,8 @@ All notable changes to the project are documented in this file. advertisements, is now a static route with the DHCPv6 route preference, like a DHCPv4 route. Before, the route preference setting was ignored +[pppoe]: https://www.kernelkit.org/infix/latest/pppoe/ + [v26.09.0][] - 2026-09-30 ------------------------- diff --git a/doc/iface.md b/doc/iface.md index ba76e6607..5fe8eac62 100644 --- a/doc/iface.md +++ b/doc/iface.md @@ -41,6 +41,7 @@ Available types can be listed from the CLI: lag IEEE link aggregate interface. loopback Linux loopback interface. other Other interface, i.e., unknown. + pppoe PPP over Ethernet (PPPoE) client session. veth Linux virtual Ethernet pair. vlan Layer 2 Virtual LAN using 802.1Q. vxlan Virtual eXtensible LAN tunnel interface. diff --git a/doc/keystore.md b/doc/keystore.md index 6d5bb858f..b97559c84 100644 --- a/doc/keystore.md +++ b/doc/keystore.md @@ -31,7 +31,7 @@ managed via CLI, NETCONF, or RESTCONF. | **Symmetric Key Format** | **Use Case** | |-----------------------------|-----------------------------------| -| `passphrase-key-format` | Human-readable passphrases (WiFi) | +| `passphrase-key-format` | Human-readable passphrases (WiFi, PPPoE) | | `octet-string-key-format` | Raw symmetric keys (WireGuard) | ## Asymmetric Keys diff --git a/doc/networking.md b/doc/networking.md index cb799a9d8..e63dd1e72 100644 --- a/doc/networking.md +++ b/doc/networking.md @@ -53,6 +53,7 @@ other traffic would be bridged as usual. | [eth](ethernet.md#physical-ethernet-interfaces) | ieee802-ethernet-interface | Physical Ethernet device/port | | | infix-ethernet-interface | | | [veth](ethernet.md#veth-pairs) | infix-if-veth | Virtual Ethernet pair, typically one end is in a container | +| [pppoe](pppoe.md) | infix-if-ppp | PPPoE client session on an Ethernet or VLAN interface | | [*common*](iface.md) | ietf-interfaces, | Properties common to all interface types | | | infix-interfaces | | diff --git a/doc/pppoe.md b/doc/pppoe.md new file mode 100644 index 000000000..fab8b9b47 --- /dev/null +++ b/doc/pppoe.md @@ -0,0 +1,95 @@ +# PPPoE Client + +Many Internet service providers connect their customers with PPP over +Ethernet (PPPoE, RFC 2516). The device then logs in with a user name +and password, and gets its IPv4 address and DNS servers from the +provider's server. + +A PPPoE client session is an interface of type `pppoe`, stacked on top +of the Ethernet interface, or VLAN, that connects to the provider. The +interface exists as soon as it is configured, but is down until the +device has logged in, and goes down again when the session ends. The +client keeps trying to log in until it succeeds, and logs in again when +a session is lost. + +## Configuration + +The password is stored in the [keystore](keystore.md), as a symmetric +key with `passphrase-key-format`: + +
admin@example:/> configure
+admin@example:/config/> edit keystore symmetric-key isp
+admin@example:/config/keystore/…/isp/> set key-format passphrase-key-format
+admin@example:/config/keystore/…/isp/> edit cleartext-symmetric-key
+Passphrase: ********
+Retype passphrase: ********
+admin@example:/config/keystore/…/isp/> end
+
+ +Then create the PPPoE interface on top of the interface facing the +provider, here `eth0`. The settings common to all PPP links, the user +name and password, are in `ppp`, and the PPPoE specific ones in `pppoe`: + +
admin@example:/config/> edit interface pppoe0
+admin@example:/config/interface/pppoe0/> set pppoe lower-layer-if eth0
+admin@example:/config/interface/pppoe0/> set ppp username user@isp.example
+admin@example:/config/interface/pppoe0/> set ppp secret isp
+admin@example:/config/interface/pppoe0/> show
+type pppoe;
+ppp {
+  username user@isp.example;
+  secret isp;
+}
+pppoe {
+  lower-layer-if eth0;
+}
+admin@example:/config/interface/pppoe0/> leave
+
+ +> [!TIP] +> If you name your PPPoE interface `pppoeN`, where `N` is a number, the +> CLI infers the interface type automatically. Any other name, e.g., +> `wan`, works too, with an explicit `set type pppoe`. + +The password may not contain control characters, `"`, or `\`. + +Some providers run several services, or several servers, on the same +network. Set `service-name` or `ac-name` to only connect to a given +service, or a given access concentrator. + +## Default Route and DNS + +By default the session installs a default route, with route preference +5, the same as a route learned from a DHCP server. Change it with `ppp +route-preference`, or turn the route off with `ppp default-route false`, +e.g., when the PPPoE session is a backup for another uplink. + +The DNS servers from the provider are used by default. Set `ppp +peer-dns false` to use only the DNS servers configured on the device. + +## TCP MSS Clamping + +PPPoE takes 8 bytes of every Ethernet frame, so the session MTU is 1492 +bytes, lower than the 1500 bytes of the hosts on the local network. +Hosts rely on path MTU discovery to adjust, which fails where ICMP is +blocked on the way, and their TCP connections then stall on large +packets. + +To avoid that, the device clamps the maximum segment size (MSS) in the +handshake of every TCP connection it forwards through the session, to +fit the session MTU. This does not depend on the firewall being +enabled. Set `pppoe mss-clamping false` to turn it off. + +## IPv6 + +When IPv6 is enabled on the PPP interface the session also negotiates +IPv6, which gives the interface a link-local address. Global addresses +and delegated prefixes come from the provider's router advertisements +and DHCPv6 server, enable the [DHCPv6 client](ip.md) on the PPP +interface to use them. + +## Forwarding + +To route traffic between the local network and the provider, enable +IPv4 (and IPv6) forwarding on the PPPoE interface and on the local +interfaces, see [IP Addressing](ip.md). diff --git a/mkdocs.yml b/mkdocs.yml index ef2e4ea47..b6e965375 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,6 +34,7 @@ nav: - Link Aggregation: lag.md - Ethernet Interfaces: ethernet.md - VLAN Interfaces: vlan.md + - PPPoE Client: pppoe.md - IP Addressing: ip.md - Routing: routing.md - Firewall Configuration: firewall.md diff --git a/package/confd/confd.mk b/package/confd/confd.mk index 1394511ca..c6169687b 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -124,6 +124,20 @@ define CONFD_INSTALL_YANG_MODULES_CONTAINERS $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/containers.inc endef endif +ifeq ($(BR2_PACKAGE_PPPD),y) +define CONFD_INSTALL_PPP + $(INSTALL) -D -m 0644 $(CONFD_PKGDIR)/ppp/pppd@.conf $(FINIT_D)/available/pppd@.conf + $(INSTALL) -D -m 0644 $(CONFD_PKGDIR)/ppp/modules.conf $(TARGET_DIR)/etc/modules-load.d/ppp.conf + $(INSTALL) -d -m 0755 $(TARGET_DIR)/etc/ppp/peers + for script in ip-up ip-down; do \ + $(INSTALL) -D -m 0755 $(CONFD_PKGDIR)/ppp/$$script $(TARGET_DIR)/etc/ppp/$$script; \ + done +endef +define CONFD_INSTALL_YANG_MODULES_PPP + $(COMMON_SYSREPO_ENV) \ + $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/ppp.inc +endef +endif ifeq ($(BR2_PACKAGE_FEATURE_WIFI),y) define CONFD_INSTALL_YANG_MODULES_WIFI $(COMMON_SYSREPO_ENV) \ @@ -179,6 +193,8 @@ CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_NETCONF_SERVER CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_NETCONF_SERVER CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_CONTAINERS +CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_PPP +CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_PPP CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WIFI CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_GPS CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WEBUI diff --git a/package/confd/ppp/ip-down b/package/confd/ppp/ip-down new file mode 100755 index 000000000..bdb04cb1a --- /dev/null +++ b/package/confd/ppp/ip-down @@ -0,0 +1,10 @@ +#!/bin/sh +# The session is gone, so are its route and DNS servers. +# ip-down IFNAME TTY SPEED LOCAL REMOTE IPPARAM + +rm -f "/etc/net.d/$1-ppp.conf" +if [ -f "/run/resolvconf/interfaces/$1.conf" ]; then + rm -f "/run/resolvconf/interfaces/$1.conf" + resolvconf -u +fi +exit 0 diff --git a/package/confd/ppp/ip-up b/package/confd/ppp/ip-up new file mode 100755 index 000000000..a6f5529d3 --- /dev/null +++ b/package/confd/ppp/ip-up @@ -0,0 +1,34 @@ +#!/bin/sh +# The default route goes to netd, like DHCP routes, and the peer DNS +# servers to resolvconf. +# ip-up IFNAME TTY SPEED LOCAL REMOTE IPPARAM + +ifname=$1 +routes="/etc/net.d/$ifname-ppp.conf" +dns="/run/resolvconf/interfaces/$ifname.conf" + +. "/etc/default/pppd-$ifname" 2>/dev/null || exit 0 + +if [ "$DEFAULT_ROUTE" = 1 ]; then + next="/run/ppp-$ifname.conf" + cat > "$next" <<-END + # Generated by pppd ip-up + route { + prefix = "0.0.0.0/0" + nexthop = "$ifname" + distance = $ROUTE_PREFERENCE + tag = 100 + } + END + mv "$next" "$routes" +fi + +# pppd only sets these with usepeerdns, i.e., when peer-dns is enabled +if [ -n "$DNS1$DNS2" ]; then + mkdir -p "$(dirname "$dns")" + for ns in $DNS1 $DNS2; do + echo "nameserver $ns # $ifname" + done > "$dns" + resolvconf -u +fi +exit 0 diff --git a/package/confd/ppp/modules.conf b/package/confd/ppp/modules.conf new file mode 100644 index 000000000..bb0555f06 --- /dev/null +++ b/package/confd/ppp/modules.conf @@ -0,0 +1,3 @@ +# PPP and PPPoE, pppd needs /dev/ppp before it can start a session +ppp_generic +pppoe diff --git a/package/confd/ppp/pppd@.conf b/package/confd/ppp/pppd@.conf new file mode 100644 index 000000000..3f7dc44b7 --- /dev/null +++ b/package/confd/ppp/pppd@.conf @@ -0,0 +1,4 @@ +# PPP interface %i, held by pppmon, options in /etc/ppp/peers/%i +service name:pppd :%i env:/etc/default/pppd-%i \ + [2345] pppd call %i file /run/pppmon-%i.opts ifname %i linkname %i nodetach \ + -- PPP client @%i diff --git a/src/confd/bin/Makefile.am b/src/confd/bin/Makefile.am index 9df1ebd62..4b2ad2767 100644 --- a/src/confd/bin/Makefile.am +++ b/src/confd/bin/Makefile.am @@ -1,3 +1,7 @@ pkglibexec_SCRIPTS = gen-config gen-hostname gen-interfaces gen-motd gen-hardware \ gen-version mstpd-wait-online wait-interface +pkglibexec_PROGRAMS = pppmon +pppmon_SOURCES = pppmon.c +pppmon_CFLAGS = $(libite_CFLAGS) +pppmon_LDADD = $(libite_LIBS) sbin_SCRIPTS = dagger migrate firewall diff --git a/src/confd/bin/pppmon.c b/src/confd/bin/pppmon.c new file mode 100644 index 000000000..f1477de07 --- /dev/null +++ b/src/confd/bin/pppmon.c @@ -0,0 +1,229 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ +/* + * pppmon - create and hold a PPP interface + * + * The kernel removes a ppp interface when the file that created it is + * closed, so the interface needs a process to own it. pppmon creates + * the interface and holds it until SIGTERM. pppd, run by Finit, + * attaches to it for each session, so the interface exists before the + * first session and stays across reconnects. + * + * pppmon [-o OPTFILE] [-p PIDFILE] IFNAME + * + * OPTFILE gets the pppd option to attach to the interface, for pppd's + * file option. pppmon returns once the interface exists and both files + * are written, then holds the interface in the background. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void addattr(struct nlmsghdr *nlh, int type, const void *data, size_t len) +{ + struct rtattr *rta = (struct rtattr *)((char *)nlh + NLMSG_ALIGN(nlh->nlmsg_len)); + + rta->rta_type = type; + rta->rta_len = RTA_LENGTH(len); + if (len) + memcpy(RTA_DATA(rta), data, len); + nlh->nlmsg_len = NLMSG_ALIGN(nlh->nlmsg_len) + RTA_ALIGN(rta->rta_len); +} + +static struct rtattr *nest_start(struct nlmsghdr *nlh, int type) +{ + struct rtattr *nest = (struct rtattr *)((char *)nlh + NLMSG_ALIGN(nlh->nlmsg_len)); + + addattr(nlh, type, NULL, 0); + return nest; +} + +static void nest_end(struct nlmsghdr *nlh, struct rtattr *nest) +{ + nest->rta_len = (char *)nlh + nlh->nlmsg_len - (char *)nest; +} + +/* Create a ppp interface named ifname for the /dev/ppp file fd */ +static int ppp_create(int fd, const char *ifname) +{ + struct { + struct nlmsghdr nlh; + struct ifinfomsg ifm; + char buf[256]; + } req = { 0 }; + struct rtattr *linkinfo, *data; + char ack[512]; + int sd, err = EIO; + + req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(req.ifm)); + req.nlh.nlmsg_type = RTM_NEWLINK; + req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL; + req.ifm.ifi_family = AF_UNSPEC; + + addattr(&req.nlh, IFLA_IFNAME, ifname, strlen(ifname) + 1); + linkinfo = nest_start(&req.nlh, IFLA_LINKINFO); + addattr(&req.nlh, IFLA_INFO_KIND, "ppp", 4); + data = nest_start(&req.nlh, IFLA_INFO_DATA); + addattr(&req.nlh, IFLA_PPP_DEV_FD, &fd, sizeof(fd)); + nest_end(&req.nlh, data); + nest_end(&req.nlh, linkinfo); + + sd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE); + if (sd < 0) + return -1; + + /* The kernel may ask us to retry, see ppp_nl_newlink() */ + do { + struct nlmsgerr *nlerr; + ssize_t len; + + if (send(sd, &req, req.nlh.nlmsg_len, 0) < 0) + break; + len = recv(sd, ack, sizeof(ack), 0); + if (len < (ssize_t)NLMSG_LENGTH(sizeof(*nlerr))) + break; + + nlerr = NLMSG_DATA((struct nlmsghdr *)ack); + err = -nlerr->error; + } while (err == EBUSY); + + close(sd); + if (err) { + errno = err; + return -1; + } + + return 0; +} + +/* + * Like daemon(), but the parent only returns when the child has written + * its pidfile, so the caller can signal it from then on. + */ +static int background(const char *pidfn) +{ + int fd, pfd[2]; + pid_t pid; + char c; + + if (pipe(pfd)) + return -1; + + pid = fork(); + if (pid < 0) + return -1; + if (pid > 0) { + close(pfd[1]); + /* EOF when the child is ready */ + _exit(read(pfd[0], &c, 1) == 0 ? 0 : 1); + } + + close(pfd[0]); + setsid(); + if (chdir("/")) + return -1; + fd = open("/dev/null", O_RDWR); + if (fd >= 0) { + dup2(fd, STDIN_FILENO); + dup2(fd, STDOUT_FILENO); + dup2(fd, STDERR_FILENO); + if (fd > STDERR_FILENO) + close(fd); + } + + /* Removed again at exit */ + if (pidfn && pidfile(pidfn)) + syslog(LOG_ERR, "failed writing %s: %m", pidfn); + close(pfd[1]); + + return 0; +} + +static int usage(int rc) +{ + fprintf(stderr, "usage: pppmon [-o OPTFILE] [-p PIDFILE] IFNAME\n"); + return rc; +} + +int main(int argc, char *argv[]) +{ + const char *optfn = NULL, *pidfn = NULL, *ifname; + int c, fd, sig, unit; + sigset_t set; + + while ((c = getopt(argc, argv, "ho:p:")) != EOF) { + switch (c) { + case 'h': + return usage(0); + case 'o': + optfn = optarg; + break; + case 'p': + pidfn = optarg; + break; + default: + return usage(1); + } + } + if (optind != argc - 1) + return usage(1); + + ifname = argv[optind]; + openlog("pppmon", LOG_PID | LOG_PERROR, LOG_DAEMON); + + fd = open("/dev/ppp", O_RDWR); + if (fd < 0) { + syslog(LOG_ERR, "%s: failed opening /dev/ppp: %m", ifname); + return 1; + } + if (ppp_create(fd, ifname) || ioctl(fd, PPPIOCGUNIT, &unit)) { + syslog(LOG_ERR, "%s: failed creating interface: %m", ifname); + return 1; + } + + if (optfn) { + FILE *fp = fopen(optfn, "w"); + + if (!fp) { + syslog(LOG_ERR, "%s: failed writing %s: %m", ifname, optfn); + return 1; + } + fprintf(fp, "attach-unit %d\n", unit); + fclose(fp); + } + + /* The interface exists, let the caller configure it */ + closelog(); + openlog("pppmon", LOG_PID, LOG_DAEMON); + if (background(pidfn)) { + syslog(LOG_ERR, "%s: failed going to background: %m", ifname); + return 1; + } + syslog(LOG_INFO, "%s: created, unit %d", ifname, unit); + + /* A stray SIGHUP must not take the interface with it */ + signal(SIGHUP, SIG_IGN); + sigemptyset(&set); + sigaddset(&set, SIGTERM); + sigaddset(&set, SIGINT); + sigprocmask(SIG_BLOCK, &set, NULL); + sigwait(&set, &sig); + + if (optfn) + erase(optfn); + syslog(LOG_INFO, "%s: removed", ifname); + + /* Closing the last reference to fd removes the interface */ + return 0; +} diff --git a/src/confd/src/Makefile.am b/src/confd/src/Makefile.am index 6ea4efd2a..c04fbcfeb 100644 --- a/src/confd/src/Makefile.am +++ b/src/confd/src/Makefile.am @@ -50,6 +50,7 @@ confd_plugin_la_SOURCES = \ if-vxlan.c \ if-wifi.c \ if-wireguard.c \ + if-ppp.c \ keystore.c \ system.c \ support.c \ diff --git a/src/confd/src/core.c b/src/confd/src/core.c index 7ebf70d51..7cf4345b6 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -325,6 +325,25 @@ static confd_dependency_t dep_symmetric_keys(struct lyd_node **diff, struct lyd_ } } ly_set_free(ifaces, NULL); + + ifaces = lydx_find_xpathf(config, + "/ietf-interfaces:interfaces/interface[infix-interfaces:ppp/secret='%s']", key_name); + if (ifaces && ifaces->count > 0) { + for (i = 0; i < ifaces->count; i++) { + const char *ifname = lydx_get_cattr(ifaces->dnodes[i], "name"); + char xpath[256]; + + snprintf(xpath, sizeof(xpath), + "/ietf-interfaces:interfaces/interface[name='%s']/infix-interfaces:ppp/secret", ifname); + result = add_dependencies(diff, xpath, key_name); + if (result == CONFD_DEP_ERROR) { + ERROR("Failed to add ppp to diff for interface %s", ifname); + ly_set_free(ifaces, NULL); + return result; + } + } + } + ly_set_free(ifaces, NULL); } return result; diff --git a/src/confd/src/if-ppp.c b/src/confd/src/if-ppp.c new file mode 100644 index 000000000..dc345184b --- /dev/null +++ b/src/confd/src/if-ppp.c @@ -0,0 +1,304 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ +/* + * PPP links, so far only PPPoE client sessions + * + * The kernel removes a ppp interface when the file that created it is + * closed, so the interface needs a process to own it: pppmon. dagger + * starts pppmon when the interface is created, before the rest of its + * setup, and stops it when the interface is deleted. The interface is + * then configured like any other, except for what pppd owns: its link + * state and MTU. + * + * pppd is a Finit service, pppd@IFNAME, that attaches to the interface + * held by pppmon, using the unit pppmon writes to its options file. + * confd writes the peers file for pppd, and an env file for the service + * that pppd's ip-up script reads too. ip-up hands the default route to + * netd and the peer DNS servers to resolvconf. Finit restarts pppd when + * the env file changes, and confd touches the service when the peers + * file does, since pppd only reads its options at start. + * + * TCP MSS clamping for a PPPoE session is an nftables table of its own, + * matching the interface by name, so it does not depend on the firewall + * being enabled. + */ + +#include + +#include +#include + +#include "interfaces.h" +#include "base64.h" + +#define PPP_PEERS "/etc/ppp/peers/%s" +#define PPP_SETTINGS "/etc/default/pppd-%s" +#define PPP_NFT "/etc/ppp/%s.nft" +#define PPPD_PID "/run/pppd/ppp-%s.pid" /* from pppd's linkname */ +#define PPPMON_OPTS "/run/pppmon-%s.opts" +#define PPPMON_PID "/run/pppmon-%s.pid" + +/* + * The password is written in double quotes into the peers file, so it + * cannot hold a '"', '\', or control characters. Returns the decoded + * password, or NULL with an error message when it is missing or bad. + */ +static char *ppp_secret(sr_session_ctx_t *session, struct lyd_node *cif) +{ + const char *ifname = lydx_get_cattr(cif, "name"); + const char *name, *b64; + struct lyd_node *key; + unsigned char *pw; + size_t len; + + name = lydx_get_cattr(lydx_get_child(cif, "ppp"), "secret"); + key = lydx_get_xpathf(cif, "../../keystore/symmetric-keys/symmetric-key[name='%s']", name); + b64 = lydx_get_cattr(key, "cleartext-symmetric-key"); + if (!b64 || !*b64) { + if (session) + sr_session_set_error_message(session, "%s: PPP secret \"%s\" has no cleartext value", + ifname, name); + return NULL; + } + + pw = base64_decode((const unsigned char *)b64, strlen(b64), &len); + if (!pw) + return NULL; + + for (size_t i = 0; i < len; i++) { + if (iscntrl(pw[i]) || pw[i] == '"' || pw[i] == '\\') { + if (session) + sr_session_set_error_message(session, "%s: PPP password may not contain " + "control characters, '\"', or '\\'", ifname); + free(pw); + return NULL; + } + } + + return (char *)pw; +} + +int ppp_validate_secret(sr_session_ctx_t *session, struct lyd_node *cif) +{ + char *pw; + + pw = ppp_secret(session, cif); + if (!pw) + return SR_ERR_VALIDATION_FAILED; + + free(pw); + return SR_ERR_OK; +} + +static int ppp_gen_peers(struct lyd_node *cif, const char *pw) +{ + const char *ifname = lydx_get_cattr(cif, "name"); + struct lyd_node *ppp, *pppoe, *ipv6; + const char *val; + mode_t oldmask; + FILE *fp; + + ppp = lydx_get_child(cif, "ppp"); + pppoe = lydx_get_child(cif, "pppoe"); + ipv6 = lydx_get_child(cif, "ipv6"); + + oldmask = umask(0077); + fp = fopenf("w", PPP_PEERS "+", ifname); + umask(oldmask); + if (!fp) + return -EIO; + + fprintf(fp, "# Generated by Infix confd\n"); + fprintf(fp, "plugin pppoe.so\n"); + fprintf(fp, "nic-%s\n", lydx_get_cattr(pppoe, "lower-layer-if")); + if ((val = lydx_get_cattr(pppoe, "service-name"))) + fprintf(fp, "pppoe-service \"%s\"\n", val); + if ((val = lydx_get_cattr(pppoe, "ac-name"))) + fprintf(fp, "pppoe-ac \"%s\"\n", val); + fprintf(fp, "user \"%s\"\n", lydx_get_cattr(ppp, "username")); + fprintf(fp, "password \"%s\"\n", pw); + fprintf(fp, "hide-password\n"); + fprintf(fp, "noauth\n"); + fprintf(fp, "noipdefault\n"); + /* Routes go through netd, see /etc/ppp/ip-up */ + fprintf(fp, "nodefaultroute\n"); + if (lydx_is_enabled(ppp, "peer-dns")) + fprintf(fp, "usepeerdns\n"); + if (ipv6 && lydx_is_enabled(ipv6, "enabled")) + fprintf(fp, "+ipv6\n"); + else + fprintf(fp, "noipv6\n"); + /* Keep trying, the server or the uplink may come back */ + fprintf(fp, "persist\n"); + fprintf(fp, "maxfail 0\n"); + fprintf(fp, "holdoff 5\n"); + fprintf(fp, "lcp-echo-interval 10\n"); + fprintf(fp, "lcp-echo-failure 3\n"); + fclose(fp); + + return 0; +} + +static int ppp_gen_settings(struct lyd_node *cif) +{ + const char *ifname = lydx_get_cattr(cif, "name"); + struct lyd_node *ppp = lydx_get_child(cif, "ppp"); + FILE *fp; + + fp = fopenf("w", PPP_SETTINGS "+", ifname); + if (!fp) + return -EIO; + + fprintf(fp, "# Generated by Infix confd, read by pppd@%s and /etc/ppp/ip-up\n", ifname); + fprintf(fp, "DEFAULT_ROUTE=%d\n", lydx_is_enabled(ppp, "default-route")); + fprintf(fp, "ROUTE_PREFERENCE=%s\n", lydx_get_cattr(ppp, "route-preference")); + fclose(fp); + + return 0; +} + +/* Replace file with file+ if they differ, returns 1 if it did */ +static int ppp_update(const char *fmt, const char *ifname) +{ + char path[256], next[260]; + int changed; + + snprintf(path, sizeof(path), fmt, ifname); + snprintf(next, sizeof(next), "%s+", path); + + changed = systemf("cmp -s %s %s", path, next) != 0; + if (changed) + rename(next, path); + else + remove(next); + + return changed; +} + +/* + * Clamp the MSS of TCP connections forwarded through the session, both + * directions, to the route MTU. The table is replaced as a whole, nft + * runs the file as one transaction. + */ +static int ppp_gen_mss(struct lyd_node *cif) +{ + const char *ifname = lydx_get_cattr(cif, "name"); + FILE *fp; + + fp = fopenf("w", PPP_NFT, ifname); + if (!fp) + return -EIO; + + fprintf(fp, "# Generated by Infix confd\n"); + fprintf(fp, "add table inet pppoe-%s\n", ifname); + fprintf(fp, "delete table inet pppoe-%s\n", ifname); + fprintf(fp, "table inet pppoe-%s {\n", ifname); + fprintf(fp, "\tchain mss-clamping {\n"); + fprintf(fp, "\t\ttype filter hook forward priority mangle; policy accept;\n"); + fprintf(fp, "\t\toifname \"%s\" tcp flags syn / syn,rst tcp option maxseg size set rt mtu\n", ifname); + fprintf(fp, "\t\tiifname \"%s\" tcp flags syn / syn,rst tcp option maxseg size set rt mtu\n", ifname); + fprintf(fp, "\t}\n"); + fprintf(fp, "}\n"); + fclose(fp); + + return 0; +} + +int ppp_gen(sr_session_ctx_t *session, struct lyd_node *dif, struct lyd_node *cif, + struct dagger *net) +{ + const char *ifname = lydx_get_cattr(cif, "name"); + int restart; + FILE *sh; + char *pw; + int err; + + pw = ppp_secret(session, cif); + if (!pw) + return -EINVAL; + + err = ppp_gen_peers(cif, pw); + free(pw); + err = err ? : ppp_gen_settings(cif); + err = err ? : ppp_gen_mss(cif); + if (err) + return err; + + /* Finit restarts pppd on its own when the env file changes */ + ppp_update(PPP_SETTINGS, ifname); + restart = ppp_update(PPP_PEERS, ifname); + + if (lydx_is_enabled(cif, "enabled")) { + finit_enablef("pppd@%s", ifname); + if (restart) + finit_reloadf("pppd@%s", ifname); + } else { + finit_disablef("pppd@%s", ifname); + } + + /* Runs before the rest of the interface setup, which needs it */ + sh = dagger_fopen_net_init(net, ifname, NETDAG_INIT_PRE, "pppmon.sh"); + if (!sh) + return -EIO; + + /* pppmon goes to the background once the interface exists */ + if (lydx_get_op(dif) == LYDX_OP_CREATE) + fprintf(sh, "/usr/libexec/confd/pppmon -o " PPPMON_OPTS " -p " PPPMON_PID + " %s || exit 1\n", ifname, ifname, ifname); + + if (lydx_is_enabled(lydx_get_child(cif, "pppoe"), "mss-clamping")) + fprintf(sh, "nft -f " PPP_NFT "\n", ifname); + else + fprintf(sh, "nft delete table inet pppoe-%s 2>/dev/null\n", ifname); + fclose(sh); + + return 0; +} + +/* Wait for the process in pidfile to exit */ +static void ppp_wait(FILE *sh, const char *pidfn) +{ + fprintf(sh, "if pid=$(cat %s 2>/dev/null); then\n" + "\twhile kill -0 $pid 2>/dev/null; do sleep 0.1; done\n" + "fi\n", pidfn); +} + +int ppp_del(struct lyd_node *dif, struct dagger *net) +{ + const char *ifname = lydx_get_cattr(dif, "name"); + char pppd[64], pppmon[64]; + FILE *sh; + + snprintf(pppd, sizeof(pppd), PPPD_PID, ifname); + snprintf(pppmon, sizeof(pppmon), PPPMON_PID, ifname); + + sh = dagger_fopen_net_exit(net, ifname, NETDAG_EXIT_DAEMON, "pppmon.sh"); + if (!sh) + return -EIO; + + finit_disablef("pppd@%s", ifname); + + /* pppd hangs up first, then the interface goes with pppmon */ + fprintf(sh, "initctl -bnq stop pppd:%s\n", ifname); + ppp_wait(sh, pppd); + fprintf(sh, "kill $(cat %s) 2>/dev/null\n", pppmon); + ppp_wait(sh, pppmon); + fprintf(sh, "nft delete table inet pppoe-%s 2>/dev/null\n", ifname); + fprintf(sh, "rm -f " PPP_PEERS " " PPP_SETTINGS " " PPP_NFT "\n", + ifname, ifname, ifname); + fclose(sh); + + return 0; +} + +int ppp_add_deps(struct lyd_node *cif) +{ + const char *lower; + int err; + + lower = lydx_get_cattr(lydx_get_child(cif, "pppoe"), "lower-layer-if"); + err = dagger_add_dep(&confd.netdag, lydx_get_cattr(cif, "name"), lower); + if (err) + return ERR_IFACE(cif, err, "Unable to depend on \"%s\"", lower); + + return 0; +} diff --git a/src/confd/src/interfaces.c b/src/confd/src/interfaces.c index 822600426..4f085c2a3 100644 --- a/src/confd/src/interfaces.c +++ b/src/confd/src/interfaces.c @@ -134,6 +134,8 @@ static int ifchange_cand_infer_type(sr_session_ctx_t *session, const char *path) inferred.data.string_val = "infix-if-type:vxlan"; else if (!fnmatch("wg+([0-9])", ifname, FNM_EXTMATCH)) inferred.data.string_val = "infix-if-type:wireguard"; + else if (!fnmatch("pppoe+([0-9])", ifname, FNM_EXTMATCH)) + inferred.data.string_val = "infix-if-type:pppoe"; free(ifname); @@ -426,6 +428,8 @@ static int netdag_gen_afspec_add(sr_session_ctx_t *session, struct dagger *net, ? : wireguard_gen(NULL, cif, ip, net); case IFT_ETH: return netdag_gen_ethtool(net, cif, dif); + case IFT_PPPOE: + return ppp_gen(session, dif, cif, net); case IFT_LO: return 0; @@ -459,6 +463,8 @@ static int netdag_gen_afspec_set(sr_session_ctx_t *session, struct dagger *net, if (wifi_get_mode(cif) == wifi_mesh) return wifi_gen_mesh(cif); return 0; + case IFT_PPPOE: + return ppp_gen(session, dif, cif, net); case IFT_DUMMY: case IFT_GRE: case IFT_GRETAP: @@ -504,6 +510,8 @@ static bool netdag_must_del(struct lyd_node *dif, struct lyd_node *cif) return lydx_get_descendant(lyd_child(dif), "vxlan", NULL); case IFT_WIREGUARD: return lydx_get_descendant(lyd_child(dif), "wireguard", NULL); + case IFT_PPPOE: + return false; case IFT_UNKNOWN: ERR_IFACE(cif, -EINVAL, "unsupported interface type \"%s\"", lydx_get_cattr(cif, "type")); @@ -601,6 +609,9 @@ static int netdag_gen_iface_del(struct dagger *net, struct lyd_node *dif, case IFT_UNKNOWN: link_gen_del(dif, ip); break; + case IFT_PPPOE: + ppp_del(dif, net); + break; } fclose(ip); @@ -651,6 +662,7 @@ static sr_error_t netdag_gen_iface(sr_session_ctx_t *session, struct dagger *net const char *ifname = lydx_get_cattr(dif, "name"); const char *iftype = lydx_get_cattr(dif, "type")?:lydx_get_cattr(cif, "type"); enum lydx_op op = lydx_get_op(dif); + bool pppd_owned; /* link state and MTU */ const char *attr; int err = 0; FILE *ip; @@ -716,7 +728,8 @@ static sr_error_t netdag_gen_iface(sr_session_ctx_t *session, struct dagger *net goto err_close_ip; } - fprintf(ip, "link set dev %s down", ifname); + pppd_owned = iftype_from_iface(cif) == IFT_PPPOE; + fprintf(ip, "link set dev %s%s", ifname, pppd_owned ? "" : " down"); /* Set generic link attributes */ err = err ? : netdag_gen_ipv4_autoconf(net, cif, dif); @@ -742,7 +755,8 @@ static sr_error_t netdag_gen_iface(sr_session_ctx_t *session, struct dagger *net } /* Set Addresses */ - err = err ? : netdag_gen_link_mtu(ip, dif); + if (!pppd_owned) + err = err ? : netdag_gen_link_mtu(ip, dif); err = err ? : netdag_gen_link_addr(ip, cif, dif); err = err ? : netdag_gen_ip_addrs(net, ip, "ipv4", cif, dif); err = err ? : netdag_gen_ip_addrs(net, ip, "ipv6", cif, dif); @@ -756,7 +770,7 @@ static sr_error_t netdag_gen_iface(sr_session_ctx_t *session, struct dagger *net fprintf(ip, "link set alias \"%s\" dev %s\n", attr ?: "", ifname); /* Bring interface back up, if enabled */ - if (lydx_is_enabled(cif, "enabled")) + if (lydx_is_enabled(cif, "enabled") && !pppd_owned) fprintf(ip, "link set dev %s up state up\n", ifname); err = err ? : netdag_gen_sysctl(net, cif, dif); @@ -797,6 +811,8 @@ static int netdag_init_iface(struct lyd_node *cif) case IFT_WIREGUARD: case IFT_UNKNOWN: break; + case IFT_PPPOE: + return ppp_add_deps(cif); } return 0; @@ -912,6 +928,9 @@ int interfaces_validate_keys(sr_session_ctx_t *session, struct lyd_node *config) case IFT_WIREGUARD: rc = wireguard_validate_peers(session, iface); break; + case IFT_PPPOE: + rc = ppp_validate_secret(session, iface); + break; default: rc = SR_ERR_OK; break; diff --git a/src/confd/src/interfaces.h b/src/confd/src/interfaces.h index 3ff5063e2..aa7743f39 100644 --- a/src/confd/src/interfaces.h +++ b/src/confd/src/interfaces.h @@ -34,6 +34,7 @@ _map(IFT_VXLAN, "infix-if-type:vxlan") \ _map(IFT_WIFI, "infix-if-type:wifi") \ _map(IFT_WIREGUARD,"infix-if-type:wireguard") \ + _map(IFT_PPPOE, "infix-if-type:pppoe") \ /* */ enum iftype { @@ -168,6 +169,13 @@ int ifchange_cand_infer_dhcp(sr_session_ctx_t *session, const char *path); /* if-vxlan.c */ int vxlan_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip); +/* if-ppp.c, PPP links, so far only PPPoE */ +int ppp_validate_secret(sr_session_ctx_t *session, struct lyd_node *cif); +int ppp_gen(sr_session_ctx_t *session, struct lyd_node *dif, struct lyd_node *cif, + struct dagger *net); +int ppp_del(struct lyd_node *dif, struct dagger *net); +int ppp_add_deps(struct lyd_node *cif); + /* infix-if-wireguard */ int wireguard_validate_peers(sr_session_ctx_t *session, struct lyd_node *cif); int wireguard_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip, struct dagger *net); diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 55a8a4cac..2c360c6a9 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -30,7 +30,7 @@ MODULES=( "infix-hardware@2026-09-24.yang" "ieee802-dot1q-types@2022-10-29.yang" "infix-ip@2026-04-28.yang" - "infix-if-type@2026-01-07.yang" + "infix-if-type@2026-10-03.yang" "infix-routing@2026-07-22.yang" "ieee802-dot1ab-lldp@2022-03-15.yang" "infix-lldp@2025-05-05.yang" @@ -48,7 +48,7 @@ MODULES=( "ieee802-ethernet-phy-type@2025-09-10.yang" "infix-ethernet-interface@2026-05-21.yang" "infix-factory-default@2023-06-28.yang" - "infix-interfaces@2026-09-28.yang -e vlan-filtering" + "infix-interfaces@2026-10-03.yang -e vlan-filtering" "ietf-crypto-types -e cleartext-symmetric-keys" "infix-crypto-types@2026-02-14.yang" "ietf-keystore -e symmetric-keys" diff --git a/src/confd/yang/confd/infix-if-ppp.yang b/src/confd/yang/confd/infix-if-ppp.yang new file mode 100644 index 000000000..b309dfd12 --- /dev/null +++ b/src/confd/yang/confd/infix-if-ppp.yang @@ -0,0 +1,147 @@ +submodule infix-if-ppp { + yang-version 1.1; + belongs-to infix-interfaces { + prefix infix-if; + } + + import ietf-interfaces { + prefix if; + } + import iana-if-type { + prefix ianaift; + } + import ietf-keystore { + prefix ks; + } + import infix-if-type { + prefix infixift; + } + + organization "KernelKit"; + contact "kernelkit@googlegroups.com"; + description + "PPP interfaces, so far only PPPoE client sessions. + + Settings common to all PPP links, authentication, default route, + and DNS, apply to every interface type derived from iana-if-type + ppp. Settings for the PPPoE transport only apply to type pppoe. + + The interface is down, without addresses, until a session comes up. + Its IPv4 address and peer DNS servers then come from the server, + IPv6 is negotiated when enabled on the interface, the DHCPv6 client + can then run on top."; + + revision 2026-10-03 { + description "Initial revision, PPPoE client."; + reference "internal"; + } + + feature ppp { + description "PPP support is an optional build-time feature in Infix."; + } + + typedef ppp-string { + type string { + length "1..64"; + pattern '[^\x00-\x1f\x22\x5c\x7f]*'; + } + description "Anything except control characters, '\"', and '\\'."; + } + + augment "/if:interfaces/if:interface" { + when "derived-from-or-self(if:type, 'ianaift:ppp')" { + description "Only shown for PPP interface types"; + } + if-feature ppp; + description "Settings common to all PPP links."; + + container ppp { + description "PPP link settings, common to all PPP transports."; + + leaf username { + type ppp-string; + mandatory true; + description "User name to authenticate with, PAP or CHAP."; + } + + leaf secret { + type ks:central-symmetric-key-ref; + mandatory true; + description + "Password to authenticate with. + + References a symmetric key in the keystore, its cleartext + value is the password. Control characters, '\"', and '\\' + are not allowed."; + } + + leaf default-route { + type boolean; + default true; + description "Install a default route through the link."; + } + + leaf route-preference { + type uint8 { + range "1..255"; + } + default 5; + description + "Preference (administrative distance) of the default route, the + same default as for DHCP routes. 255 means the route is never + used."; + } + + leaf peer-dns { + type boolean; + default true; + description "Use the DNS servers the peer provides."; + } + } + } + + augment "/if:interfaces/if:interface" { + when "derived-from-or-self(if:type, 'infixift:pppoe')" { + description "Only shown for if:type pppoe"; + } + if-feature ppp; + description "PPPoE transport settings."; + + container pppoe { + description "PPP over Ethernet (RFC 2516) client session."; + + leaf lower-layer-if { + type if:interface-ref; + must "deref(.)/../if:name != current()/../../if:name" { + error-message "A PPPoE interface cannot run on top of itself."; + } + mandatory true; + description "Ethernet interface, or VLAN, to discover the server on."; + } + + leaf service-name { + type ppp-string; + description "Only connect to a server offering this service."; + } + + leaf ac-name { + type ppp-string; + description "Only connect to the access concentrator with this name."; + } + + leaf mss-clamping { + type boolean; + default true; + description + "Clamp the TCP MSS of forwarded connections to the MTU of the + session. + + PPPoE takes 8 bytes of every Ethernet frame, so the session + MTU is lower than that of the hosts behind the router. Hosts + rely on path MTU discovery to adjust, which fails where ICMP + is blocked, and their TCP connections then stall on large + packets. Clamping the MSS in the TCP handshake avoids that."; + } + } + } +} diff --git a/src/confd/yang/confd/infix-if-ppp@2026-10-03.yang b/src/confd/yang/confd/infix-if-ppp@2026-10-03.yang new file mode 120000 index 000000000..d23e24fd4 --- /dev/null +++ b/src/confd/yang/confd/infix-if-ppp@2026-10-03.yang @@ -0,0 +1 @@ +infix-if-ppp.yang \ No newline at end of file diff --git a/src/confd/yang/confd/infix-if-type.yang b/src/confd/yang/confd/infix-if-type.yang index 3674376a8..edf4faf1a 100644 --- a/src/confd/yang/confd/infix-if-type.yang +++ b/src/confd/yang/confd/infix-if-type.yang @@ -11,6 +11,11 @@ module infix-if-type { contact "kernelkit@googlegroups.com"; description "Infix extensions to IANA interfaces types"; + revision 2026-10-03 { + description "Add interface type pppoe."; + reference "internal"; + } + revision 2026-01-07 { description "Add interface type wifi and wireguard"; reference "internal"; @@ -51,6 +56,10 @@ module infix-if-type { description "WiFi support is an optional build-time feature in Infix."; } + feature ppp { + description "PPP support is an optional build-time feature in Infix."; + } + /* * Identities */ @@ -121,6 +130,12 @@ module infix-if-type { base ianaift:l2vlan; description "Layer 2 Virtual LAN using 802.1Q."; } + identity pppoe { + if-feature ppp; + base infix-interface-type; + base ianaift:ppp; + description "PPP over Ethernet (PPPoE) client session."; + } identity wifi { if-feature wifi; base infix-interface-type; diff --git a/src/confd/yang/confd/infix-if-type@2026-01-07.yang b/src/confd/yang/confd/infix-if-type@2026-10-03.yang similarity index 100% rename from src/confd/yang/confd/infix-if-type@2026-01-07.yang rename to src/confd/yang/confd/infix-if-type@2026-10-03.yang diff --git a/src/confd/yang/confd/infix-interfaces.yang b/src/confd/yang/confd/infix-interfaces.yang index 2c11068fb..2e51ba0ea 100644 --- a/src/confd/yang/confd/infix-interfaces.yang +++ b/src/confd/yang/confd/infix-interfaces.yang @@ -35,12 +35,18 @@ module infix-interfaces { include infix-if-vxlan; include infix-if-wifi; include infix-if-wireguard; + include infix-if-ppp; include infix-if-ptp; organization "KernelKit"; contact "kernelkit@googlegroups.com"; description "Linux bridge and lag extensions for ietf-interfaces."; + revision 2026-10-03 { + description "Add PPPoE client interfaces."; + reference "internal"; + } + revision 2026-09-28 { description "Constrain the character set of interface names."; reference "internal"; diff --git a/src/confd/yang/confd/infix-interfaces@2026-09-28.yang b/src/confd/yang/confd/infix-interfaces@2026-10-03.yang similarity index 100% rename from src/confd/yang/confd/infix-interfaces@2026-09-28.yang rename to src/confd/yang/confd/infix-interfaces@2026-10-03.yang diff --git a/src/confd/yang/ppp.inc b/src/confd/yang/ppp.inc new file mode 100644 index 000000000..30d4ba4f0 --- /dev/null +++ b/src/confd/yang/ppp.inc @@ -0,0 +1,5 @@ +# -*- sh -*- +MODULES=( + "infix-if-type -e ppp" + "infix-interfaces -e ppp" +) diff --git a/src/statd/python/yanger/ietf_interfaces/link.py b/src/statd/python/yanger/ietf_interfaces/link.py index f5ef6a7ca..1cbcdbc3a 100644 --- a/src/statd/python/yanger/ietf_interfaces/link.py +++ b/src/statd/python/yanger/ietf_interfaces/link.py @@ -59,6 +59,9 @@ def iplink2yang_type(iplink): return "infix-if-type:loopback" case "gre"|"gre6": return "infix-if-type:gre" + case "ppp": + # PPPoE is the only PPP transport, the kernel cannot tell + return "infix-if-type:pppoe" case "ether": data = HOST.run(tuple(f"ls /sys/class/net/{ifname}/wireless/".split()), default="no") if data != "no": From 1c587de4c1aaac644963d896ae93cf66bdb41ea7 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 23:33:38 +0200 Subject: [PATCH 13/14] statd: answer requests for the routing interfaces statd only provided /ietf-routing:routing/ribs, so sysrepo never asked it about anything else in the routing tree. The interfaces list, which yanger produced along with the routes, showed up when the whole tree was requested, but a request for /ietf-routing:routing/interfaces came back empty. Provide each part on its own path, with yanger producing only the part asked for, so a request for the whole tree does not get any part twice. Signed-off-by: Joachim Wiberg --- src/statd/python/yanger/__main__.py | 2 +- src/statd/python/yanger/ietf_routing.py | 34 ++++++++---------- src/statd/statd.c | 48 ++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 22 deletions(-) diff --git a/src/statd/python/yanger/__main__.py b/src/statd/python/yanger/__main__.py index 3a2799b47..3347a7f0e 100644 --- a/src/statd/python/yanger/__main__.py +++ b/src/statd/python/yanger/__main__.py @@ -92,7 +92,7 @@ def main(): yang_data = ietf_interfaces.operational(param) elif model == 'ietf-routing': from . import ietf_routing - yang_data = ietf_routing.operational() + yang_data = ietf_routing.operational(param) elif model == 'ietf-ospf': from . import ietf_ospf yang_data = ietf_ospf.operational() diff --git a/src/statd/python/yanger/ietf_routing.py b/src/statd/python/yanger/ietf_routing.py index 9c55dfc20..eea45429e 100644 --- a/src/statd/python/yanger/ietf_routing.py +++ b/src/statd/python/yanger/ietf_routing.py @@ -166,27 +166,21 @@ def get_routing_interfaces(): return routing_ifaces -def operational(): - out = { - "ietf-routing:routing": { - "interfaces": { - "interface": get_routing_interfaces() - }, - "ribs": { - "rib": [{ - "name": "ipv4", - "address-family": "ipv4" - }, { - "name": "ipv6", - "address-family": "ipv6" - }] - } +def operational(part=None): + """All of the routing tree, or only its "interfaces" or "ribs" part""" + routing = {} + out = {"ietf-routing:routing": routing} + + if part in (None, "interfaces"): + routing["interfaces"] = { + "interface": get_routing_interfaces() } - } - ipv4routes = out['ietf-routing:routing']['ribs']['rib'][0] - ipv6routes = out['ietf-routing:routing']['ribs']['rib'][1] - add_protocol(ipv4routes, "ipv4") - add_protocol(ipv6routes, "ipv6") + if part in (None, "ribs"): + ipv4routes = {"name": "ipv4", "address-family": "ipv4"} + ipv6routes = {"name": "ipv6", "address-family": "ipv6"} + add_protocol(ipv4routes, "ipv4") + add_protocol(ipv6routes, "ipv6") + routing["ribs"] = {"rib": [ipv4routes, ipv6routes]} return out diff --git a/src/statd/statd.c b/src/statd/statd.c index c3fbef096..d859df146 100644 --- a/src/statd/statd.c +++ b/src/statd/statd.c @@ -44,6 +44,7 @@ #define XPATH_IFACE_BASE "/ietf-interfaces:interfaces" #define XPATH_ROUTING_BASE "/ietf-routing:routing/control-plane-protocols/control-plane-protocol" #define XPATH_ROUTING_TABLE "/ietf-routing:routing/ribs" +#define XPATH_ROUTING_IFACES "/ietf-routing:routing/interfaces" #define XPATH_HARDWARE_BASE "/ietf-hardware:hardware" #define XPATH_SYSTEM_BASE "/ietf-system" #define XPATH_ROUTING_OSPF XPATH_ROUTING_BASE "/ospf" @@ -242,6 +243,49 @@ static int sr_generic_cb(sr_session_ctx_t *session, uint32_t, const char *model, return err; } +/* + * The routing tree has a subscription per part, a request is only sent + * to the ones it overlaps. yanger produces the part subscribed to, so + * a request for all of it does not get any part twice. + */ +static int sr_routing_cb(sr_session_ctx_t *session, uint32_t, const char *model, + const char *path, const char *xpath, uint32_t, + struct lyd_node **parent, __attribute__((unused)) void *priv) +{ + char *yanger_args[5] = { + YANGER_BINPATH, + (char *)model, + "-p", + strrchr(path, '/') + 1, + NULL + }; + const struct ly_ctx *ctx; + sr_conn_ctx_t *con; + sr_error_t err; + + DEBUG("Incoming routing query for xpath: %s", xpath); + + con = sr_session_get_connection(session); + if (!con) { + ERROR("Error getting sysrepo connection"); + return SR_ERR_INTERNAL; + } + + ctx = sr_acquire_context(con); + if (!ctx) { + ERROR("Failed acquiring sysrepo context"); + return SR_ERR_INTERNAL; + } + + err = ly_add_yanger_data(ctx, parent, yanger_args); + if (err) + ERROR("Failed adding yanger data for %s %s", model, yanger_args[3]); + + sr_release_context(con); + + return err; +} + static int sr_ospf_cb(sr_session_ctx_t *session, uint32_t, const char *, const char *, const char *xpath, uint32_t, struct lyd_node **parent, __attribute__((unused)) void *priv) @@ -436,7 +480,9 @@ static int subscribe_to_all(struct statd *statd) { DEBUG("Attempting to subscribe to all"); - if (subscribe(statd, "ietf-routing", XPATH_ROUTING_TABLE, sr_generic_cb)) + if (subscribe(statd, "ietf-routing", XPATH_ROUTING_TABLE, sr_routing_cb)) + return SR_ERR_INTERNAL; + if (subscribe(statd, "ietf-routing", XPATH_ROUTING_IFACES, sr_routing_cb)) return SR_ERR_INTERNAL; if (subscribe(statd, "ietf-interfaces", XPATH_IFACE_BASE, sr_iface_cb)) return SR_ERR_INTERNAL; From f2ffbbc500008f0355011f044305a51e22c6da03 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 18:44:51 +0200 Subject: [PATCH 14/14] test: verify the PPPoE client Covers session setup with PAP and CHAP, the default route, DNS, forwarding, MSS clamping, reconnect after the server drops the session, disabling and deleting the interface, and a wrong password. Signed-off-by: Joachim Wiberg --- test/case/interfaces/Readme.adoc | 5 + test/case/interfaces/all.yaml | 3 + test/case/interfaces/pppoe_client/Readme.adoc | 1 + test/case/interfaces/pppoe_client/test.adoc | 51 +++++ test/case/interfaces/pppoe_client/test.py | 183 ++++++++++++++++++ .../case/interfaces/pppoe_client/topology.dot | 24 +++ .../case/interfaces/pppoe_client/topology.svg | 54 ++++++ 7 files changed, 321 insertions(+) create mode 120000 test/case/interfaces/pppoe_client/Readme.adoc create mode 100644 test/case/interfaces/pppoe_client/test.adoc create mode 100755 test/case/interfaces/pppoe_client/test.py create mode 100644 test/case/interfaces/pppoe_client/topology.dot create mode 100644 test/case/interfaces/pppoe_client/topology.svg diff --git a/test/case/interfaces/Readme.adoc b/test/case/interfaces/Readme.adoc index 4e1c8b372..cee1f2aac 100644 --- a/test/case/interfaces/Readme.adoc +++ b/test/case/interfaces/Readme.adoc @@ -6,6 +6,7 @@ Tests verifying interface configuration and management: - VLAN interface configuration and connectivity - IPv4 and IPv6 address assignment and management - IPv4 address auto-configuration mechanisms + - PPPoE client sessions, authentication, and default route - Interface aliases and physical address configuration - IPv4 and IPv6 forwarding between interfaces - Linux bridge creation, STP, and VLAN handling @@ -33,6 +34,10 @@ include::ipv4_autoconf/Readme.adoc[] <<< +include::pppoe_client/Readme.adoc[] + +<<< + include::ifalias/Readme.adoc[] <<< diff --git a/test/case/interfaces/all.yaml b/test/case/interfaces/all.yaml index d6584c710..d1943bccd 100644 --- a/test/case/interfaces/all.yaml +++ b/test/case/interfaces/all.yaml @@ -41,6 +41,9 @@ - name: IPv4 link-local case: ipv4_autoconf/test.py +- name: PPPoE Client + case: pppoe_client/test.py + - name: Bridge Interface Tests suite: bridge.yaml diff --git a/test/case/interfaces/pppoe_client/Readme.adoc b/test/case/interfaces/pppoe_client/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/interfaces/pppoe_client/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/interfaces/pppoe_client/test.adoc b/test/case/interfaces/pppoe_client/test.adoc new file mode 100644 index 000000000..366435663 --- /dev/null +++ b/test/case/interfaces/pppoe_client/test.adoc @@ -0,0 +1,51 @@ +=== PPPoE Client + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/pppoe_client] + +==== Description + +Verify that a PPPoE client session comes up and is used, against a +minimal PPPoE server on the host. + +The client authenticates with PAP and gets its IPv4 address and a DNS +server from the server. Its default route through the session must be +a static route with the configured route preference, the same as a +DHCP route. + +A host on the LAN behind the client must reach the server over the +session, which needs the IPv4 forwarding configured on the PPP +interface. The MSS of its TCP connections to the server must arrive +clamped to the session MTU. + +The PPP interface must exist, with its description set, before the +session is up. When the server drops the session the interface must +stay, without the session's address and route, and the client must come +back on its own when the server returns, this time with CHAP. +Disabling the PPP interface must end the session but keep the interface, +and enabling it again must bring the session back. With a wrong +password the session must not come up. Deleting the PPP interface must +remove it. + +==== Topology + +image::topology.svg[PPPoE Client topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUT +. Configure PPPoE client on wan, on top of the data port +. Verify wan exists and is configured before the session is up +. Verify session comes up with PAP, wan gets {PEER} +. Verify default route via wan is static with preference 5 +. Verify DNS server {DNS} from the server is used +. Verify IPv4 forwarding is enabled on wan +. Verify LAN host reaches server {SERVER} over the session +. Verify TCP MSS from the LAN is clamped to {CLAMPED_MSS} +. Stop server, verify wan stays without session and default route +. Restart server with CHAP, verify the client reconnects +. Disable wan, verify the session ends and wan stays +. Enable wan, verify the session comes back +. Change password to a wrong one, verify the session stays down +. Delete wan, verify the interface is removed + + diff --git a/test/case/interfaces/pppoe_client/test.py b/test/case/interfaces/pppoe_client/test.py new file mode 100755 index 000000000..6f6ca536f --- /dev/null +++ b/test/case/interfaces/pppoe_client/test.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""PPPoE client + +Verify that a PPPoE client session comes up and is used, against a +minimal PPPoE server on the host. + +The client authenticates with PAP and gets its IPv4 address and a DNS +server from the server. Its default route through the session must be +a static route with the configured route preference, the same as a +DHCP route. + +A host on the LAN behind the client must reach the server over the +session, which needs the IPv4 forwarding configured on the PPP +interface. The MSS of its TCP connections to the server must arrive +clamped to the session MTU. + +The PPP interface must exist, with its description set, before the +session is up. When the server drops the session the interface must +stay, without the session's address and route, and the client must come +back on its own when the server returns, this time with CHAP. +Disabling the PPP interface must end the session but keep the interface, +and enabling it again must bring the session back. With a wrong +password the session must not come up. Deleting the PPP interface must +remove it. + +""" +import infamy +import infamy.iface as iface +import infamy.pppoe +import infamy.route as route +from infamy.util import until +from infamy.wifi import keystore + +PEER = "10.0.0.100" +SERVER = "10.0.0.1" +DNS = "192.0.2.53" +LAN_CLIENT = "192.168.1.1" +LAN_HOST = "192.168.1.2" +CLAMPED_MSS = 1492 - 40 # PPPoE MTU minus IPv4 and TCP headers + + +def set_enabled(target, enabled): + target.put_config_dicts({"ietf-interfaces": {"interfaces": {"interface": [{ + "name": "wan", + "enabled": enabled, + }]}}}) + + +def session_up(target): + """wan has the address from the server, pppd sets no origin""" + return iface.exist(target, "wan") and \ + iface.address_exist(target, "wan", PEER, prefix_length=32, proto="other") + + +def forwarding(target): + """wan is a routing interface, i.e., has forwarding enabled""" + data = target.get_data("/ietf-routing:routing/interfaces") + return "wan" in data.get("routing", {}).get("interfaces", {}).get("interface", []) + + +def peer_dns(target): + """The server's DNS server is in use, learned on wan""" + data = target.get_data("/ietf-system:system-state/infix-system:dns-resolver") + resolver = data.get("system-state", {}).get("dns-resolver", {}) + return any(srv.get("address") == DNS and srv.get("interface") == "wan" + for srv in resolver.get("server", [])) + + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + client = env.attach("client", "mgmt") + if not client.has_feature("infix-interfaces", "ppp"): + print("DUT does not advertise the 'ppp' feature -- skipping") + test.skip() + + _, host = env.ltop.xlate("host", "data") + _, port = env.ltop.xlate("client", "data") + _, hostlan = env.ltop.xlate("host", "lan") + _, lan = env.ltop.xlate("client", "lan") + + with test.step("Configure PPPoE client on wan, on top of the data port"): + client.put_config_dicts({ + "ietf-keystore": keystore({"pppoe": "secret"}), + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": port, + "enabled": True + }, { + "name": lan, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{"ip": LAN_CLIENT, "prefix-length": 24}] + } + }, { + "name": "wan", + "type": "infix-if-type:pppoe", + "description": "Uplink", + "ipv4": { + "forwarding": True + }, + "infix-interfaces:ppp": { + "username": "user", + "secret": "pppoe" + }, + "infix-interfaces:pppoe": { + "lower-layer-if": port + } + }] + } + } + }) + + with test.step("Verify wan exists and is configured before the session is up"): + until(lambda: iface.exist(client, "wan"), attempts=20) + until(lambda: iface.get_param(client, "wan", "description") == "Uplink", attempts=10) + if session_up(client): + test.fail() + + with infamy.IsolatedMacVlan(host) as ns: + with infamy.pppoe.Server(ns, auth="pap", local=SERVER, peer=PEER, dns=DNS) as server: + with test.step(f"Verify session comes up with PAP, wan gets {PEER}"): + until(lambda: session_up(client), attempts=60) + + with test.step("Verify default route via wan is static with preference 5"): + until(lambda: route.ipv4_route_exist(client, "0.0.0.0/0", proto="ietf-routing:static", + pref=5, active_check=True), attempts=20) + + with test.step(f"Verify DNS server {DNS} from the server is used"): + until(lambda: peer_dns(client), attempts=20) + + with test.step("Verify IPv4 forwarding is enabled on wan"): + until(lambda: forwarding(client), attempts=10) + + with infamy.IsolatedMacVlan(hostlan) as lanhost: + lanhost.addip(LAN_HOST) + lanhost.addroute(f"{SERVER}/32", LAN_CLIENT) + + with test.step(f"Verify LAN host reaches server {SERVER} over the session"): + lanhost.must_reach(SERVER, timeout=10) + + with test.step(f"Verify TCP MSS from the LAN is clamped to {CLAMPED_MSS}"): + # The server never answers, the SYN is all we need + syn = f"import socket; socket.create_connection(('{SERVER}', 80), timeout=2)" + until(lambda: lanhost.run(["python3", "-c", syn], capture_output=True) and + server.syn_mss() == CLAMPED_MSS, attempts=10) + + with test.step("Stop server, verify wan stays without session and default route"): + server.stop() + until(lambda: not session_up(client), attempts=30) + if not iface.exist(client, "wan"): + test.fail() + until(lambda: not route.ipv4_route_exist(client, "0.0.0.0/0", + proto="ietf-routing:static"), attempts=20) + + with infamy.pppoe.Server(ns, auth="chap", local=SERVER, peer=PEER, dns=DNS): + with test.step("Restart server with CHAP, verify the client reconnects"): + until(lambda: session_up(client), attempts=60) + + with test.step("Disable wan, verify the session ends and wan stays"): + set_enabled(client, False) + until(lambda: not session_up(client), attempts=20) + if not iface.exist(client, "wan"): + test.fail() + + with test.step("Enable wan, verify the session comes back"): + set_enabled(client, True) + until(lambda: session_up(client), attempts=30) + + with test.step("Change password to a wrong one, verify the session stays down"): + client.put_config_dicts({"ietf-keystore": keystore({"pppoe": "wrong"})}) + until(lambda: not session_up(client), attempts=30) + for _ in range(10): + if session_up(client): + test.fail() + + with test.step("Delete wan, verify the interface is removed"): + client.delete_xpath("/ietf-interfaces:interfaces/interface[name='wan']") + until(lambda: not iface.exist(client, "wan"), attempts=20) + + test.succeed() diff --git a/test/case/interfaces/pppoe_client/topology.dot b/test/case/interfaces/pppoe_client/topology.dot new file mode 100644 index 000000000..f7d2966e7 --- /dev/null +++ b/test/case/interfaces/pppoe_client/topology.dot @@ -0,0 +1,24 @@ +graph "1x3" { + 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 | lan }", + pos="0,20!", + requires="controller", + ]; + + client [ + label="{ mgmt | data | lan } | client", + pos="200,20!", + requires="infix", + ]; + + host:mgmt -- client:mgmt [requires="mgmt", color=lightgrey] + host:data -- client:data [color=black, taillabel="PPPoE server"] + host:lan -- client:lan [color=black, taillabel="192.168.1.2/24", headlabel="192.168.1.1/24"] +} diff --git a/test/case/interfaces/pppoe_client/topology.svg b/test/case/interfaces/pppoe_client/topology.svg new file mode 100644 index 000000000..c49f7c9de --- /dev/null +++ b/test/case/interfaces/pppoe_client/topology.svg @@ -0,0 +1,54 @@ + + + + + + +1x3 + + + +host + +host + +mgmt + +data + +lan + + + +client + +mgmt + +data + +lan + +client + + + +host:mgmt--client:mgmt + + + + +host:data--client:data + +PPPoE server + + + +host:lan--client:lan + +192.168.1.1/24 +192.168.1.2/24 + + +