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()