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
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/common/rootfs/usr/libexec/odhcp6c.sh b/board/common/rootfs/usr/libexec/odhcp6c.sh
index 21ca00464..c5c7d4d64 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
}
@@ -199,7 +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
;;
@@ -222,6 +260,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/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
diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index 837e15d2e..32fa9417a 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -11,6 +11,19 @@ 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
+
+- 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
+
+[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/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
+
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/dhcpv6-client.c b/src/confd/src/dhcpv6-client.c
index 69746acf3..c30300a8e 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"
@@ -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,
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/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 271642a13..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))
@@ -289,12 +303,12 @@ 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;
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);
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_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":
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;
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()
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/use_case/dhcp_ntp_dns_combination/Readme.adoc b/test/case/interfaces/pppoe_client/Readme.adoc
similarity index 100%
rename from test/case/use_case/dhcp_ntp_dns_combination/Readme.adoc
rename to test/case/interfaces/pppoe_client/Readme.adoc
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
+
+
+
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/Readme.adoc b/test/case/use_case/dhcp_ntp_dns/Readme.adoc
new file mode 120000
index 000000000..ae32c8412
--- /dev/null
+++ b/test/case/use_case/dhcp_ntp_dns/Readme.adoc
@@ -0,0 +1 @@
+test.adoc
\ No newline at end of file
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
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()