diff --git a/board/aarch64/linux_defconfig b/board/aarch64/linux_defconfig
index cd4a84c1f..67252d707 100644
--- a/board/aarch64/linux_defconfig
+++ b/board/aarch64/linux_defconfig
@@ -244,6 +244,7 @@ CONFIG_DM_VERITY_VERIFY_ROOTHASH_SIG=y
CONFIG_NETDEVICES=y
CONFIG_BONDING=m
CONFIG_DUMMY=m
+CONFIG_WIREGUARD=m
CONFIG_MACVLAN=m
CONFIG_MACVTAP=m
CONFIG_IPVLAN=m
diff --git a/board/x86_64/linux_defconfig b/board/x86_64/linux_defconfig
index ffd4b045d..4451a6834 100644
--- a/board/x86_64/linux_defconfig
+++ b/board/x86_64/linux_defconfig
@@ -191,6 +191,7 @@ CONFIG_DM_VERITY_VERIFY_ROOTHASH_SIG=y
CONFIG_NETDEVICES=y
CONFIG_BONDING=m
CONFIG_DUMMY=m
+CONFIG_WIREGUARD=m
CONFIG_MACVLAN=m
CONFIG_MACVTAP=m
CONFIG_IPVLAN=m
diff --git a/configs/aarch32_defconfig b/configs/aarch32_defconfig
index 029e15f44..b2f60003c 100644
--- a/configs/aarch32_defconfig
+++ b/configs/aarch32_defconfig
@@ -29,7 +29,7 @@ BR2_ROOTFS_POST_BUILD_SCRIPT="${BR2_EXTERNAL_INFIX_PATH}/board/common/post-build
BR2_ROOTFS_POST_IMAGE_SCRIPT="${BR2_EXTERNAL_INFIX_PATH}/board/common/post-image.sh"
BR2_LINUX_KERNEL=y
BR2_LINUX_KERNEL_CUSTOM_VERSION=y
-BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.12.58"
+BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.12.64"
BR2_LINUX_KERNEL_USE_CUSTOM_CONFIG=y
BR2_LINUX_KERNEL_CUSTOM_CONFIG_FILE="${BR2_EXTERNAL_INFIX_PATH}/board/aarch32/linux_defconfig"
BR2_LINUX_KERNEL_INSTALL_TARGET=y
@@ -82,6 +82,7 @@ BR2_PACKAGE_OPENSSH=y
BR2_PACKAGE_SOCAT=y
BR2_PACKAGE_TCPDUMP=y
BR2_PACKAGE_WHOIS=y
+BR2_PACKAGE_WIREGUARD_TOOLS=y
BR2_PACKAGE_BASH_COMPLETION=y
BR2_PACKAGE_SUDO=y
BR2_PACKAGE_GETENT=y
diff --git a/configs/aarch32_minimal_defconfig b/configs/aarch32_minimal_defconfig
index 4410a9137..51577a67b 100644
--- a/configs/aarch32_minimal_defconfig
+++ b/configs/aarch32_minimal_defconfig
@@ -82,6 +82,7 @@ BR2_PACKAGE_OPENSSH=y
BR2_PACKAGE_SOCAT=y
BR2_PACKAGE_TCPDUMP=y
BR2_PACKAGE_WHOIS=y
+BR2_PACKAGE_WIREGUARD_TOOLS=y
BR2_PACKAGE_BASH_COMPLETION=y
BR2_PACKAGE_SUDO=y
BR2_PACKAGE_GETENT=y
diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig
index e83ba3bfe..028f00250 100644
--- a/configs/aarch64_defconfig
+++ b/configs/aarch64_defconfig
@@ -102,6 +102,7 @@ BR2_PACKAGE_TCPDUMP=y
BR2_PACKAGE_TRACEROUTE=y
BR2_PACKAGE_ULOGD=y
BR2_PACKAGE_WHOIS=y
+BR2_PACKAGE_WIREGUARD_TOOLS=y
BR2_PACKAGE_BASH_COMPLETION=y
BR2_PACKAGE_NEOFETCH=y
BR2_PACKAGE_SUDO=y
diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig
index b9d83f4ef..98c7a492f 100644
--- a/configs/aarch64_minimal_defconfig
+++ b/configs/aarch64_minimal_defconfig
@@ -85,6 +85,7 @@ BR2_PACKAGE_OPENSSH=y
BR2_PACKAGE_SOCAT=y
BR2_PACKAGE_TCPDUMP=y
BR2_PACKAGE_WHOIS=y
+BR2_PACKAGE_WIREGUARD_TOOLS=y
BR2_PACKAGE_BASH_COMPLETION=y
BR2_PACKAGE_SUDO=y
BR2_PACKAGE_KMOD_TOOLS=y
diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig
index ab35d43ae..f218cbd27 100644
--- a/configs/x86_64_defconfig
+++ b/configs/x86_64_defconfig
@@ -98,6 +98,7 @@ BR2_PACKAGE_TCPDUMP=y
BR2_PACKAGE_TRACEROUTE=y
BR2_PACKAGE_ULOGD=y
BR2_PACKAGE_WHOIS=y
+BR2_PACKAGE_WIREGUARD_TOOLS=y
BR2_PACKAGE_BASH_COMPLETION=y
BR2_PACKAGE_NEOFETCH=y
BR2_PACKAGE_SUDO=y
diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig
index d97366a04..b72818583 100644
--- a/configs/x86_64_minimal_defconfig
+++ b/configs/x86_64_minimal_defconfig
@@ -80,6 +80,7 @@ BR2_PACKAGE_OPENSSH=y
BR2_PACKAGE_SOCAT=y
BR2_PACKAGE_TCPDUMP=y
BR2_PACKAGE_WHOIS=y
+BR2_PACKAGE_WIREGUARD_TOOLS=y
BR2_PACKAGE_BASH_COMPLETION=y
BR2_PACKAGE_SUDO=y
BR2_PACKAGE_GETENT=y
diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index db71a077e..d649d874f 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -19,6 +19,7 @@ All notable changes to the project are documented in this file.
>
> - WiFi Access Point (AP) mode support with multi-SSID capability
> - RIPv2 routing support
+> - WireGuard support
### Changes
@@ -47,6 +48,7 @@ All notable changes to the project are documented in this file.
moved to `wifi/station` container. Existing Wi-Fi interfaces will be
removed during upgrade (for the rest of the configuration to apply)
and you need to reconfigure them again. See [wifi.md](wifi.md) for details
+- Add support for WireGuard VPN tunnels.
### Fixes
diff --git a/doc/img/vpn-hub-spoke.svg b/doc/img/vpn-hub-spoke.svg
new file mode 100644
index 000000000..59d51ea3a
--- /dev/null
+++ b/doc/img/vpn-hub-spoke.svg
@@ -0,0 +1,983 @@
+
+
diff --git a/doc/img/vpn-roadwarrior.svg b/doc/img/vpn-roadwarrior.svg
new file mode 100644
index 000000000..f0eefbd95
--- /dev/null
+++ b/doc/img/vpn-roadwarrior.svg
@@ -0,0 +1,689 @@
+
+
diff --git a/doc/img/vpn-site-to-site.svg b/doc/img/vpn-site-to-site.svg
new file mode 100644
index 000000000..ad8f14203
--- /dev/null
+++ b/doc/img/vpn-site-to-site.svg
@@ -0,0 +1,595 @@
+
+
diff --git a/doc/img/wireguard-allowed-ips.svg b/doc/img/wireguard-allowed-ips.svg
new file mode 100644
index 000000000..ed8d6e229
--- /dev/null
+++ b/doc/img/wireguard-allowed-ips.svg
@@ -0,0 +1,785 @@
+
+
diff --git a/doc/vpn-wireguard.md b/doc/vpn-wireguard.md
new file mode 100644
index 000000000..1b8a8e2c9
--- /dev/null
+++ b/doc/vpn-wireguard.md
@@ -0,0 +1,437 @@
+# WireGuard VPN
+
+> [!NOTE]
+> For a general introduction to VPN concepts and deployment models, see
+> [VPN Configuration](vpn.md).
+
+WireGuard is a modern, high-performance VPN protocol that uses state-of-the-art
+cryptography. It is significantly simpler and faster than traditional VPN
+solutions like IPsec or OpenVPN, while maintaining strong security guarantees.
+
+Key features of WireGuard:
+
+- **Simple Configuration:** Minimal settings required compared to IPsec
+- **High Performance:** Runs in kernel space with efficient cryptography
+- **Strong Cryptography:** Uses Curve25519, ChaCha20, Poly1305, and BLAKE2
+- **Roaming Support:** Seamlessly handles endpoint IP address changes
+- **Dual-Stack:** Supports IPv4 and IPv6 for both tunnel endpoints and traffic
+
+> [!IMPORTANT]
+> When issuing `leave` to activate your changes, remember to also save
+> your settings, `copy running-config startup-config`. See the [CLI
+> Introduction](cli/introduction.md) for a background.
+
+## Key Management
+
+WireGuard uses public-key cryptography similar to SSH. Each WireGuard interface
+requires a private key, and each peer is identified by its public key.
+
+**Generate a WireGuard key pair using the `wg` command:**
+
+```bash
+admin@example:~$ wg genkey | tee privatekey | wg pubkey > publickey
+admin@example:~$ cat privatekey
+aMqBvZqkSP5JrqBvZqkSP5JrqBvZqkSP5JrqBvZqkSP=
+admin@example:~$ cat publickey
+bN1CwZ1lTP6KsrCwZ1lTP6KsrCwZ1lTP6KsrCwZ1lTP=
+```
+
+This generates a private key, saves it to `privatekey`, derives the public key,
+and saves it to `publickey`.
+
+**Import the private key into the keystore:**
+
+```
+admin@example:/> configure
+admin@example:/config/> edit keystore asymmetric-key wg-site-a
+admin@example:/config/keystore/asymmetric-key/wg-site-a/> set public-key-format x25519-public-key-format
+admin@example:/config/keystore/asymmetric-key/wg-site-a/> set private-key-format x25519-private-key-format
+admin@example:/config/keystore/asymmetric-key/wg-site-a/> set public-key bN1CwZ1lTP6KsrCwZ1lTP6KsrCwZ1lTP6KsrCwZ1lTP=
+admin@example:/config/keystore/asymmetric-key/wg-site-a/> set private-key aMqBvZqkSP5JrqBvZqkSP5JrqBvZqkSP5JrqBvZqkSP=
+admin@example:/config/keystore/asymmetric-key/wg-site-a/> leave
+admin@example:/>
+```
+
+**Import peer public keys into the truststore:**
+
+```
+admin@example:/> configure
+admin@example:/config/> edit truststore public-key-bag wg-peers public-key peer-b
+admin@example:/config/truststore/…/peer-b/> set public-key-format x25519-public-key-format
+admin@example:/config/truststore/…/peer-b/> set public-key PEER_PUBLIC_KEY_HERE
+admin@example:/config/truststore/…/peer-b/> leave
+admin@example:/>
+```
+
+> [!IMPORTANT]
+> Keep private keys secure! Never share your private key. Only exchange
+> public keys with peers. Delete the `privatekey` file after importing it
+> into the keystore.
+
+## Point-to-Point Configuration
+
+> [!TIP]
+> If you name your WireGuard interface `wgN`, where `N` is a number, the
+> CLI infers the interface type automatically.
+
+A basic WireGuard tunnel between two sites:
+
+**Site A configuration:**
+
+```
+admin@siteA:/> configure
+admin@siteA:/config/> edit interface wg0
+admin@siteA:/config/interface/wg0/> set wireguard listen-port 51820
+admin@siteA:/config/interface/wg0/> set wireguard private-key wg-site-a
+admin@siteA:/config/interface/wg0/> set ipv4 address 10.0.0.1 prefix-length 24
+admin@siteA:/config/interface/wg0/> edit wireguard peer wg-peers peer-b
+admin@siteA:/config/interface/…/wg-peers/peer/peer-b/> set endpoint 203.0.113.2
+admin@siteA:/config/interface/…/wg-peers/peer/peer-b/> set endpoint-port 51820
+admin@siteA:/config/interface/…/wg-peers/peer/peer-b/> set allowed-ips 10.0.0.2/32
+admin@siteA:/config/interface/…/wg-peers/peer/peer-b/> set persistent-keepalive 25
+admin@siteA:/config/interface/…/wg-peers/peer/peer-b/> leave
+admin@siteA:/>
+```
+
+**Site B configuration:**
+
+```
+admin@siteB:/> configure
+admin@siteB:/config/> edit interface wg0
+admin@siteB:/config/interface/wg0/> set wireguard listen-port 51820
+admin@siteB:/config/interface/wg0/> set wireguard private-key wg-site-b
+admin@siteB:/config/interface/wg0/> set ipv4 address 10.0.0.2 prefix-length 24
+admin@siteB:/config/interface/wg0/> edit wireguard peer wg-peers peer-a
+admin@siteB:/config/interface/…/wg-peers/peer/peer-a/> set endpoint 203.0.113.1
+admin@siteB:/config/interface/…/wg-peers/peer/peer-a/> set endpoint-port 51820
+admin@siteB:/config/interface/…/wg-peers/peer/peer-a/> set allowed-ips 10.0.0.1/32
+admin@siteB:/config/interface/…/wg-peers/peer/peer-a/> set persistent-keepalive 25
+admin@siteB:/config/interface/…/wg-peers/peer/peer-a/> leave
+admin@siteB:/>
+```
+
+This creates an encrypted tunnel with Site A at 10.0.0.1 and Site B at 10.0.0.2.
+
+## Understanding Allowed IPs
+
+The `allowed-ips` setting in WireGuard serves two critical purposes:
+
+1. **Ingress Filtering:** Only packets with source IPs in the allowed list
+ are accepted from the peer
+2. **Cryptokey Routing:** Determines which peer receives outbound packets
+ for a given destination
+
+Think of `allowed-ips` as a combination of firewall rules and routing table.
+
+
+*Figure: WireGuard key exchange and allowed-ips configuration between two peers*
+
+For a simple point-to-point tunnel, you typically allow only the peer's
+tunnel IP address (e.g., `10.0.0.2/32`). For site-to-site VPNs connecting
+entire networks, include the remote network prefixes:
+
+```
+admin@example:/config/interface/…/wg-peers/peer/peer-a/> set allowed-ips 10.0.0.2/32
+admin@example:/config/interface/…/wg-peers/peer/peer-a/> set allowed-ips 192.168.2.0/24
+```
+
+This allows traffic to/from the peer at 10.0.0.2 and routes traffic destined
+for 192.168.2.0/24 through this peer.
+
+> [!NOTE]
+> When routing traffic to networks behind WireGuard peers, you also need
+> to configure static routes pointing to the WireGuard interface. See
+> [Static Routes](networking.md#static-routes) for more information.
+
+## Peer Configuration and Key Bags
+
+WireGuard peer configuration supports a two-level hierarchy that allows
+efficient management of multiple peers with shared settings.
+
+**Public Key Bags** group related peers together (e.g., all mobile clients,
+all branch offices) and allow you to configure default settings that apply
+to all peers in the bag. Individual peers can then override these defaults
+when needed.
+
+Settings that support bag-level defaults and per-peer overrides:
+
+- `endpoint` - Remote peer's IP address
+- `endpoint-port` - Remote peer's UDP port (defaults to 51820 at bag level)
+- `persistent-keepalive` - Keepalive interval in seconds
+- `preshared-key` - Optional pre-shared key for additional quantum resistance
+- `allowed-ips` - IP addresses allowed to/from this peer
+
+> [!IMPORTANT]
+> **Key Bag Configuration Rules:**
+> - **Single key in bag:** You can use bag-level settings (endpoint, allowed-ips, etc.)
+> without specifying individual peer configurations. All settings apply to that one peer.
+> - **Multiple keys in bag:** You MUST provide individual `peer` configuration for
+> each key in the bag. Settings like `endpoint` and `allowed-ips` must be unique
+> per peer and cannot be shared at the bag level when multiple peers exist.
+>
+> This prevents configuration errors where multiple peers would incorrectly share
+> the same endpoint address or allowed-ips ranges.
+
+**Example with bag-level defaults:**
+
+```
+admin@example:/> configure
+admin@example:/config/> edit interface wg0
+admin@example:/config/interface/wg0/> set wireguard listen-port 51820
+admin@example:/config/interface/wg0/> set wireguard private-key wg-key
+admin@example:/config/interface/wg0/> set ipv4 address 10.0.0.1 prefix-length 24
+
+# Configure defaults for all peers in the 'branch-offices' bag
+admin@example:/config/interface/wg0/> edit wireguard peers branch-offices
+admin@example:/config/interface/…/wireguard/peers/branch-offices/> set endpoint-port 51820
+admin@example:/config/interface/…/wireguard/peers/branch-offices/> set persistent-keepalive 25
+admin@example:/config/interface/…/wireguard/peers/branch-offices/> end
+
+# Configure peer-specific settings (inherits endpoint-port and keepalive from bag)
+admin@example:/config/interface/…/wireguard/peers/branch-offices/> edit peer office-east
+admin@example:/config/interface/…/branch-offices/peer/office-east/> set endpoint 203.0.113.10
+admin@example:/config/interface/…/branch-offices/peer/office-east/> set allowed-ips 10.0.0.10/32
+admin@example:/config/interface/…/branch-offices/peer/office-east/> set allowed-ips 192.168.10.0/24
+admin@example:/config/interface/…/branch-offices/peer/office-east/> end
+
+# Another peer with an override for persistent-keepalive
+admin@example:/config/interface/…/wireguard/peers/branch-offices/> edit peer office-west
+admin@example:/config/interface/…/branch-offices/peer/office-west/> set endpoint 203.0.113.20
+admin@example:/config/interface/…/branch-offices/peer/office-west/> set allowed-ips 10.0.0.20/32
+admin@example:/config/interface/…/branch-offices/peer/office-west/> set allowed-ips 192.168.20.0/24
+admin@example:/config/interface/…/branch-offices/peer/office-west/> set persistent-keepalive 10
+admin@example:/config/interface/…/branch-offices/peer/office-west/> leave
+admin@example:/>
+```
+
+In this example:
+- Both peers inherit `endpoint-port 51820` and `persistent-keepalive 25` from the bag
+- `office-west` overrides the keepalive to 10 seconds while `office-east` uses the default 25
+- Each peer has its own `endpoint` and `allowed-ips` configuration
+
+This approach simplifies management when you have many peers with similar
+configurations - set the common defaults once at the bag level, then only
+specify per-peer differences.
+
+## Site-to-Site VPN
+
+
+*Figure: Site-to-Site VPN connecting two office networks*
+
+A site-to-site VPN connects entire networks across locations, creating a unified
+private network over the internet. This allows devices in one location to
+seamlessly access resources in another as if they were on the same local network.
+
+This is the point-to-point configuration shown earlier, extended with routing
+to allow access to networks behind each peer. Configure the WireGuard tunnel
+as shown in [Point-to-Point Configuration](#point-to-point-configuration),
+then add the remote network to `allowed-ips` and configure static routes.
+
+## Road Warrior VPN
+
+
+*Figure: Mobile clients connecting to corporate network*
+
+For mobile clients or peers without fixed IPs, omit the `endpoint` setting.
+WireGuard learns the peer's endpoint from authenticated incoming packets:
+
+```
+admin@hub:/> configure
+admin@hub:/config/> edit interface wg0 wireguard peers wg-peers peer mobile-client
+admin@hub:/config/interface/…/wg-peers/peer/mobile-client/> set allowed-ips 10.0.0.10/32
+admin@hub:/config/interface/…/wg-peers/peer/mobile-client/> leave
+admin@hub:/>
+```
+
+The mobile client configures the hub's endpoint normally. The hub learns
+and tracks the mobile client's changing IP address automatically.
+
+## Hub-and-Spoke Topology
+
+
+*Figure: Hub-and-Spoke topology with central hub routing traffic between spokes*
+
+WireGuard excels at hub-and-spoke (star) topologies where multiple remote
+sites connect to a central hub.
+
+**Hub configuration:**
+
+```
+admin@hub:/> configure
+admin@hub:/config/> edit interface wg0
+admin@hub:/config/interface/wg0/> set ipv4 address 10.0.0.1 prefix-length 24
+admin@hub:/config/interface/wg0/> set wireguard listen-port 51820
+admin@hub:/config/interface/wg0/> set wireguard private-key wg-hub
+admin@hub:/config/interface/wg0/> edit wireguard peers wg-peers
+
+# Spoke 1
+admin@hub:/config/interface/…/wireguard/peers/wg-peers/> edit peer spoke1
+admin@hub:/config/interface/…/wg-peers/peer/spoke1/> set allowed-ips 10.0.0.2/32
+admin@hub:/config/interface/…/wg-peers/peer/spoke1/> set allowed-ips 192.168.1.0/24
+admin@hub:/config/interface/…/wg-peers/peer/spoke1/> end
+
+# Spoke 2
+admin@hub:/config/interface/…/wireguard/peers/wg-peers/> edit peer spoke2
+admin@hub:/config/interface/…/wg-peers/peer/spoke2/> set allowed-ips 10.0.0.3/32
+admin@hub:/config/interface/…/wg-peers/peer/spoke2/> set allowed-ips 192.168.2.0/24
+admin@hub:/config/interface/…/wg-peers/peer/spoke2/> leave
+
+# Add routes for spoke networks
+admin@hub:/> configure
+admin@hub:/config/> edit routing control-plane-protocol static name default
+admin@hub:/config/routing/…/static/name/default/> set ipv4 route 192.168.1.0/24 wg0
+admin@hub:/config/routing/…/static/name/default/> set ipv4 route 192.168.2.0/24 wg0
+admin@hub:/config/routing/…/static/name/default/> leave
+admin@hub:/>
+```
+
+**Spoke 1 configuration:**
+
+```
+admin@spoke1:/> configure
+admin@spoke1:/config/> edit interface wg0
+admin@spoke1:/config/interface/wg0/> set wireguard listen-port 51820
+admin@spoke1:/config/interface/wg0/> set wireguard private-key wg-spoke1
+admin@spoke1:/config/interface/wg0/> set ipv4 address 10.0.0.2 prefix-length 24
+admin@spoke1:/config/interface/wg0/> edit wireguard peers wg-peers peer hub
+admin@spoke1:/config/interface/…/wg-peers/peer/hub/> set endpoint 203.0.113.1
+admin@spoke1:/config/interface/…/wg-peers/peer/hub/> set endpoint-port 51820
+admin@spoke1:/config/interface/…/wg-peers/peer/hub/> set allowed-ips 10.0.0.1/32
+admin@spoke1:/config/interface/…/wg-peers/peer/hub/> set allowed-ips 10.0.0.3/32
+admin@spoke1:/config/interface/…/wg-peers/peer/hub/> set allowed-ips 192.168.0.0/24
+admin@spoke1:/config/interface/…/wg-peers/peer/hub/> set allowed-ips 192.168.2.0/24
+admin@spoke1:/config/interface/…/wg-peers/peer/hub/> set persistent-keepalive 25
+admin@spoke1:/config/interface/…/wg-peers/peer/hub/> leave
+admin@spoke1:/> configure
+admin@spoke1:/config/> edit routing control-plane-protocol static name default
+admin@spoke1:/config/routing/…/static/name/default/> set ipv4 route 192.168.0.0/24 wg0
+admin@spoke1:/config/routing/…/static/name/default/> set ipv4 route 192.168.2.0/24 wg0
+admin@spoke1:/config/routing/…/static/name/default/> leave
+admin@spoke1:/>
+```
+
+This configuration allows Spoke 1 to reach both the hub network (192.168.0.0/24)
+and Spoke 2's network (192.168.2.0/24) via the hub, enabling spoke-to-spoke
+communication through the central hub.
+
+## Persistent Keepalive
+
+The `persistent-keepalive` setting sends periodic packets to keep the tunnel
+active through NAT devices and firewalls:
+
+```
+admin@example:/config/interface/…/wg-peers/peer/hub/> set persistent-keepalive 25
+```
+
+This is particularly important when:
+
+- The peer is behind NAT
+- Intermediate firewalls have connection timeouts
+- You need the tunnel to remain ready for bidirectional traffic
+
+A value of 25 seconds is recommended for most scenarios. Omit this setting
+for peers with public static IPs that initiate connections.
+
+> [!NOTE]
+> Only the peer behind NAT needs `persistent-keepalive` configured. The
+> peer with a public IP learns the NAT endpoint from incoming packets.
+
+## IPv6 Endpoints
+
+WireGuard fully supports IPv6 for tunnel endpoints:
+
+```
+admin@example:/> configure
+admin@example:/config/> edit interface wg0
+admin@example:/config/interface/wg0/> set wireguard listen-port 51820
+admin@example:/config/interface/wg0/> set wireguard private-key wg-key
+admin@example:/config/interface/wg0/> set ipv4 address 10.0.0.1 prefix-length 24
+admin@example:/config/interface/wg0/> set ipv6 address fd00::1 prefix-length 64
+admin@example:/config/interface/wg0/> edit wireguard peers wg-peers peer remote
+admin@example:/config/interface/…/wg-peers/peer/remote/> set endpoint 2001:db8::2
+admin@example:/config/interface/…/wg-peers/peer/remote/> set endpoint-port 51820
+admin@example:/config/interface/…/wg-peers/peer/remote/> set allowed-ips 10.0.0.2/32
+admin@example:/config/interface/…/wg-peers/peer/remote/> set allowed-ips fd00::2/128
+admin@example:/config/interface/…/wg-peers/peer/remote/> leave
+admin@example:/>
+```
+
+WireGuard can carry both IPv4 and IPv6 traffic regardless of whether the
+tunnel endpoints use IPv4 or IPv6.
+
+## Monitoring WireGuard Status
+
+Check WireGuard interface status and peer connections:
+
+```
+admin@example:/> show interfaces
+wg0 wireguard UP 2 peers (1 up)
+ ipv4 10.0.0.1/24 (static)
+ ipv6 fd00::1/64 (static)
+
+admin@example:/> show interfaces wg0
+name : wg0
+type : wireguard
+index : 12
+operational status : up
+peers : 2
+
+ Peer 1:
+ status : UP
+ endpoint : 203.0.113.2:51820
+ latest handshake : 2025-12-09T10:23:45+0000
+ transfer tx : 125648 bytes
+ transfer rx : 98432 bytes
+
+ Peer 2:
+ status : DOWN
+ endpoint : 203.0.113.3:51820
+ latest handshake : 2025-12-09T09:15:22+0000
+ transfer tx : 45120 bytes
+ transfer rx : 32768 bytes
+```
+
+The connection status shows `UP` if a handshake occurred within the last 3
+minutes, indicating an active tunnel. The `latest handshake` timestamp shows
+when the peers last successfully authenticated and exchanged keys.
+
+## Post-Quantum Security (Preshared Keys)
+
+WireGuard supports optional preshared keys (PSK) that add an extra layer of
+symmetric encryption alongside Curve25519. This provides defense-in-depth
+against future quantum computers that might break elliptic curve cryptography.
+
+PSKs protect your data from "harvest now, decrypt later" attacks - adversaries
+recording traffic today would still need the PSK even if they break Curve25519
+later. However, peer authentication still relies on Curve25519, so PSKs don't
+provide complete post-quantum security.
+
+**Generate a preshared key using `wg genpsk`:**
+
+```bash
+admin@example:~$ wg genpsk > preshared.key
+admin@example:~$ cat preshared.key
+cO2DxZ2mUQ7LtsrDxZ2mUQ7LtsrDxZ2mUQ7LtsrDxZ2m=
+```
+
+**Import the preshared key into the keystore:**
+
+```
+admin@example:/> configure
+admin@example:/config/> edit keystore symmetric-key wg-psk
+admin@example:/config/keystore/symmetric-key/wg-psk/> set key-format wireguard-symmetric-key-format
+admin@example:/config/keystore/symmetric-key/wg-psk/> set key cO2DxZ2mUQ7LtsrDxZ2mUQ7LtsrDxZ2mUQ7LtsrDxZ2m=
+admin@example:/config/keystore/symmetric-key/wg-psk/> end
+admin@example:/config/interface/wg0/> edit wireguard peers wg-peers peer remote
+admin@example:/config/interface/…/wg-peers/peer/remote/> set preshared-key wg-psk
+admin@example:/config/interface/…/wg-peers/peer/remote/> leave
+admin@example:/>
+```
+
+The preshared key must be securely shared between both peers and configured
+on both sides.
+
+> [!IMPORTANT]
+> Preshared keys must be kept secret and exchanged through a secure channel,
+> just like passwords. Delete the `preshared.key` file after importing it
+> into both peer keystores.
diff --git a/doc/vpn.md b/doc/vpn.md
new file mode 100644
index 000000000..d17456c2e
--- /dev/null
+++ b/doc/vpn.md
@@ -0,0 +1,83 @@
+# VPN Configuration
+
+A Virtual Private Network (VPN) creates encrypted tunnels over public networks,
+enabling secure communication between remote locations or users. Unlike plain
+tunnels (GRE, VXLAN) that only provide encapsulation, VPNs add authentication
+and encryption to protect data confidentiality and integrity.
+
+
+## Configuring VPN
+
+For detailed configuration instructions and examples, see:
+
+- **[WireGuard VPN](vpn-wireguard.md)** - Complete guide to configuring
+ WireGuard tunnels, including site-to-site, road warrior, and hub-and-spoke
+ topologies.
+
+## Understanding VPN Tunnels
+
+VPN tunnels establish secure connections across untrusted networks by:
+
+- **Authentication:** Verifying the identity of tunnel endpoints using
+ cryptographic keys or certificates
+- **Encryption:** Protecting data confidentiality with strong ciphers
+- **Integrity:** Detecting tampering through message authentication codes
+
+This makes VPNs essential for connecting sites over the internet, enabling
+remote access for mobile users, and securing traffic in untrusted environments.
+
+### VPN Deployment Models
+
+VPNs are typically deployed in one of several models:
+
+**Site-to-Site VPN**
+
+
+*Figure: Site-to-Site VPN connecting two office networks*
+
+Connects entire networks across locations, creating a unified private network
+over the internet. Routers or firewalls at each site maintain persistent
+tunnels, allowing seamless access between locations.
+
+- Use case: Connecting branch offices to headquarters
+- Characteristics: Always-on, connects networks not individual devices
+- Example: Main office (192.168.1.0/24) ↔ Branch office (192.168.2.0/24)
+
+**Remote Access VPN (Road Warrior)**
+
+
+*Figure: Mobile clients connecting to corporate network*
+
+Enables individual users to securely access a private network from remote
+locations. Clients initiate connections as needed from dynamic IP addresses.
+
+- Use case: Remote employees accessing corporate resources
+- Characteristics: On-demand, handles dynamic endpoints and roaming
+- Example: Mobile laptop ↔ Corporate network
+
+**Hub-and-Spoke VPN**
+
+
+*Figure: Hub-and-Spoke topology with central hub routing traffic between spokes*
+
+A central hub connects to multiple remote sites (spokes), routing traffic
+between them. Spokes don't connect directly to each other but communicate
+through the hub.
+
+- Use case: Central office connecting multiple remote locations
+- Characteristics: Centralized control, simplified management
+- Example: HQ ↔ (Branch A, Branch B, Branch C)
+
+### VPN Protocol Comparison
+
+Different VPN protocols offer varying trade-offs between security, performance,
+and complexity:
+
+| Protocol | Complexity | Performance | Use Case |
+|------------|------------|-------------|---------------------------------|
+| WireGuard | Simple | Very High | Modern deployments, all models |
+| IPsec | Complex | High | Legacy systems, compliance reqs |
+| OpenVPN | Moderate | Moderate | Maximum compatibility |
+
+Infix supports WireGuard as its primary VPN solution, offering the best
+balance of simplicity, security, and performance for modern networks.
diff --git a/doc/wifi.md b/doc/wifi.md
index 155b484eb..ddb838025 100644
--- a/doc/wifi.md
+++ b/doc/wifi.md
@@ -185,33 +185,6 @@ In the CLI, signal strength is reported as: excellent, good, fair or bad.
For precise RSSI values in dBm, use NETCONF or RESTCONF to access the
operational datastore directly.
-### Connect to a Network
-
-Once you've identified the desired network from the scan results, configure
-station mode with the SSID and credentials. First, store your WiFi password
-in the keystore:
-
-```
-admin@example:/> configure
-admin@example:/config/> edit keystore symmetric-key my-wifi-key
-admin@example:/config/keystore/…/my-wifi-key/> set key-format wifi-preshared-key-format
-admin@example:/config/keystore/…/my-wifi-key/> set symmetric-key YourWiFiPassword
-admin@example:/config/keystore/…/my-wifi-key/> leave
-```
-
-Then configure the WiFi interface for station mode:
-
-```
-admin@example:/> configure
-admin@example:/config/> edit interface wifi0
-admin@example:/config/interface/wifi0/> set wifi station ssid MyNetwork
-admin@example:/config/interface/wifi0/> set wifi station security secret my-wifi-key
-admin@example:/config/interface/wifi0/> leave
-```
-
-The interface will transition from scan-only mode to station mode and
-attempt to connect to the specified network.
-
## Station Mode (Client)
Station mode connects to an existing Wi-Fi network. Before configuring station
diff --git a/mkdocs.yml b/mkdocs.yml
index 6aae8a6f0..dd8e5c2c5 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -33,6 +33,7 @@ nav:
- Quality of Service: qos.md
- RMON Counters: eth-counters.md
- Tunneling (L2/L3): tunnels.md
+ - VPN Tunnels: vpn.md
- Wireless LAN (WiFi): wifi.md
- Services:
- Device Discovery: discovery.md
diff --git a/src/confd/src/Makefile.am b/src/confd/src/Makefile.am
index 1d379e698..08bf1cb73 100644
--- a/src/confd/src/Makefile.am
+++ b/src/confd/src/Makefile.am
@@ -38,6 +38,7 @@ confd_plugin_la_SOURCES = \
if-gre.c \
if-vxlan.c \
if-wifi.c \
+ if-wireguard.c \
keystore.c \
system.c \
ntp.c \
diff --git a/src/confd/src/if-wireguard.c b/src/confd/src/if-wireguard.c
new file mode 100644
index 000000000..7552885aa
--- /dev/null
+++ b/src/confd/src/if-wireguard.c
@@ -0,0 +1,161 @@
+/* SPDX-License-Identifier: BSD-3-Clause */
+#include
+
+#include "interfaces.h"
+
+#define WIREGUARD_CONFIG "/run/wireguard-%s.conf"
+
+/* Helper to get a peer setting with override logic:
+ * 1. Check peer-specific override
+ * 2. Fall back to key-bag level default
+ */
+static const char *get_peer_setting(struct lyd_node *peer_override, struct lyd_node *bag_peer,
+ const char *setting_name)
+{
+ const char *value = NULL;
+
+ if (peer_override)
+ value = lydx_get_cattr(peer_override, setting_name);
+ if (!value)
+ value = lydx_get_cattr(bag_peer, setting_name);
+
+ return value;
+}
+
+static void write_allowed_ips(FILE *wg_fp, struct lyd_node *peer_override, struct lyd_node *bag_peer)
+{
+ struct lyd_node *settings_node, *allowed_ip, *check_ip;
+ int first = 1;
+
+ /* Determine which node has the allowed-ips to use:
+ * If peer override exists and has any allowed-ips, use those.
+ * Otherwise use bag-level allowed-ips.
+ */
+ settings_node = bag_peer; /* Default to bag level */
+ if (peer_override && lyd_child(peer_override)) {
+ LYX_LIST_FOR_EACH(lyd_child(peer_override), check_ip, "allowed-ips") {
+ settings_node = peer_override; /* Override has IPs, use them */
+ break;
+ }
+ }
+
+ LYX_LIST_FOR_EACH(lyd_child(settings_node), allowed_ip, "allowed-ips") {
+ const char *ip_prefix = lyd_get_value(allowed_ip);
+
+ if (ip_prefix) {
+ fprintf(wg_fp, "%s%s", first ? "AllowedIPs = " : ", ", ip_prefix);
+ first = 0;
+ }
+ }
+ if (!first)
+ fprintf(wg_fp, "\n");
+}
+
+static void write_peer(FILE *wg_fp, struct lyd_node *cif, struct lyd_node *bag_peer,
+ struct lyd_node *peer_override, const char *public_key_data)
+{
+ const char *preshared_key_ref, *preshared_key_data;
+ const char *endpoint, *endpoint_port, *keepalive;
+ struct lyd_node *psk_node;
+
+ fprintf(wg_fp, "\n[Peer]\n");
+ fprintf(wg_fp, "PublicKey = %s\n", public_key_data);
+
+ preshared_key_ref = get_peer_setting(peer_override, bag_peer, "preshared-key");
+ endpoint = get_peer_setting(peer_override, bag_peer, "endpoint");
+ endpoint_port = get_peer_setting(peer_override, bag_peer, "endpoint-port");
+ keepalive = get_peer_setting(peer_override, bag_peer, "persistent-keepalive");
+
+ if (preshared_key_ref) {
+ psk_node = lydx_get_xpathf(cif, "../../keystore/symmetric-keys/symmetric-key[name='%s']",
+ preshared_key_ref);
+ if (psk_node) {
+ preshared_key_data = lydx_get_cattr(psk_node, "cleartext-symmetric-key");
+ if (preshared_key_data)
+ fprintf(wg_fp, "PresharedKey = %s\n", preshared_key_data);
+ }
+ }
+
+ if (endpoint) {
+ if (!endpoint_port)
+ endpoint_port = "51820"; /* Default port */
+ fprintf(wg_fp, "Endpoint = %s:%s\n", endpoint, endpoint_port);
+ }
+
+ write_allowed_ips(wg_fp, peer_override, bag_peer);
+
+ if (keepalive)
+ fprintf(wg_fp, "PersistentKeepalive = %s\n", keepalive);
+}
+
+
+int wireguard_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip, struct dagger *net)
+{
+ const char *ifname, *listen_port, *private_key_ref, *private_key_data;
+ struct lyd_node *wg, *key_node, *bag_peer, *pub_key_bag;
+ FILE *wg_fp = NULL;
+ FILE *wg_sh = NULL;
+ mode_t old_umask;
+
+ ifname = lydx_get_cattr(cif, "name");
+ wg = lydx_get_child(cif, "wireguard");
+ if (!wg)
+ return -EINVAL;
+
+ listen_port = lydx_get_cattr(wg, "listen-port");
+ private_key_ref = lydx_get_cattr(wg, "private-key");
+
+ key_node = lydx_get_xpathf(cif, "../../keystore/asymmetric-keys/asymmetric-key[name='%s']",
+ private_key_ref);
+ private_key_data = lydx_get_cattr(key_node, "cleartext-private-key");
+
+ fprintf(ip, "link add dev %s type wireguard\n", ifname);
+
+ /* Set umask to create config file with limited permissions (0600) */
+ old_umask = umask(0177);
+ wg_fp = fopenf("w", WIREGUARD_CONFIG, ifname);
+ umask(old_umask);
+ if (!wg_fp)
+ return -errno;
+
+ fprintf(wg_fp, "[Interface]\n");
+ fprintf(wg_fp, "PrivateKey = %s\n", private_key_data);
+ fprintf(wg_fp, "ListenPort = %s\n", listen_port);
+
+ LYX_LIST_FOR_EACH(lyd_child(wg), bag_peer, "peers") {
+ const char *public_key_bag_ref = lydx_get_cattr(bag_peer, "public-key-bag");
+
+ pub_key_bag = lydx_get_xpathf(cif, "../../truststore/public-key-bags/public-key-bag[name='%s']",
+ public_key_bag_ref);
+ if (pub_key_bag) {
+ struct lyd_node *pub_key, *peer_override;
+ const char *public_key_name, *public_key_data;
+
+ LYX_LIST_FOR_EACH(lyd_child(pub_key_bag), pub_key, "public-key") {
+ public_key_name = lydx_get_cattr(pub_key, "name");
+ public_key_data = lydx_get_cattr(pub_key, "public-key");
+
+ if (!public_key_data)
+ continue;
+
+ /* Check if there's a peer-specific override for this key */
+ peer_override = lydx_get_xpathf(bag_peer, "peer[public-key='%s']", public_key_name);
+
+ write_peer(wg_fp, cif, bag_peer, peer_override, public_key_data);
+ }
+ }
+ }
+
+ fclose(wg_fp);
+
+ /* Create activation script */
+ wg_sh = dagger_fopen_net_init(net, ifname, NETDAG_INIT_POST, "enable-wireguard.sh");
+
+ fprintf(wg_sh, "wg setconf %s ", ifname);
+ fprintf(wg_sh, WIREGUARD_CONFIG, ifname);
+ fprintf(wg_sh, "\n");
+
+ fclose(wg_sh);
+
+ return 0;
+}
diff --git a/src/confd/src/interfaces.c b/src/confd/src/interfaces.c
index 6c33659bd..7df74980f 100644
--- a/src/confd/src/interfaces.c
+++ b/src/confd/src/interfaces.c
@@ -108,6 +108,8 @@ static int ifchange_cand_infer_type(sr_session_ctx_t *session, const char *path)
inferred.data.string_val = "infix-if-type:gretap";
else if (!fnmatch("vxlan+([0-9])", ifname, FNM_EXTMATCH))
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";
free(ifname);
@@ -419,6 +421,8 @@ static int netdag_gen_afspec_add(sr_session_ctx_t *session, struct dagger *net,
return vxlan_gen(NULL, cif, ip);
case IFT_WIFI:
return wifi_add_iface(cif, net);
+ case IFT_WIREGUARD:
+ return wireguard_gen(NULL, cif, ip, net);
case IFT_ETH:
return netdag_gen_ethtool(net, cif, dif);
case IFT_LO:
@@ -457,6 +461,7 @@ static int netdag_gen_afspec_set(sr_session_ctx_t *session, struct dagger *net,
case IFT_GRETAP:
case IFT_VETH:
case IFT_VXLAN:
+ case IFT_WIREGUARD:
case IFT_LO:
return 0;
@@ -494,6 +499,8 @@ static bool netdag_must_del(struct lyd_node *dif, struct lyd_node *cif)
return lydx_get_descendant(lyd_child(dif), "veth", NULL);
case IFT_VXLAN:
return lydx_get_descendant(lyd_child(dif), "vxlan", NULL);
+ case IFT_WIREGUARD:
+ return lydx_get_descendant(lyd_child(dif), "wireguard", NULL);
case IFT_UNKNOWN:
ERR_IFACE(cif, -EINVAL, "unsupported interface type \"%s\"",
lydx_get_cattr(cif, "type"));
@@ -579,6 +586,7 @@ static int netdag_gen_iface_del(struct dagger *net, struct lyd_node *dif,
case IFT_LAG:
case IFT_VLAN:
case IFT_VXLAN:
+ case IFT_WIREGUARD:
case IFT_UNKNOWN:
link_gen_del(dif, ip);
break;
@@ -744,6 +752,7 @@ static int netdag_init_iface(struct lyd_node *cif)
case IFT_GRETAP:
case IFT_LO:
case IFT_VXLAN:
+ case IFT_WIREGUARD:
case IFT_UNKNOWN:
break;
}
diff --git a/src/confd/src/interfaces.h b/src/confd/src/interfaces.h
index c26010c16..3e7bcfd6e 100644
--- a/src/confd/src/interfaces.h
+++ b/src/confd/src/interfaces.h
@@ -33,6 +33,7 @@
_map(IFT_VLAN, "infix-if-type:vlan") \
_map(IFT_VXLAN, "infix-if-type:vxlan") \
_map(IFT_WIFI, "infix-if-type:wifi") \
+ _map(IFT_WIREGUARD,"infix-if-type:wireguard") \
/* */
enum iftype {
@@ -152,4 +153,7 @@ 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);
+/* infix-if-wireguard */
+int wireguard_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip, struct dagger *net);
+
#endif /* CONFD_INTERFACES_H_ */
diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc
index bb136003d..c8941cbe5 100644
--- a/src/confd/yang/confd.inc
+++ b/src/confd/yang/confd.inc
@@ -30,7 +30,7 @@ MODULES=(
"infix-hardware@2025-12-04.yang"
"ieee802-dot1q-types@2022-10-29.yang"
"infix-ip@2025-11-02.yang"
- "infix-if-type@2025-02-12.yang"
+ "infix-if-type@2026-01-07.yang"
"infix-routing@2025-12-02.yang"
"ieee802-dot1ab-lldp@2022-03-15.yang"
"infix-lldp@2025-05-05.yang"
@@ -49,8 +49,8 @@ MODULES=(
"infix-factory-default@2023-06-28.yang"
"infix-interfaces@2025-11-06.yang -e vlan-filtering"
"ietf-crypto-types -e cleartext-symmetric-keys"
- "infix-crypto-types@2025-06-17.yang"
+ "infix-crypto-types@2025-11-09.yang"
"ietf-keystore -e symmetric-keys"
- "infix-keystore@2025-12-10.yang"
"infix-ntp@2025-12-03.yang"
+ "infix-keystore@2025-12-17.yang"
)
diff --git a/src/confd/yang/confd/infix-crypto-types.yang b/src/confd/yang/confd/infix-crypto-types.yang
index 25851e673..f6b06e926 100644
--- a/src/confd/yang/confd/infix-crypto-types.yang
+++ b/src/confd/yang/confd/infix-crypto-types.yang
@@ -6,6 +6,9 @@ module infix-crypto-types {
prefix ct;
}
+ revision 2025-11-09 {
+ description "Add Wireguard public/private key and sha";
+ }
revision 2025-06-17 {
description "Add Wi-Fi secret support.";
}
@@ -28,6 +31,7 @@ module infix-crypto-types {
base public-key-format;
base ct:ssh-public-key-format;
}
+
identity symmetric-key-format {
description
"Base for symmetric key format";
@@ -38,4 +42,29 @@ module infix-crypto-types {
description
"WiFi secret key";
}
+
+ identity x25519-public-key-format {
+ base public-key-format;
+ base ct:public-key-format;
+ description
+ "X25519 (Curve25519) public key format for Diffie-Hellman key exchange.
+ This is the format used by WireGuard.";
+ }
+
+ identity x25519-private-key-format {
+ base private-key-format;
+ base ct:private-key-format;
+ description
+ "X25519 (Curve25519) private key format for Diffie-Hellman key exchange.
+ This is the format used by WireGuard.";
+ }
+
+ identity wireguard-symmetric-key-format {
+ base ct:symmetric-key-format;
+ base symmetric-key-format;
+ description
+ "WireGuard pre-shared key format.
+ 32-byte base64-encoded key used as an optional additional layer
+ of symmetric encryption for post-quantum resistance.";
+ }
}
diff --git a/src/confd/yang/confd/infix-crypto-types@2025-06-17.yang b/src/confd/yang/confd/infix-crypto-types@2025-11-09.yang
similarity index 100%
rename from src/confd/yang/confd/infix-crypto-types@2025-06-17.yang
rename to src/confd/yang/confd/infix-crypto-types@2025-11-09.yang
diff --git a/src/confd/yang/confd/infix-if-type.yang b/src/confd/yang/confd/infix-if-type.yang
index dd937609b..052e6220c 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-01-07 {
+ description "Add interface type wifi and wireguard";
+ reference "internal";
+ }
+
revision 2025-02-12 {
description "Remove interface type etherlike.";
reference "internal";
@@ -81,6 +86,9 @@ module infix-if-type {
identity vxlan {
base infix-interface-type;
}
+ identity wireguard {
+ base infix-interface-type;
+ }
identity lag {
base infix-interface-type;
base ianaift:ieee8023adLag;
diff --git a/src/confd/yang/confd/infix-if-type@2025-02-12.yang b/src/confd/yang/confd/infix-if-type@2026-01-07.yang
similarity index 100%
rename from src/confd/yang/confd/infix-if-type@2025-02-12.yang
rename to src/confd/yang/confd/infix-if-type@2026-01-07.yang
diff --git a/src/confd/yang/confd/infix-if-wireguard.yang b/src/confd/yang/confd/infix-if-wireguard.yang
new file mode 100644
index 000000000..6afb3c17c
--- /dev/null
+++ b/src/confd/yang/confd/infix-if-wireguard.yang
@@ -0,0 +1,309 @@
+submodule infix-if-wireguard {
+ yang-version 1.1;
+ belongs-to infix-interfaces {
+ prefix infix-if;
+ }
+
+ import ietf-interfaces {
+ prefix if;
+ }
+ import ietf-inet-types {
+ prefix inet;
+ }
+ import infix-crypto-types {
+ prefix ixct;
+ }
+ import ietf-keystore {
+ prefix ks;
+ }
+ import infix-keystore {
+ prefix infix-ks;
+ }
+ import ietf-truststore {
+ prefix ts;
+ }
+ import infix-if-type {
+ prefix infixift;
+ }
+ import ietf-yang-types {
+ prefix yang;
+ }
+
+ organization "KernelKit";
+ contact "kernelkit@googlegroups.com";
+ description "WireGuard VPN tunnel interface";
+
+ revision 2025-11-09 {
+ description "Initial revision";
+ reference "internal";
+ }
+
+ typedef port {
+ type inet:port-number;
+ description
+ "WireGuard UDP port. Valid range: 0..65535.";
+ }
+
+ typedef keepalive-interval {
+ type uint16 {
+ range "1..65535";
+ }
+ units "seconds";
+ description
+ "Persistent keepalive interval in seconds.
+
+ A keepalive packet will be sent to the peer endpoint at this
+ interval if no traffic has been exchanged. This is useful for
+ traversing NAT and firewalls that may close the UDP connection
+ after a period of inactivity.
+
+ Recommended value is 25 seconds for peers behind NAT.";
+ }
+
+ grouping peer-settings {
+ description
+ "Common WireGuard peer configuration settings.
+
+ These settings can be applied at the key-bag level (affecting all
+ keys in the bag) or at individual peer level (overriding key-bag
+ settings for specific keys).";
+
+ leaf preshared-key {
+ type ks:central-symmetric-key-ref;
+ description
+ "Optional preshared key for additional layer of symmetric encryption.
+
+ This provides post-quantum resistance as an attacker would need
+ to break both the Curve25519 key exchange and this symmetric key.";
+ must "derived-from-or-self(deref(.)/../ks:key-format, 'ixct:wireguard-symmetric-key-format')" {
+ error-message "Preshared key must be in wireguard-symmetric-key-format";
+ }
+ }
+
+ leaf endpoint {
+ type inet:host;
+ description
+ "Peer endpoint address (IP address or DNS hostname).
+
+ If not specified, this peer can only be a responder and cannot
+ initiate connections. WireGuard will learn the endpoint from
+ incoming authenticated packets.";
+ }
+
+ leaf endpoint-port {
+ type port;
+ description "Peer endpoint UDP port";
+ }
+
+ leaf-list allowed-ips {
+ type inet:ip-prefix;
+ description
+ "List of IP address ranges (in CIDR notation) that are allowed
+ to be used as source addresses inside the tunnel from this peer.
+
+ This also controls which destination addresses will be routed
+ to this peer. For example:
+ - '10.0.0.2/32' allows only this single IP
+ - '10.0.0.0/24' allows the entire subnet
+ - '0.0.0.0/0, ::/0' routes all traffic through this peer
+
+ WireGuard uses this as a cryptographic routing table.
+
+ IMPORTANT: Allowed-IPs must be unique across all peers. If
+ multiple peers have overlapping allowed-ips, only the last
+ peer will be used for routing. When configuring at the
+ key-bag level, only use allowed-ips if all peers genuinely
+ need identical routing (rare). For typical hub-and-spoke
+ scenarios, use per-peer configuration with unique allowed-ips
+ for each client (e.g., 10.0.0.2/32, 10.0.0.3/32, etc.).";
+ }
+
+ leaf persistent-keepalive {
+ type keepalive-interval;
+ description
+ "Interval in seconds to send keepalive packets to this peer.
+
+ If not specified (or set to 0), keepalive is disabled.
+ Use this when the peer is behind NAT or a firewall that may
+ close the UDP connection after inactivity.";
+ }
+ }
+
+ augment "/if:interfaces/if:interface" {
+ when "derived-from-or-self(if:type, 'infixift:wireguard')" {
+ description "Only shown for if:type infixift:wireguard";
+ }
+ container wireguard {
+ description "WireGuard VPN configuration";
+
+ leaf listen-port {
+ type port;
+ default 51820;
+ description "Local UDP port to listen on for incoming WireGuard traffic";
+ }
+
+ leaf private-key {
+ type ks:central-asymmetric-key-ref;
+ mandatory true;
+ description "Reference to WireGuard private key (X25519/Curve25519 format)";
+ must "not(deref(.)/ks:public-key-format) or "
+ + "(derived-from-or-self(deref(.)/ks:public-key-format, 'ixct:x25519-public-key-format') and "
+ + "derived-from-or-self(deref(.)/ks:private-key-format, 'ixct:x25519-private-key-format'))" {
+ error-message "Private key must be in WireGuard (X25519/Curve25519) format";
+ }
+ }
+
+ list peers {
+ key "public-key-bag";
+ description
+ "Peers from a public key bag with common settings.
+
+ If the bag contains a single key, bag-level settings (endpoint,
+ allowed-ips, etc.) apply to that peer. If the bag contains multiple
+ keys, individual peer configuration is required for each key - you
+ must provide a 'peer' entry for each public key in the bag with
+ unique settings like endpoint and allowed-ips.";
+
+ must "count(../../../../ts:truststore/ts:public-key-bags/ts:public-key-bag[ts:name=current()/public-key-bag]/ts:public-key) = 1 or "
+ + "count(./peer) = count(../../../../ts:truststore/ts:public-key-bags/ts:public-key-bag[ts:name=current()/public-key-bag]/ts:public-key)" {
+ error-message "When a key-bag contains multiple keys, individual peer "
+ + "configuration is required for each key. Either use a "
+ + "key-bag with a single key for shared settings, or provide "
+ + "a 'peer' entry for each key in the bag with unique settings.";
+ }
+
+ leaf public-key-bag {
+ type ts:central-public-key-bag-ref;
+ must "not(deref(.)/ts:public-key-format) or "
+ + "(derived-from-or-self(deref(.)/ts:public-key-format, 'ixct:x25519-public-key-format'))" {
+ error-message "Public key must be in WireGuard (X25519/Curve25519) format";
+ }
+ description "Reference to public key bag containing peer public keys";
+ }
+
+ uses peer-settings {
+ refine endpoint-port {
+ default 51820;
+ }
+ }
+
+ list peer {
+ key "public-key";
+ description
+ "Individual peer with override settings.
+
+ Settings specified here override the key-bag level settings
+ for this specific peer.";
+
+ leaf public-key {
+ type leafref {
+ path "../../../../../../ts:truststore/ts:public-key-bags/" +
+ "ts:public-key-bag[ts:name=current()/../../public-key-bag]/" +
+ "ts:public-key/ts:name";
+ }
+ description "Name of the peer's public key within the referenced key-bag";
+ }
+
+ uses peer-settings;
+ }
+ }
+
+ container peer-status {
+ config false;
+ description "Operational state for WireGuard peers";
+
+ list peer {
+ key "public-key";
+ description "Per-peer operational statistics and status";
+
+ leaf public-key {
+ type string {
+ pattern '[A-Za-z0-9+/]{43}=';
+ }
+ description
+ "WireGuard public key of the peer in base64 encoding.
+
+ This is the actual key value, not a keystore reference.";
+ }
+
+ leaf latest-handshake {
+ type yang:date-and-time;
+ description
+ "Timestamp of the most recent successful handshake with this peer.
+
+ If no handshake has occurred yet, this leaf will not be present.
+ A successful handshake indicates that the peer is authenticated
+ and a secure session has been established.";
+ }
+
+ leaf endpoint-address {
+ type inet:ip-address;
+ description
+ "The actual IP address from which packets were last received
+ from this peer.
+
+ This may differ from the configured endpoint if:
+ - The peer is roaming (changed IP address)
+ - The configured endpoint is a DNS hostname
+ - No endpoint was configured (learned from incoming packets)
+
+ If no packets have been received, this leaf will not be present.";
+ }
+
+ leaf endpoint-port {
+ type port;
+ description
+ "The actual UDP port from which packets were last received
+ from this peer.
+
+ If no packets have been received, this leaf will not be present.";
+ }
+
+ container transfer {
+ description "Data transfer statistics for this peer";
+
+ leaf tx-bytes {
+ type yang:counter64;
+ units "bytes";
+ description
+ "Total number of bytes transmitted (sent) to this peer.
+
+ This counts encrypted payload bytes sent through the tunnel.";
+ }
+
+ leaf rx-bytes {
+ type yang:counter64;
+ units "bytes";
+ description
+ "Total number of bytes received from this peer.
+
+ This counts decrypted payload bytes received through the tunnel.";
+ }
+ }
+
+ leaf connection-status {
+ type enumeration {
+ enum down {
+ description
+ "No handshake has occurred, or the last handshake is too old.
+
+ The peer is not considered connected.";
+ }
+ enum up {
+ description
+ "A recent handshake has occurred and the connection is active.
+
+ Typically means a handshake within the last 2-3 minutes.";
+ }
+ }
+ description
+ "Current connection status with this peer.
+
+ This is derived from the latest-handshake timestamp and indicates
+ whether the tunnel is currently operational.";
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/confd/yang/confd/infix-if-wireguard@2025-11-09.yang b/src/confd/yang/confd/infix-if-wireguard@2025-11-09.yang
new file mode 120000
index 000000000..4d9bf2eeb
--- /dev/null
+++ b/src/confd/yang/confd/infix-if-wireguard@2025-11-09.yang
@@ -0,0 +1 @@
+infix-if-wireguard.yang
\ No newline at end of file
diff --git a/src/confd/yang/confd/infix-interfaces.yang b/src/confd/yang/confd/infix-interfaces.yang
index 405cf8326..097b581dc 100644
--- a/src/confd/yang/confd/infix-interfaces.yang
+++ b/src/confd/yang/confd/infix-interfaces.yang
@@ -34,6 +34,7 @@ module infix-interfaces {
include infix-if-gre;
include infix-if-vxlan;
include infix-if-wifi;
+ include infix-if-wireguard;
organization "KernelKit";
contact "kernelkit@googlegroups.com";
diff --git a/src/confd/yang/confd/infix-keystore.yang b/src/confd/yang/confd/infix-keystore.yang
index f94a362c3..ec7536d6f 100644
--- a/src/confd/yang/confd/infix-keystore.yang
+++ b/src/confd/yang/confd/infix-keystore.yang
@@ -12,6 +12,9 @@ module infix-keystore {
prefix infix-ct;
}
+ revision 2025-12-17 {
+ description "Add WireGuard support";
+ }
revision 2025-12-10 {
description "Adapt to changes in final version of ietf-keystore";
}
@@ -36,18 +39,10 @@ module infix-keystore {
}
}
deviation "/ks:keystore/ks:symmetric-keys/ks:symmetric-key/ks:key-format" {
- deviate not-supported;
- }
- augment "/ks:keystore/ks:symmetric-keys/ks:symmetric-key" {
- leaf key-format {
+ deviate replace {
type identityref {
base infix-ct:symmetric-key-format;
}
- description
- "Identifies the symmetric key's format
-
- Valid symmetric key formats are:
- wifi-preshared-key-format - WiFi preshared key";
}
}
deviation "/ks:keystore/ks:symmetric-keys/ks:symmetric-key/ks:key-type/ks:cleartext-symmetric-key" {
@@ -61,7 +56,16 @@ module infix-keystore {
"(string-length(.) >= 8 and string-length(.) <= 63)" {
error-message "WiFi pre-shared key must be 8-63 characters long";
}
- description "WiFi pre-shared key: 8-63 printable ASCII characters";
+ must "../infix-ks:key-format != 'infix-ct:wireguard-symmetric-key-format' or " +
+ "string-length(.) = 44" {
+ error-message "WireGuard pre-shared key must be 44 characters (32-byte base64-encoded)";
+ }
+ description
+ "Cleartext symmetric key value.
+
+ Format depends on key-format:
+ - WiFi pre-shared key: 8-63 printable ASCII characters
+ - WireGuard pre-shared key: 32-byte base64-encoded key (44 chars with padding)";
}
}
diff --git a/src/confd/yang/confd/infix-keystore@2025-12-10.yang b/src/confd/yang/confd/infix-keystore@2025-12-17.yang
similarity index 100%
rename from src/confd/yang/confd/infix-keystore@2025-12-10.yang
rename to src/confd/yang/confd/infix-keystore@2025-12-17.yang
diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py
index 80e3379e1..b1e25cd6e 100755
--- a/src/statd/python/cli_pretty/cli_pretty.py
+++ b/src/statd/python/cli_pretty/cli_pretty.py
@@ -1114,6 +1114,7 @@ def __init__(self, data):
self.gre = self.data.get('infix-interfaces:gre')
self.vxlan = self.data.get('infix-interfaces:vxlan')
self.wifi = self.data.get('infix-interfaces:wifi')
+ self.wireguard = self.data.get('infix-interfaces:wireguard')
if self.data.get('infix-interfaces:vlan'):
self.lower_if = self.data.get('infix-interfaces:vlan', None).get('lower-layer-if',None)
@@ -1148,6 +1149,9 @@ def is_gre(self):
def is_gretap(self):
return self.data['type'] == "infix-if-type:gretap"
+ def is_wireguard(self):
+ return self.data['type'] == "infix-if-type:wireguard"
+
def oper(self, detail=False):
"""Remap in brief overview to fit column widths."""
if not detail and self.oper_status == "lower-layer-down":
@@ -1219,6 +1223,23 @@ def pr_proto_vxlan(self, pipe=''):
row = self._pr_proto_common("vxlan", True, pipe);
print(row)
+ def pr_proto_wireguard(self, pipe=''):
+ row = self._pr_proto_common("wireguard", False, pipe)
+
+ if self.wireguard:
+ peer_status = self.wireguard.get('peer-status', {})
+ peers = peer_status.get('peer', [])
+ total_peers = len(peers)
+ up_peers = sum(1 for p in peers if p.get('connection-status') == 'up')
+
+ if total_peers > 0:
+ row += f"{total_peers} peer"
+ if total_peers != 1:
+ row += "s"
+ row += f" ({up_peers} up)"
+
+ print(row)
+
def pr_proto_loopack(self, pipe=''):
row = self._pr_proto_common("loopback", False, pipe);
print(row)
@@ -1471,6 +1492,12 @@ def pr_vxlan(self):
self.pr_proto_ipv4()
self.pr_proto_ipv6()
+ def pr_wireguard(self):
+ self.pr_name(pipe="")
+ self.pr_proto_wireguard()
+ self.pr_proto_ipv4()
+ self.pr_proto_ipv6()
+
def pr_wifi(self):
self.pr_name(pipe="")
self.pr_proto_wifi()
@@ -1598,9 +1625,6 @@ def pr_iface(self):
print(f"{'in-octets':<{20}}: {self.in_octets}")
print(f"{'out-octets':<{20}}: {self.out_octets}")
- frame = get_json_data([], self.data,'ieee802-ethernet-interface:ethernet',
- 'statistics', 'frame')
-
if self.wifi:
# Detect mode: AP has "stations", Station has "rssi" or "scan-results"
ap = self.wifi.get('access-point')
@@ -1635,6 +1659,46 @@ def pr_iface(self):
print(f"{'remote address':<{20}}: {self.vxlan['remote']}")
print(f"{'VxLAN id':<{20}}: {self.vxlan['vni']}")
+ if self.wireguard:
+ peer_status = self.wireguard.get('peer-status', {})
+ peers = peer_status.get('peer', [])
+ if peers:
+ print(f"{'peers':<{20}}: {len(peers)}")
+ for idx, peer in enumerate(peers, 1):
+ print(f"\n Peer {idx}:")
+
+ # Public key (always 44 chars: 43 + '=')
+ if pubkey := peer.get('public-key'):
+ print(f" {'public key':<{18}}: {pubkey}")
+
+ # Connection status with color
+ status = peer.get('connection-status', 'unknown')
+ if status == 'up':
+ status_str = Decore.green(status.upper())
+ else:
+ status_str = Decore.red(status.upper())
+ print(f" {'status':<{18}}: {status_str}")
+
+ # Endpoint information
+ if endpoint := peer.get('endpoint-address'):
+ port = peer.get('endpoint-port', '')
+ endpoint_str = f"{endpoint}:{port}" if port else endpoint
+ print(f" {'endpoint':<{18}}: {endpoint_str}")
+
+ # Latest handshake
+ if handshake := peer.get('latest-handshake'):
+ print(f" {'latest handshake':<{18}}: {handshake}")
+
+ # Transfer statistics
+ if transfer := peer.get('transfer'):
+ tx = transfer.get('tx-bytes', '0')
+ rx = transfer.get('rx-bytes', '0')
+ print(f" {'transfer tx':<{18}}: {tx} bytes")
+ print(f" {'transfer rx':<{18}}: {rx} bytes")
+
+
+ frame = get_json_data([], self.data,'ieee802-ethernet-interface:ethernet',
+ 'statistics', 'frame')
if frame:
print("")
for key, val in frame.items():
@@ -1799,6 +1863,10 @@ def pr_interface_list(json):
iface.pr_vxlan()
continue
+ if iface.is_wireguard():
+ iface.pr_wireguard()
+ continue
+
if iface.is_wifi():
iface.pr_wifi()
continue
diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py
index 75e530791..74bf46cca 100644
--- a/src/statd/python/yanger/ietf_hardware.py
+++ b/src/statd/python/yanger/ietf_hardware.py
@@ -668,7 +668,7 @@ def wifi_radio_components():
def operational():
- systemjson = HOST.read_json("/run/system.json")
+ systemjson = HOST.read_json("/run/system.json", {})
return {
"ietf-hardware:hardware": {
diff --git a/src/statd/python/yanger/ietf_interfaces/ethernet.py b/src/statd/python/yanger/ietf_interfaces/ethernet.py
index 8e3c04045..c2b3821c8 100644
--- a/src/statd/python/yanger/ietf_interfaces/ethernet.py
+++ b/src/statd/python/yanger/ietf_interfaces/ethernet.py
@@ -89,6 +89,9 @@ def link(ifname):
def ethernet(iplink):
eth = link(iplink["ifname"])
+ if eth is None:
+ eth = {}
+
if stats := statistics(iplink["ifname"]):
eth["statistics"] = stats
diff --git a/src/statd/python/yanger/ietf_interfaces/link.py b/src/statd/python/yanger/ietf_interfaces/link.py
index 9cd321689..60665e0ce 100644
--- a/src/statd/python/yanger/ietf_interfaces/link.py
+++ b/src/statd/python/yanger/ietf_interfaces/link.py
@@ -9,6 +9,7 @@
from . import veth
from . import vlan
from . import wifi
+from . import wireguard
def statistics(iplink):
@@ -27,6 +28,7 @@ def statistics(iplink):
def iplink2yang_type(iplink):
ifname=iplink["ifname"]
+
match iplink["link_type"]:
case "loopback":
return "infix-if-type:loopback"
@@ -36,6 +38,8 @@ def iplink2yang_type(iplink):
data = HOST.run(tuple(f"ls /sys/class/net/{ifname}/wireless/".split()), default="no")
if data != "no":
return "infix-if-type:wifi"
+ case "none":
+ pass # WireGuard interfaces is for some reason link_type none
case _:
return "infix-if-type:other"
@@ -54,6 +58,8 @@ def iplink2yang_type(iplink):
return "infix-if-type:veth"
case "vlan":
return "infix-if-type:vlan"
+ case "wireguard":
+ return "infix-if-type:wireguard"
return "infix-if-type:ethernet"
@@ -141,6 +147,9 @@ def interface(iplink, ipaddr):
case "infix-if-type:wifi":
if w := wifi.wifi(iplink["ifname"]):
interface["infix-interfaces:wifi"] = w
+ case "infix-if-type:wireguard":
+ if wg := wireguard.wireguard(iplink):
+ interface["infix-interfaces:wireguard"] = wg
match iplink2yang_lower(iplink):
case "infix-interfaces:bridge-port":
diff --git a/src/statd/python/yanger/ietf_interfaces/wireguard.py b/src/statd/python/yanger/ietf_interfaces/wireguard.py
new file mode 100644
index 000000000..901386b2f
--- /dev/null
+++ b/src/statd/python/yanger/ietf_interfaces/wireguard.py
@@ -0,0 +1,127 @@
+import subprocess
+import json
+from datetime import datetime, timezone
+
+from ..host import HOST
+
+
+def _parse_wg_show(ifname):
+ """Parse `wg show dump` output into structured data"""
+ try:
+ result = HOST.run(("wg", "show", ifname, "dump"), default="")
+ if not result:
+ return None
+
+ lines = result.strip().split('\n')
+ if len(lines) < 2: # Need at least interface line + one peer
+ return None
+
+ peers = []
+ # Skip first line (interface info), process peer lines
+ for line in lines[1:]:
+ parts = line.split('\t')
+ if len(parts) < 8:
+ continue
+
+ public_key, preshared_key, endpoint, allowed_ips, \
+ latest_handshake, rx_bytes, tx_bytes, persistent_keepalive = parts
+
+ peer = {
+ "public_key": public_key,
+ "endpoint": endpoint if endpoint != "(none)" else None,
+ "allowed_ips": allowed_ips.split(',') if allowed_ips else [],
+ "latest_handshake": int(latest_handshake) if latest_handshake != "0" else None,
+ "rx_bytes": int(rx_bytes),
+ "tx_bytes": int(tx_bytes),
+ }
+ peers.append(peer)
+
+ return peers
+ except Exception:
+ return None
+
+
+def _format_timestamp(epoch_seconds):
+ """Convert Unix timestamp to YANG date-and-time format"""
+ if not epoch_seconds:
+ return None
+ dt = datetime.fromtimestamp(epoch_seconds, tz=timezone.utc)
+ # YANG date-and-time requires timezone with colon: +00:00 not +0000
+ timestamp = dt.strftime("%Y-%m-%dT%H:%M:%S%z")
+ # Insert colon in timezone offset: +0000 -> +00:00
+ return timestamp[:-2] + ':' + timestamp[-2:]
+
+
+def _parse_endpoint(endpoint_str):
+ """Parse endpoint string like '192.168.1.1:51820' or '[2001:db8::1]:51820'"""
+ if not endpoint_str or endpoint_str == "(none)":
+ return None, None
+
+ # Handle IPv6 with brackets
+ if endpoint_str.startswith('['):
+ addr_end = endpoint_str.find(']')
+ if addr_end == -1:
+ return None, None
+ addr = endpoint_str[1:addr_end]
+ port_part = endpoint_str[addr_end+1:]
+ port = int(port_part.lstrip(':')) if ':' in port_part else None
+ return addr, port
+
+ # Handle IPv4
+ parts = endpoint_str.rsplit(':', 1)
+ if len(parts) == 2:
+ return parts[0], int(parts[1])
+ return parts[0], None
+
+
+def _connection_status(latest_handshake_epoch):
+ """Determine connection status based on handshake time"""
+ if not latest_handshake_epoch:
+ return "down"
+
+ # Consider connection up if handshake within last 3 minutes
+ age = datetime.now(timezone.utc).timestamp() - latest_handshake_epoch
+ return "up" if age < 180 else "down"
+
+
+def wireguard(iplink):
+ """Get WireGuard operational state data"""
+ ifname = iplink.get("ifname")
+ if not ifname:
+ return None
+
+ peers_data = _parse_wg_show(ifname)
+ if not peers_data:
+ return None
+
+ peers = []
+ for peer_data in peers_data:
+ peer = {
+ "public-key": peer_data["public_key"]
+ }
+
+ # Connection status (always include)
+ if peer_data["latest_handshake"]:
+ peer["latest-handshake"] = _format_timestamp(peer_data["latest_handshake"])
+ peer["connection-status"] = _connection_status(peer_data["latest_handshake"])
+ else:
+ peer["connection-status"] = "down"
+
+ # Parse endpoint
+ if peer_data["endpoint"]:
+ addr, port = _parse_endpoint(peer_data["endpoint"])
+ if addr:
+ peer["endpoint-address"] = addr
+ if port:
+ peer["endpoint-port"] = port
+
+ # Transfer statistics
+ if peer_data["tx_bytes"] or peer_data["rx_bytes"]:
+ peer["transfer"] = {
+ "tx-bytes": str(peer_data["tx_bytes"]),
+ "rx-bytes": str(peer_data["rx_bytes"]),
+ }
+
+ peers.append(peer)
+
+ return {"peer-status": {"peer": peers}} if peers else None
diff --git a/test/case/interfaces/tunnels.yaml b/test/case/interfaces/tunnels.yaml
index 5886bc190..270b57ecc 100644
--- a/test/case/interfaces/tunnels.yaml
+++ b/test/case/interfaces/tunnels.yaml
@@ -7,3 +7,12 @@
- name: Tunnel TTL verification
suite: tunnel_ttl/test.yaml
+
+- name: WireGuard Point-to-Point
+ case: wireguard_p2p/test.py
+
+- name: WireGuard multipoint
+ case: wireguard_multipoint/test.py
+
+- name: WireGuard Roadwarrior
+ case: wireguard_roadwarrior/test.py
diff --git a/test/case/interfaces/wireguard_multipoint/Readme.adoc b/test/case/interfaces/wireguard_multipoint/Readme.adoc
new file mode 120000
index 000000000..ae32c8412
--- /dev/null
+++ b/test/case/interfaces/wireguard_multipoint/Readme.adoc
@@ -0,0 +1 @@
+test.adoc
\ No newline at end of file
diff --git a/test/case/interfaces/wireguard_multipoint/test.adoc b/test/case/interfaces/wireguard_multipoint/test.adoc
new file mode 100644
index 000000000..265ac3fe3
--- /dev/null
+++ b/test/case/interfaces/wireguard_multipoint/test.adoc
@@ -0,0 +1,57 @@
+=== WireGuard multipoint
+
+ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wireguard_multipoint]
+
+==== Description
+
+Set up a WireGuard hub-and-spoke topology with one server (hub) and two
+clients (spokes). The server acts as a central point through which clients
+can communicate. Host namespaces are connected behind the server and client1
+to test routing through the WireGuard mesh.
+
+This test verifies:
+
+- WireGuard hub-and-spoke topology with multiple peers
+- Mixed IPv4/IPv6 tunnel endpoints (client1 uses IPv4, client2 uses IPv6)
+- Dual-stack WireGuard tunnels carrying both IPv4 and IPv6 traffic
+- Advanced key management with preshared keys for post-quantum resistance
+- Persistent keepalive configuration for NAT traversal
+- Different listen ports on server and clients
+- Multiple allowed-ips per peer for routing multiple subnets
+- Static routes for subnet reachability through WireGuard
+- Security boundaries enforced by allowed-ips (client2 isolated from server subnet)
+- IPv4 and IPv6 connectivity through the encrypted tunnel mesh
+- Proper routing between all nodes in the WireGuard network
+
+WireGuard hub-and-spoke:
+....
+ server:wg0 (10.0.0.1, fd00:0::1)
+ |
+ +----------------+----------------+
+ | |
+ client1:wg0 client2:wg0
+ (10.0.0.2, fd00:0::2) (10.0.0.3, fd00:0::3)
+ via IPv4 endpoint via IPv6 endpoint
+ 192.168.10.x 2001:db8:3c4d:20::x
+....
+
+Security boundaries:
+- host:data1 can reach all WireGuard IPs (10.0.0.1, .2, .3 and fd00:0::1, ::2, ::3)
+- host:data2 can reach server and client1 WireGuard IPs, but NOT client2 (blocked by allowed-ips)
+
+==== Topology
+
+image::topology.svg[WireGuard multipoint topology, align=center, scaledwidth=75%]
+
+==== Sequence
+
+. Set up topology and attach to target DUTs
+. Configure server, client1 and client2
+. Verify IPv4 connectivity with ping 10.0.0.1, 10.0.0.2 and 10.0.0.3 from host:data1
+. Verify IPv4 connectivity with ping 10.0.0.1 and 10.0.0.2 from host:data2
+. Verify host:data2 can not ping 10.0.0.3
+. Verify IPv6 connectivity with ping fd00:0::1, fd00:0::2 and fd00:0::3 from host:data1
+. Verify IPv6 connectivity with ping fd00:0:1 and fd00:0:2 from host:data2
+. Verify host:data2 can not ping fd00:0::3
+
+
diff --git a/test/case/interfaces/wireguard_multipoint/test.py b/test/case/interfaces/wireguard_multipoint/test.py
new file mode 100755
index 000000000..bb9e86645
--- /dev/null
+++ b/test/case/interfaces/wireguard_multipoint/test.py
@@ -0,0 +1,512 @@
+#!/usr/bin/env python3
+r"""
+Advanced WireGuard multipoint hub-and-spoke tunnel test
+
+Set up a WireGuard hub-and-spoke topology with one server (hub) and two
+clients (spokes). The server acts as a central point through which clients
+can communicate. Host namespaces are connected behind the server and client1
+to test routing through the WireGuard mesh.
+
+This test verifies:
+
+- WireGuard hub-and-spoke topology with multiple peers
+- Mixed IPv4/IPv6 tunnel endpoints (client1 uses IPv4, client2 uses IPv6)
+- Dual-stack WireGuard tunnels carrying both IPv4 and IPv6 traffic
+- Advanced key management with preshared keys for post-quantum resistance
+- Persistent keepalive configuration for NAT traversal
+- Different listen ports on server and clients
+- Multiple allowed-ips per peer for routing multiple subnets
+- Static routes for subnet reachability through WireGuard
+- Security boundaries enforced by allowed-ips (client2 isolated from server subnet)
+- IPv4 and IPv6 connectivity through the encrypted tunnel mesh
+- Proper routing between all nodes in the WireGuard network
+
+WireGuard hub-and-spoke:
+....
+ server:wg0 (10.0.0.1, fd00:0::1)
+ |
+ +----------------+----------------+
+ | |
+ client1:wg0 client2:wg0
+ (10.0.0.2, fd00:0::2) (10.0.0.3, fd00:0::3)
+ via IPv4 endpoint via IPv6 endpoint
+ 192.168.10.x 2001:db8:3c4d:20::x
+....
+
+Security boundaries:
+- host:data1 can reach all WireGuard IPs (10.0.0.1, .2, .3 and fd00:0::1, ::2, ::3)
+- host:data2 can reach server and client1 WireGuard IPs, but NOT client2 (blocked by allowed-ips)
+
+"""
+
+import infamy
+import infamy.util as util
+
+# Server keys
+server_private_key = "uIUL4AnD5QaVrwHDPHJzQ7sIQ+Q3zDdflnvfd59qa28="
+server_public_key = "qGVmu5UbNtMuZs2t9wFoOoHlvgmV+A1SyQacVb/bEV0="
+
+# Client1 keys
+client1_private_key = "kNmkNlSkSh9+Va2tmFv9Va8TBCZlTBF0fKAGJf8vomo="
+client1_public_key = "ROaZyvJc5DzA2XUAAeTj2YlwDsy2w0lr3t+rWj2imAk="
+
+# Client2 keys
+client2_private_key = "OPT7v/l5zICEmFIrO0U+YwA+w07l8Xo2Dp38hjGOHGY="
+client2_public_key = "Om9CPLYdK3l93GauKrq5WXo/gbcD+1CeqFpobRLLkB4="
+
+# Preshared keys (256-bit symmetric keys, base64-encoded)
+psk_client1 = "zYr83O4Ykj9i1gN+/aaosJxQxCzvXv1EYOj0MX9H2K4="
+psk_client2 = "A4Gf6KCp+CL+tH2TUd9cyARpBZAH8e+9QXiPJ0t+4So="
+
+
+def configure_server(dut):
+ dut.put_config_dicts({
+ "ietf-keystore": {
+ "keystore": {
+ "asymmetric-keys": {
+ "asymmetric-key": [{
+ "name": "server-wg-key",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": server_public_key,
+ "private-key-format": "infix-crypto-types:x25519-private-key-format",
+ "cleartext-private-key": server_private_key
+ }]
+ },
+ "symmetric-keys": {
+ "symmetric-key": [{
+ "name": "psk-client1",
+ "infix-keystore:symmetric-key": psk_client1,
+ "key-format": "infix-crypto-types:wireguard-symmetric-key-format"
+ }, {
+ "name": "psk-client2",
+ "infix-keystore:symmetric-key": psk_client2,
+ "key-format": "infix-crypto-types:wireguard-symmetric-key-format"
+ }]
+ }
+ }
+ },
+ "ietf-truststore": {
+ "truststore": {
+ "public-key-bags": {
+ "public-key-bag": [{
+ "name": "wireguard-clients",
+ "public-key": [{
+ "name": "client1-wg-pubkey",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": client1_public_key
+ }, {
+ "name": "client2-wg-pubkey",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": client2_public_key
+ }]
+ }]
+ }
+ }
+ },
+ "ietf-interfaces": {
+ "interfaces": {
+ "interface": [{
+ "name": server["link1"],
+ "ipv4": {
+ "address": [{
+ "ip": "192.168.10.1",
+ "prefix-length": 24
+ }]
+ }
+ }, {
+ "name": server["link2"],
+ "ipv6": {
+ "address": [{
+ "ip": "2001:db8:3c4d:20::1",
+ "prefix-length": 64
+ }]
+ }
+ }, {
+ "name": server["data"],
+ "ipv4": {
+ "address": [{
+ "ip": "192.168.0.2",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ },
+ "ipv6": {
+ "address": [{
+ "ip": "2001:db8:3c4d:02::2",
+ "prefix-length": 64
+ }],
+ "forwarding": True
+ }
+ },
+ {
+ "name": "wg0",
+ "type": "infix-if-type:wireguard",
+ "ipv4": {
+ "address": [{
+ "ip": "10.0.0.1",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ },
+ "ipv6": {
+ "address": [{
+ "ip": "fd00:0::1",
+ "prefix-length": 64
+ }],
+ "forwarding": True
+ },
+ "wireguard": {
+ "listen-port": 51820,
+ "private-key": "server-wg-key",
+ "peers": [{
+ "public-key-bag": "wireguard-clients",
+ "persistent-keepalive": 3,
+ "peer": [{
+ "public-key": "client1-wg-pubkey",
+ "preshared-key": "psk-client1",
+ "endpoint": "192.168.10.2",
+ "endpoint-port": 51821,
+ "allowed-ips": ["10.0.0.2/32", "192.168.1.0/24", "fd00:0::2/128", "2001:db8:3c4d:01::/64"]
+ }, {
+ "public-key": "client2-wg-pubkey",
+ "preshared-key": "psk-client2",
+ "endpoint": "2001:db8:3c4d:20::2",
+ "endpoint-port": 51822,
+ "allowed-ips": ["10.0.0.3/32", "fd00:0::3/128"],
+ "persistent-keepalive": 5
+ }]
+ }]
+ }
+ }]
+ }
+ },
+ "ietf-routing": {
+ "routing": {
+ "control-plane-protocols": {
+ "control-plane-protocol": [{
+ "type": "infix-routing:static",
+ "name": "default",
+ "static-routes": {
+ "ipv4": {
+ "route": [{
+ "destination-prefix": "192.168.1.0/24",
+ "next-hop": {
+ "next-hop-address": "10.0.0.2"
+ }
+ }]
+ },
+ "ipv6": {
+ "route": [{
+ "destination-prefix": "2001:db8:3c4d:01::/64",
+ "next-hop": {
+ "next-hop-address": "fd00:0::2"
+ }
+ }]
+ }
+ }
+ }]
+ }
+ }
+ }
+ })
+
+def configure_client1(dut):
+ dut.put_config_dicts({
+ "ietf-keystore": {
+ "keystore": {
+ "asymmetric-keys": {
+ "asymmetric-key": [{
+ "name": "client1-wg-key",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": client1_public_key,
+ "private-key-format": "infix-crypto-types:x25519-private-key-format",
+ "cleartext-private-key": client1_private_key
+ }]
+ },
+ "symmetric-keys": {
+ "symmetric-key": [{
+ "name": "psk-server",
+ "infix-keystore:symmetric-key": psk_client1,
+ "key-format": "infix-crypto-types:wireguard-symmetric-key-format"
+ }]
+ }
+ }
+ },
+ "ietf-truststore": {
+ "truststore": {
+ "public-key-bags": {
+ "public-key-bag": [{
+ "name": "wireguard-server",
+ "public-key": [{
+ "name": "server-wg-pubkey",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": server_public_key
+ }]
+ }]
+ }
+ }
+ },
+ "ietf-interfaces": {
+ "interfaces": {
+ "interface": [{
+ "name": client1["link"],
+ "ipv4": {
+ "address": [{
+ "ip": "192.168.10.2",
+ "prefix-length": 24
+ }]
+ }
+ }, {
+ "name": client1["data"],
+ "ipv4": {
+ "address": [{
+ "ip": "192.168.1.2",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ },
+ "ipv6": {
+ "address": [{
+ "ip": "2001:db8:3c4d:01::2",
+ "prefix-length": 64
+ }],
+ "forwarding": True
+ }
+ }, {
+ "name": "wg0",
+ "type": "infix-if-type:wireguard",
+ "ipv4": {
+ "address": [{
+ "ip": "10.0.0.2",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ },
+ "ipv6": {
+ "address": [{
+ "ip": "fd00:0::2",
+ "prefix-length": 64
+ }],
+ "forwarding": True
+ },
+ "wireguard": {
+ "listen-port": 51821,
+ "private-key": "client1-wg-key",
+ "peers": [{
+ "public-key-bag": "wireguard-server",
+ "preshared-key": "psk-server",
+ "endpoint": "192.168.10.1",
+ "endpoint-port": 51820,
+ "allowed-ips": ["10.0.0.1/32", "10.0.0.3/32", "192.168.0.0/24", "fd00:0::1/128", "fd00:0::3/128", "2001:db8:3c4d:02::/64"],
+ "persistent-keepalive": 3
+ }]
+ }
+ }]
+ }
+ },
+ "ietf-routing": {
+ "routing": {
+ "control-plane-protocols": {
+ "control-plane-protocol": [{
+ "type": "infix-routing:static",
+ "name": "default",
+ "static-routes": {
+ "ipv4": {
+ "route": [{
+ "destination-prefix": "10.0.0.0/24",
+ "next-hop": {
+ "next-hop-address": "10.0.0.1"
+ }
+ }, {
+ "destination-prefix": "192.168.0.0/16",
+ "next-hop": {
+ "next-hop-address": "10.0.0.1"
+ }
+ }]
+ },
+ "ipv6": {
+ "route": [{
+ "destination-prefix": "fd00::/64",
+ "next-hop": {
+ "next-hop-address": "fd00::1"
+ }
+ }, {
+ "destination-prefix": "2001:db8:3c4d:02::/64",
+ "next-hop": {
+ "next-hop-address": "fd00::1"
+ }
+ }]
+ }
+ }
+ }]
+ }
+ }
+ }
+ })
+
+def configure_client2(dut):
+ dut.put_config_dicts({
+ "ietf-keystore": {
+ "keystore": {
+ "asymmetric-keys": {
+ "asymmetric-key": [{
+ "name": "client2-wg-key",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": client2_public_key,
+ "private-key-format": "infix-crypto-types:x25519-private-key-format",
+ "cleartext-private-key": client2_private_key
+ }]
+ },
+ "symmetric-keys": {
+ "symmetric-key": [{
+ "name": "psk-server",
+ "infix-keystore:symmetric-key": psk_client2,
+ "key-format": "infix-crypto-types:wireguard-symmetric-key-format"
+ }]
+ }
+ }
+ },
+ "ietf-truststore": {
+ "truststore": {
+ "public-key-bags": {
+ "public-key-bag": [{
+ "name": "wireguard-server",
+ "public-key": [{
+ "name": "server-wg-pubkey",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": server_public_key
+ }]
+ }]
+ }
+ }
+ },
+ "ietf-interfaces": {
+ "interfaces": {
+ "interface": [{
+ "name": client2["link"],
+ "ipv6": {
+ "address": [{
+ "ip": "2001:db8:3c4d:20::2",
+ "prefix-length": 64
+ }]
+ }
+ }, {
+ "name": "wg0",
+ "type": "infix-if-type:wireguard",
+ "ipv4": {
+ "address": [{
+ "ip": "10.0.0.3",
+ "prefix-length": 24
+ }]
+ },
+ "ipv6": {
+ "address": [{
+ "ip": "fd00:0::3",
+ "prefix-length": 64
+ }]
+ },
+
+ "wireguard": {
+ "listen-port": 51822,
+ "private-key": "client2-wg-key",
+ "peers": [{
+ "public-key-bag": "wireguard-server",
+ "preshared-key": "psk-server",
+ "endpoint": "2001:db8:3c4d:20::1",
+ "endpoint-port": 51820,
+ "allowed-ips": ["10.0.0.1/32", "10.0.0.2/32", "192.168.1.0/24", "fd00:0::1/128", "fd00:0::2/128", "2001:db8:3c4d:01::/64"],
+ "persistent-keepalive": 5
+ }]
+ }
+ }]
+ }
+ },
+ "ietf-routing": {
+ "routing": {
+ "control-plane-protocols": {
+ "control-plane-protocol": [{
+ "type": "infix-routing:static",
+ "name": "default",
+ "static-routes": {
+ "ipv4": {
+ "route": [{
+ "destination-prefix": "10.0.0.0/24",
+ "next-hop": {
+ "next-hop-address": "10.0.0.1"
+ }
+ }, {
+ "destination-prefix": "192.168.0.0/16",
+ "next-hop": {
+ "next-hop-address": "10.0.0.1"
+ }
+ }]
+ },
+ "ipv6": {
+ "route": [{
+ "destination-prefix": "fd00::/64",
+ "next-hop": {
+ "next-hop-address": "fd00::1"
+ }
+ }, {
+ "destination-prefix": "2001:db8:3c4d:01::/64",
+ "next-hop": {
+ "next-hop-address": "fd00::1"
+ }
+ }]
+ }
+ }
+ }]
+ }
+ }
+ }
+ })
+
+with infamy.Test() as test:
+ with test.step("Set up topology and attach to target DUTs"):
+ env = infamy.Env()
+ server = env.attach("server", "mgmt")
+ client1 = env.attach("client1", "mgmt")
+ client2 = env.attach("client2", "mgmt")
+
+ _, hclient1 = env.ltop.xlate("host", "data1")
+ _, hserver = env.ltop.xlate("host", "data2")
+
+ with test.step("Configure server, client1 and client2"):
+ util.parallel(configure_server(server), configure_client1(client1), configure_client2(client2))
+
+ with infamy.IsolatedMacVlan(hserver) as nsserver, infamy.IsolatedMacVlan(hclient1) as nsclient1:
+ nsserver.addip("192.168.0.1")
+ nsserver.addroute("default", "192.168.0.2");
+ nsclient1.addip("192.168.1.1")
+ nsclient1.addroute("default", "192.168.1.2");
+ nsserver.addip("2001:db8:3c4d:02::100", prefix_length=64, proto="ipv6")
+ nsserver.addroute("default", "2001:db8:3c4d:02::2", proto="ipv6")
+ nsclient1.addip("2001:db8:3c4d:01::100", prefix_length=64, proto="ipv6")
+ nsclient1.addroute("default", "2001:db8:3c4d:01::2", proto="ipv6")
+
+ with test.step("Verify IPv4 connectivity with ping 10.0.0.1, 10.0.0.2 and 10.0.0.3 from host:data1"):
+ util.parallel(nsclient1.must_reach("10.0.0.1"),
+ nsclient1.must_reach("10.0.0.2"),
+ nsclient1.must_reach("10.0.0.3"))
+
+ with test.step("Verify IPv4 connectivity with ping 10.0.0.1 and 10.0.0.2 from host:data2"):
+ util.parallel(nsserver.must_reach("10.0.0.1"),
+ nsserver.must_reach("10.0.0.2"))
+
+ with test.step("Verify host:data2 can not ping 10.0.0.3"):
+ nsserver.must_not_reach("10.0.0.3") # Not in allowed IPs
+
+ with test.step("Verify IPv6 connectivity with ping fd00:0::1, fd00:0::2 and fd00:0::3 from host:data1"):
+ util.parallel(nsclient1.must_reach("fd00:0::1"),
+ nsclient1.must_reach("fd00:0::2"),
+ nsclient1.must_reach("fd00:0::3"))
+
+ with test.step("Verify IPv6 connectivity with ping fd00:0:1 and fd00:0:2 from host:data2"):
+ util.parallel(nsserver.must_reach("fd00:0::1"),
+ nsserver.must_reach("fd00:0::2"))
+
+ with test.step("Verify host:data2 can not ping fd00:0::3"):
+ nsserver.must_not_reach("fd00:0::3") # Not in allowed IPs
+
+
+ test.succeed()
diff --git a/test/case/interfaces/wireguard_multipoint/topology.dot b/test/case/interfaces/wireguard_multipoint/topology.dot
new file mode 100644
index 000000000..006ffeb45
--- /dev/null
+++ b/test/case/interfaces/wireguard_multipoint/topology.dot
@@ -0,0 +1,43 @@
+graph "wireguard-multipoint" {
+ layout="neato";
+ overlap="false";
+ esep="+40";
+
+ node [shape=record, fontname="DejaVu Sans Mono, Book"];
+ edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
+
+ host [
+ label=" { data1 } | { mgmt2 } | { mgmt1 } | host | { data2 } | { mgmt3 }"
+ pos="6,0!",
+ requires="controller",
+ ];
+
+ server [
+ label=" { link1 } | { server } | { mgmt } | { data } | { link2 }"
+ pos="6, -6!",
+ requires="infix",
+ ];
+
+ client1 [
+ label="{ { data } | { mgmt } | { client1 } | link } ",
+ pos="0, -6!",
+ requires="infix",
+ ];
+
+ client2 [
+ label="{ { data } | { mgmt } | { client2 } | link } ",
+ pos="12,-6!",
+ requires="infix",
+ ];
+
+ host:mgmt1 -- server:mgmt [requires="mgmt", color="lightgray"]
+ host:mgmt2 -- client1:mgmt [requires="mgmt", color="lightgray"]
+ host:mgmt3 -- client2:mgmt [requires="mgmt", color="lightgray"]
+
+ host:data1 -- client1:data [headlabel=".1", label="192.168.1.0/24" taillabel=".2 ", labeldistance=1, fontcolor="black", color="black"]
+ host:data2 -- server:data [headlabel=".1", label="192.168.0.0/24" taillabel=".2 ", labeldistance=1, fontcolor="black", color="black"]
+
+ server:link1 -- client1:link [headlabel=".1", label="192.168.10.0/24", taillabel=".10 ", labeldistance=1, fontcolor="black", color="black"]
+ server:link2 -- client2:link [headlabel=".2\n\n", label="192.168.20.0/24", taillabel="\n.20", labeldistance=1, fontcolor="black", color="black"]
+
+}
diff --git a/test/case/interfaces/wireguard_multipoint/topology.svg b/test/case/interfaces/wireguard_multipoint/topology.svg
new file mode 100644
index 000000000..06e0c0318
--- /dev/null
+++ b/test/case/interfaces/wireguard_multipoint/topology.svg
@@ -0,0 +1,113 @@
+
+
+
+
+
diff --git a/test/case/interfaces/wireguard_p2p/Readme.adoc b/test/case/interfaces/wireguard_p2p/Readme.adoc
new file mode 120000
index 000000000..ae32c8412
--- /dev/null
+++ b/test/case/interfaces/wireguard_p2p/Readme.adoc
@@ -0,0 +1 @@
+test.adoc
\ No newline at end of file
diff --git a/test/case/interfaces/wireguard_p2p/test.adoc b/test/case/interfaces/wireguard_p2p/test.adoc
new file mode 100644
index 000000000..4cff4afc1
--- /dev/null
+++ b/test/case/interfaces/wireguard_p2p/test.adoc
@@ -0,0 +1,45 @@
+=== WireGuard Point-to-Point
+
+ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wireguard_p2p]
+
+==== Description
+
+Set up a WireGuard tunnel between two DUTs with a host connected to the first
+DUT. Enable IP forwarding on first DUT's interface to host and the WireGuard
+tunnel interface to the second DUT. On host, add route to IP network of second
+DUT and verify connectivity with the second DUT through the WireGuard tunnel.
+
+This test verifies:
+
+- WireGuard tunnel establishment between two peers
+- Key management via ietf-keystore with X25519 keypairs
+- IPv4 and IPv6 connectivity through the encrypted tunnel
+- Proper routing through the WireGuard tunnel
+
+Topology:
+....
+ 192.168.50.0/24
+ host:data ---- left:data left:link ---- right:link
+ 192.168.10.2/24 192.168.10.1 192.168.50.1 192.168.50.2
+ \\ /
+ \\ /
+ \\ WireGuard
+ \\ Tunnel /
+ \\ /
+ left:wg0 right:wg0
+ 10.0.0.1/32 10.0.0.2/32
+
+....
+
+==== Topology
+
+image::topology.svg[WireGuard Point-to-Point topology, align=center, scaledwidth=75%]
+
+==== Sequence
+
+. Set up topology and attach to target DUTs
+. Configure WireGuard tunnel on DUTs
+. Verify IPv4 connectivity with ping 10.0.0.2 from host:data
+. Verify IPv6 connectivity with ping fd00::2 from host:data
+
+
diff --git a/test/case/interfaces/wireguard_p2p/test.py b/test/case/interfaces/wireguard_p2p/test.py
new file mode 100755
index 000000000..33081e288
--- /dev/null
+++ b/test/case/interfaces/wireguard_p2p/test.py
@@ -0,0 +1,260 @@
+#!/usr/bin/env python3
+r"""
+Basic WireGuard point-to-point tunnel test
+
+Set up a WireGuard tunnel between two DUTs with a host connected to the first
+DUT. Enable IP forwarding on first DUT's interface to host and the WireGuard
+tunnel interface to the second DUT. On host, add route to IP network of second
+DUT and verify connectivity with the second DUT through the WireGuard tunnel.
+
+This test verifies:
+
+- WireGuard tunnel establishment between two peers
+- Key management via ietf-keystore with X25519 keypairs
+- IPv4 and IPv6 connectivity through the encrypted tunnel
+- Proper routing through the WireGuard tunnel
+
+Topology:
+....
+ 192.168.50.0/24
+ host:data ---- left:data left:link ---- right:link
+ 192.168.10.2/24 192.168.10.1 192.168.50.1 192.168.50.2
+ \\ /
+ \\ /
+ \\ WireGuard
+ \\ Tunnel /
+ \\ /
+ left:wg0 right:wg0
+ 10.0.0.1/32 10.0.0.2/32
+
+....
+
+"""
+
+import infamy
+import infamy.util as util
+
+left_private_key = "EJPoi0BnccsfjEhKk0IWwNzJKXZKgS6XaKt+InYITVA="
+left_public_key = "xWVOEFUZZ5VI6t1fhZeISNyw7Ma/bY8INzIoaSSLlz8="
+
+right_private_key = "UEaX13FTGhiIrnnKRd20KWh/vG6zqRIMSTzOP3hNs2s="
+right_public_key = "2pytpunN+e3V9e5asMXP+UqKoerFm08KWzcFYoWP41k="
+
+with infamy.Test() as test:
+ with test.step("Set up topology and attach to target DUTs"):
+ env = infamy.Env()
+ left = env.attach("left", "mgmt")
+ right = env.attach("right", "mgmt")
+
+ with test.step("Configure WireGuard tunnel on DUTs"):
+ util.parallel(left.put_config_dicts({
+ "ietf-keystore": {
+ "keystore": {
+ "asymmetric-keys": {
+ "asymmetric-key": [{
+ "name": "left-wg-key",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": left_public_key,
+ "private-key-format": "infix-crypto-types:x25519-private-key-format",
+ "cleartext-private-key": left_private_key
+ }]
+ }
+ }
+ },
+ "ietf-truststore": {
+ "truststore": {
+ "public-key-bags": {
+ "public-key-bag": [{
+ "name": "wireguard-peers",
+ "public-key": [{
+ "name": "right-wg-pubkey",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": right_public_key
+ }]
+ }]
+ }
+ }
+ },
+ "ietf-interfaces": {
+ "interfaces": {
+ "interface": [{
+ "name": left["link"],
+ "ipv4": {
+ "address": [{
+ "ip": "192.168.50.1",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ },
+ "ipv6": {
+ "address": [{
+ "ip": "2001:db8:3c4d:50::1",
+ "prefix-length": 64
+ }],
+ "forwarding": True
+ }
+ }, {
+ "name": left["data"],
+ "ipv4": {
+ "address": [{
+ "ip": "192.168.10.1",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ },
+ "ipv6": {
+ "address": [{
+ "ip": "2001:db8:3c4d:10::1",
+ "prefix-length": 64
+ }],
+ "forwarding": True
+ }
+ }, {
+ "name": "wg0",
+ "type": "infix-if-type:wireguard",
+ "ipv4": {
+ "address": [{
+ "ip": "10.0.0.1",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ },
+ "ipv6": {
+ "address": [{
+ "ip": "fd00::1",
+ "prefix-length": 64
+ }],
+ "forwarding": True
+ },
+ "wireguard": {
+ "listen-port": 51820,
+ "private-key": "left-wg-key",
+ "peers": [{
+ "public-key-bag": "wireguard-peers",
+ "endpoint": "192.168.50.2",
+ "endpoint-port": 51820,
+ "allowed-ips": ["10.0.0.0/24", "fd00::/64"]
+ }]
+ }
+ }]
+ }
+ }
+ }),
+ right.put_config_dicts({
+ "ietf-keystore": {
+ "keystore": {
+ "asymmetric-keys": {
+ "asymmetric-key": [{
+ "name": "right-wg-key",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": right_public_key,
+ "private-key-format": "infix-crypto-types:x25519-private-key-format",
+ "cleartext-private-key": right_private_key
+ }]
+ }
+ }
+ },
+ "ietf-truststore": {
+ "truststore": {
+ "public-key-bags": {
+ "public-key-bag": [{
+ "name": "wireguard-peers",
+ "public-key": [{
+ "name": "left-wg-pubkey",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": left_public_key
+ }]
+ }]
+ }
+ }
+ },
+ "ietf-interfaces": {
+ "interfaces": {
+ "interface": [{
+ "name": right["link"],
+ "ipv4": {
+ "address": [{
+ "ip": "192.168.50.2",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ },
+ "ipv6": {
+ "address": [{
+ "ip": "2001:db8:3c4d:50::2",
+ "prefix-length": 64
+ }]
+ }
+ }, {
+ "name": "wg0",
+ "type": "infix-if-type:wireguard",
+ "ipv4": {
+ "address": [{
+ "ip": "10.0.0.2",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ },
+ "ipv6": {
+ "address": [{
+ "ip": "fd00::2",
+ "prefix-length": 64
+ }]
+ },
+ "wireguard": {
+ "listen-port": 51820,
+ "private-key": "right-wg-key",
+ "peers": [{
+ "public-key-bag": "wireguard-peers",
+ "endpoint": "192.168.50.1",
+ "endpoint-port": 51820,
+ "allowed-ips": ["10.0.0.0/24", "fd00::/64", "2001:db8:3c4d:10::/64", "192.168.10.0/24"]
+ }]
+ }
+ }]
+ }
+ },
+ "ietf-routing": {
+ "routing": {
+ "control-plane-protocols": {
+ "control-plane-protocol": [{
+ "type": "infix-routing:static",
+ "name": "default",
+ "static-routes": {
+ "ipv4": {
+ "route": [{
+ "destination-prefix": "192.168.10.0/24",
+ "next-hop": {
+ "next-hop-address": "10.0.0.1"
+ }
+ }]
+ },
+ "ipv6": {
+ "route": [{
+ "destination-prefix": "2001:db8:3c4d:10::/64",
+ "next-hop": {
+ "next-hop-address": "fd00::1"
+ }
+ }]
+ }
+ }
+ }]
+ }
+ }
+ }
+ }))
+
+ _, hport = env.ltop.xlate("host", "data")
+ with test.step("Verify IPv4 connectivity with ping 10.0.0.2 from host:data"):
+ with infamy.IsolatedMacVlan(hport) as ns0:
+ ns0.addip("192.168.10.2")
+ ns0.addroute("10.0.0.0/24", "192.168.10.1")
+ ns0.must_reach("10.0.0.2")
+
+ with test.step("Verify IPv6 connectivity with ping fd00::2 from host:data"):
+ with infamy.IsolatedMacVlan(hport) as ns0:
+ ns0.addip("2001:db8:3c4d:10::2", prefix_length=64, proto="ipv6")
+ ns0.addroute("fd00::/64", "2001:db8:3c4d:10::1", proto="ipv6")
+ ns0.must_reach("fd00::2")
+
+ test.succeed()
diff --git a/test/case/interfaces/wireguard_p2p/topology.dot b/test/case/interfaces/wireguard_p2p/topology.dot
new file mode 100644
index 000000000..3658b97db
--- /dev/null
+++ b/test/case/interfaces/wireguard_p2p/topology.dot
@@ -0,0 +1,34 @@
+graph "wireguard-basic" {
+ layout="neato";
+ overlap="false";
+ esep="+40";
+
+ node [shape=record, fontname="DejaVu Sans Mono, Book"];
+ edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
+
+ host [
+ label="host | { mgmt1 } | { data } | { mgmt2 }"
+ pos="3,0!",
+ requires="controller",
+ ];
+
+ left [
+ label="{ left } | { mgmt } | { data } | { link }",
+ pos="0, -3!",
+
+ requires="infix",
+ ];
+
+ right [
+ label="{ link } | { mgmt } | { right }",
+ pos="8,-3!",
+
+ requires="infix",
+ ];
+
+ host:mgmt1 -- left:mgmt [requires="mgmt", color="lightgray"]
+ host:data -- left:data [headlabel=".1", label="192.168.10.0/24" taillabel=".2 ", labeldistance=1, fontcolor="black", color="black"]
+ host:mgmt2 -- right:mgmt [requires="mgmt", color="lightgray"]
+
+ left:link -- right:link [headlabel=".1\n\n", label="192.168.50.0/24", taillabel="\n.2", labeldistance=1, fontcolor="black", color="black"]
+}
diff --git a/test/case/interfaces/wireguard_p2p/topology.svg b/test/case/interfaces/wireguard_p2p/topology.svg
new file mode 100644
index 000000000..5b951047b
--- /dev/null
+++ b/test/case/interfaces/wireguard_p2p/topology.svg
@@ -0,0 +1,72 @@
+
+
+
+
+
diff --git a/test/case/interfaces/wireguard_roadwarrior/Readme.adoc b/test/case/interfaces/wireguard_roadwarrior/Readme.adoc
new file mode 120000
index 000000000..ae32c8412
--- /dev/null
+++ b/test/case/interfaces/wireguard_roadwarrior/Readme.adoc
@@ -0,0 +1 @@
+test.adoc
\ No newline at end of file
diff --git a/test/case/interfaces/wireguard_roadwarrior/test.adoc b/test/case/interfaces/wireguard_roadwarrior/test.adoc
new file mode 100644
index 000000000..21052b91b
--- /dev/null
+++ b/test/case/interfaces/wireguard_roadwarrior/test.adoc
@@ -0,0 +1,56 @@
+=== WireGuard Roadwarrior
+
+ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wireguard_roadwarrior]
+
+==== Description
+
+This test demonstrates a realistic roadwarrior scenario where clients
+connect through an internet router to reach a VPN server.
+
+Set up a WireGuard server with 2 roadwarrior clients connecting through
+a router that simulates the internet. Each client is on a different subnet
+behind the router, and has a private network that should be accessible
+through the VPN tunnel.
+
+This test verifies:
+
+- WireGuard tunnel establishment through an intermediate router
+- Roadwarrior clients on different subnets
+- Access to server's private network through the VPN tunnel
+- Routing between client networks and server network via WireGuard
+
+Topology:
+....
+ Server ---- Router ---- Client1
+ (VPN) (Internet) \---- Client2
+
+ WAN: 192.168.100.0/24
+ Server: 192.168.100.1
+ Router: 192.168.100.2
+
+ Client1 link: 192.168.50.0/24 (Router LAN1)
+ Client2 link: 192.168.51.0/24 (Router LAN2)
+
+ WireGuard tunnel: 10.0.0.0/24
+ Server: 10.0.0.1/24
+ Client1: 10.0.0.2/24
+ Client2: 10.0.0.3/24
+
+ Backend networks:
+ Server data: 192.168.0.0/24
+ Client1 data: 192.168.1.0/24
+ Client2 data: 192.168.2.0/24
+....
+
+==== Topology
+
+image::topology.svg[WireGuard Roadwarrior topology, align=center, scaledwidth=75%]
+
+==== Sequence
+
+. Set up topology and attach to target DUTs
+. Configure DUTs
+. Check on the server that both clients is connected
+. Verify IPv4 connectivity with ping 192.168.0.2 from host:data1 and host:data2
+
+
diff --git a/test/case/interfaces/wireguard_roadwarrior/test.py b/test/case/interfaces/wireguard_roadwarrior/test.py
new file mode 100755
index 000000000..4ed4b7e0d
--- /dev/null
+++ b/test/case/interfaces/wireguard_roadwarrior/test.py
@@ -0,0 +1,359 @@
+#!/usr/bin/env python3
+r"""
+WireGuard roadwarrior test with router simulating internet
+
+This test demonstrates a realistic roadwarrior scenario where clients
+connect through an internet router to reach a VPN server.
+
+Set up a WireGuard server with 2 roadwarrior clients connecting through
+a router that simulates the internet. Each client is on a different subnet
+behind the router, and has a private network that should be accessible
+through the VPN tunnel.
+
+This test verifies:
+
+- WireGuard tunnel establishment through an intermediate router
+- Roadwarrior clients on different subnets
+- Access to server's private network through the VPN tunnel
+- Routing between client networks and server network via WireGuard
+
+Topology:
+....
+ Server ---- Router ---- Client1
+ (VPN) (Internet) \---- Client2
+
+ WAN: 192.168.100.0/24
+ Server: 192.168.100.1
+ Router: 192.168.100.2
+
+ Client1 link: 192.168.50.0/24 (Router LAN1)
+ Client2 link: 192.168.51.0/24 (Router LAN2)
+
+ WireGuard tunnel: 10.0.0.0/24
+ Server: 10.0.0.1/24
+ Client1: 10.0.0.2/24
+ Client2: 10.0.0.3/24
+
+ Backend networks:
+ Server data: 192.168.0.0/24
+ Client1 data: 192.168.1.0/24
+ Client2 data: 192.168.2.0/24
+....
+
+"""
+
+import infamy
+import infamy.util as util
+import infamy.iface as iface
+import infamy.wireguard as wg
+
+def configure_server(server, server_public_key, server_private_key, client1_public_key, client2_public_key):
+ """Configure WireGuard server with key-bag level settings for all clients"""
+ server.put_config_dicts({
+ "ietf-keystore": {
+ "keystore": {
+ "asymmetric-keys": {
+ "asymmetric-key": [{
+ "name": "server-wg-key",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": server_public_key,
+ "private-key-format": "infix-crypto-types:x25519-private-key-format",
+ "cleartext-private-key": server_private_key
+ }]
+ }
+ }
+ },
+ "ietf-truststore": {
+ "truststore": {
+ "public-key-bags": {
+ "public-key-bag": [{
+ "name": "roadwarriors",
+ "public-key": [{
+ "name": "client1-key",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": client1_public_key,
+ }, {
+ "name": "client2-key",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": client2_public_key,
+ }]
+ }]
+ }
+ }
+ },
+ "ietf-interfaces": {
+ "interfaces": {
+ "interface": [{
+ "name": server["wan"],
+ "ipv4": {
+ "address": [{
+ "ip": "192.168.100.1",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ }
+ }, {
+ "name": server["data"],
+ "ipv4": {
+ "address": [{
+ "ip": "192.168.0.1",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ }
+ },
+ {
+ "name": "wg0",
+ "type": "infix-if-type:wireguard",
+ "ipv4": {
+ "address": [{
+ "ip": "10.0.0.1",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ },
+ "wireguard": {
+ "private-key": "server-wg-key",
+ "peers": [{
+ "public-key-bag": "roadwarriors",
+ "persistent-keepalive": 3,
+ "peer": [{
+ "public-key": "client1-key",
+ "allowed-ips": ["10.0.0.2/32", "192.168.1.0/24"]
+ }, {
+ "public-key": "client2-key",
+ "allowed-ips": ["10.0.0.3/32", "192.168.2.0/24"]
+ }]
+ }]
+ }
+ }]
+ }
+ },
+ "ietf-routing": {
+ "routing": {
+ "control-plane-protocols": {
+ "control-plane-protocol": [{
+ "type": "infix-routing:static",
+ "name": "default",
+ "static-routes": {
+ "ipv4": {
+ "route": [{
+ "destination-prefix": "192.168.1.0/24",
+ "next-hop": {
+ "next-hop-address": "10.0.0.2"
+ }
+ }, {
+ "destination-prefix": "192.168.2.0/24",
+ "next-hop": {
+ "next-hop-address": "10.0.0.3"
+ }
+ }, {
+ "destination-prefix": "192.168.50.0/24",
+ "next-hop": {
+ "next-hop-address": "192.168.100.2"
+ }
+ }, {
+ "destination-prefix": "192.168.51.0/24",
+ "next-hop": {
+ "next-hop-address": "192.168.100.2"
+ }
+ }]
+ }
+ }
+ }]
+ }
+ }
+ }
+ })
+
+def configure_router(router):
+ """Configure router to forward traffic between server and clients"""
+ router.put_config_dicts({
+ "ietf-interfaces": {
+ "interfaces": {
+ "interface": [{
+ "name": router["wan"],
+ "ipv4": {
+ "address": [{
+ "ip": "192.168.100.2",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ }
+ }, {
+ "name": router["lan1"],
+ "ipv4": {
+ "address": [{
+ "ip": "192.168.50.1",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ }
+ }, {
+ "name": router["lan2"],
+ "ipv4": {
+ "address": [{
+ "ip": "192.168.51.1",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ }
+ }]
+ }
+ }
+ })
+
+def configure_client(client, client_num, private_key, public_key, server_pubkey):
+ """Configure a WireGuard roadwarrior client"""
+ # Client1 is on 192.168.50.0/24, Client2 is on 192.168.51.0/24
+ link_subnet = 49 + client_num
+
+ client.put_config_dicts({
+ "ietf-keystore": {
+ "keystore": {
+ "asymmetric-keys": {
+ "asymmetric-key": [{
+ "name": f"client{client_num}-wg-key",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": public_key,
+ "private-key-format": "infix-crypto-types:x25519-private-key-format",
+ "cleartext-private-key": private_key
+ }]
+ }
+ }
+ },
+ "ietf-truststore": {
+ "truststore": {
+ "public-key-bags": {
+ "public-key-bag": [{
+ "name": "server",
+ "public-key": [{
+ "name": "server-key",
+ "public-key-format": "infix-crypto-types:x25519-public-key-format",
+ "public-key": server_pubkey
+ }]
+ }]
+ }
+ }
+ },
+ "ietf-interfaces": {
+ "interfaces": {
+ "interface": [{
+ "name": client["link"],
+ "ipv4": {
+ "address": [{
+ "ip": f"192.168.{link_subnet}.2",
+ "prefix-length": 24
+ }]
+ }
+ }, {
+ "name": client["data"],
+ "ipv4": {
+ "address": [{
+ "ip": f"192.168.{client_num}.1",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ }
+ }, {
+ "name": "wg0",
+ "type": "infix-if-type:wireguard",
+ "ipv4": {
+ "address": [{
+ "ip": f"10.0.0.{client_num + 1}",
+ "prefix-length": 24
+ }],
+ "forwarding": True
+ },
+ "wireguard": {
+ "private-key": f"client{client_num}-wg-key",
+ "peers": [{
+ "public-key-bag": "server",
+ "endpoint": "192.168.100.1",
+ "endpoint-port": 51820,
+ "allowed-ips": ["10.0.0.0/24", "192.168.0.0/24"],
+ "persistent-keepalive": 3
+ }]
+ }
+ }]
+ }
+ },
+ "ietf-routing": {
+ "routing": {
+ "control-plane-protocols": {
+ "control-plane-protocol": [{
+ "type": "infix-routing:static",
+ "name": "default",
+ "static-routes": {
+ "ipv4": {
+ "route": [{
+ "destination-prefix": "192.168.0.0/24",
+ "next-hop": {
+ "next-hop-address": "10.0.0.1"
+ }
+ }, {
+ "destination-prefix": "0.0.0.0/0",
+ "next-hop": {
+ "next-hop-address": f"192.168.{link_subnet}.1"
+ }
+ }]
+ }
+ }
+ }]
+ }
+ }
+ }
+ })
+server_private_key = "uIUL4AnD5QaVrwHDPHJzQ7sIQ+Q3zDdflnvfd59qa28="
+server_public_key = "qGVmu5UbNtMuZs2t9wFoOoHlvgmV+A1SyQacVb/bEV0="
+
+client1_private_key = "kNmkNlSkSh9+Va2tmFv9Va8TBCZlTBF0fKAGJf8vomo="
+client1_public_key = "ROaZyvJc5DzA2XUAAeTj2YlwDsy2w0lr3t+rWj2imAk="
+
+client2_private_key = "OPT7v/l5zICEmFIrO0U+YwA+w07l8Xo2Dp38hjGOHGY="
+client2_public_key = "Om9CPLYdK3l93GauKrq5WXo/gbcD+1CeqFpobRLLkB4="
+
+
+with infamy.Test() as test:
+ with test.step("Set up topology and attach to target DUTs"):
+ env = infamy.Env()
+ server = env.attach("server", "mgmt")
+ router = env.attach("router", "mgmt")
+ client1 = env.attach("client1", "mgmt")
+ client2 = env.attach("client2", "mgmt")
+
+ _, hport_server = env.ltop.xlate("host", "data")
+ _, hport_client1 = env.ltop.xlate("host", "data1")
+ _, hport_client2 = env.ltop.xlate("host", "data2")
+
+ with test.step("Configure DUTs"):
+ util.parallel(
+ lambda: configure_server(server, server_public_key, server_private_key, client1_public_key, client2_public_key),
+ lambda: configure_router(router),
+ lambda: configure_client(client1, 1, client1_private_key, client1_public_key, server_public_key),
+ lambda: configure_client(client2, 2, client2_private_key, client2_public_key, server_public_key)
+ )
+
+ with test.step("Check on the server that both clients is connected"):
+ util.parallel(lambda: util.until(lambda: wg.is_peer_up(server, "wg0", client1_public_key)),
+ lambda: util.until(lambda: wg.is_peer_up(server, "wg0", client2_public_key)))
+
+ with infamy.IsolatedMacVlan(hport_server) as ns_server, \
+ infamy.IsolatedMacVlan(hport_client1) as ns_client1, \
+ infamy.IsolatedMacVlan(hport_client2) as ns_client2:
+ ns_server.addip("192.168.0.2")
+ ns_server.addroute("default", "192.168.0.1")
+
+ ns_client1.addip("192.168.1.2")
+ ns_client1.addroute("default", "192.168.1.1")
+
+ ns_client2.addip("192.168.2.2")
+ ns_client2.addroute("default", "192.168.2.1")
+
+ with test.step("Verify IPv4 connectivity with ping 192.168.0.2 from host:data1 and host:data2"):
+ util.parallel(
+ lambda: ns_client1.must_reach("192.168.0.2"),
+ lambda: ns_client2.must_reach("192.168.0.2")
+ )
+
+ test.succeed()
diff --git a/test/case/interfaces/wireguard_roadwarrior/topology.dot b/test/case/interfaces/wireguard_roadwarrior/topology.dot
new file mode 100644
index 000000000..3faa38297
--- /dev/null
+++ b/test/case/interfaces/wireguard_roadwarrior/topology.dot
@@ -0,0 +1,52 @@
+graph "roadwarrior" {
+ layout="neato";
+ overlap=false;
+ esep="+20";
+ splines=true;
+
+ node [shape=record, fontname="DejaVu Sans Mono, Book"];
+ edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
+
+ host [
+ label="{ mgmt1 | data } | host | { mgmt2 } | { data2 | mgmt3 | data1 | mgmt4 }",
+ pos="200,250!",
+ requires="controller",
+ ];
+
+ server [
+ label="{ data | mgmt } | { server | 192.168.100.1 } | wan",
+ pos="50,150!",
+ requires="infix",
+ ];
+
+ router [
+ label=" wan | { mgmt | router | (internet) } | { lan1 | lan2 }",
+ pos="200,150!",
+ requires="infix",
+ ];
+
+ client1 [
+ label=" link | { data | mgmt } | { client1 | 192.168.50.2 }",
+ pos="325,175!",
+ requires="infix",
+ ];
+
+ client2 [
+ label=" link | { mgmt | data } | { client2 | 192.168.51.2 }",
+ pos="325,125!",
+ requires="infix",
+ ];
+
+ host:mgmt1 -- server:mgmt [requires="mgmt", color="lightgray"];
+ host:mgmt2 -- router:mgmt [requires="mgmt", color="lightgray"];
+ host:mgmt3 -- client1:mgmt [requires="mgmt", color="lightgray"];
+ host:mgmt4 -- client2:mgmt [requires="mgmt", color="lightgray"];
+
+ server:wan -- router:wan [label="192.168.100.0/24", headlabel=".2", taillabel=".1", fontcolor="black", color="black"];
+ router:lan1 -- client1:link [label="192.168.50.0/24", headlabel=".2", taillabel=".1", fontcolor="black", color="black"];
+ router:lan2 -- client2:link [label="192.168.51.0/24", headlabel=".2", taillabel=".1", fontcolor="black", color="black"];
+
+ host:data -- server:data [headlabel=".1", label="192.168.0.0/24", taillabel=".2", labeldistance=1, fontcolor="black", color="black"];
+ host:data1 -- client1:data [headlabel=".1", label="192.168.1.0/24", taillabel=".2", labeldistance=1, fontcolor="black", color="black"];
+ host:data2 -- client2:data [headlabel=".1", label="192.168.2.0/24", taillabel=".2", labeldistance=1, fontcolor="black", color="black"];
+}
diff --git a/test/case/interfaces/wireguard_roadwarrior/topology.svg b/test/case/interfaces/wireguard_roadwarrior/topology.svg
new file mode 100644
index 000000000..73f5e21ba
--- /dev/null
+++ b/test/case/interfaces/wireguard_roadwarrior/topology.svg
@@ -0,0 +1,158 @@
+
+
+
+
+
diff --git a/test/case/ntp/client_stratum_selection/test.adoc b/test/case/ntp/client_stratum_selection/test.adoc
index 9c458894f..56f5ebc52 100644
--- a/test/case/ntp/client_stratum_selection/test.adoc
+++ b/test/case/ntp/client_stratum_selection/test.adoc
@@ -29,6 +29,7 @@ image::topology.svg[NTP client stratum selection topology, align=center, scaledw
. Wait for srv2 to sync from srv1
. Configure client to sync from both servers
. Wait for client to see both servers
+. Wait for srv2 stratum to stabilize
. Verify client selects srv1 (lower stratum)
diff --git a/test/case/routing/ospf_debug/test.adoc b/test/case/routing/ospf_debug/test.adoc
new file mode 100644
index 000000000..b42f26f87
--- /dev/null
+++ b/test/case/routing/ospf_debug/test.adoc
@@ -0,0 +1,33 @@
+=== OSPF Debug Logging
+
+ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospf_debug]
+
+==== Description
+
+Verifies OSPF debug logging by configuring two routers (R1 and R2) with
+OSPF on their interconnecting link. The test enables specific OSPF debug
+categories and verifies that appropriate debug messages appear in
+/var/log/debug.
+
+This test specifically validates:
+- Debug messages appear when debug options are enabled
+- No excessive debug messages when debug options are disabled
+- Individual categories (ism, nsm, packet) can be toggled independently
+
+==== Topology
+
+image::topology.svg[OSPF Debug Logging topology, align=center, scaledwidth=75%]
+
+==== Sequence
+
+. Set up topology and attach to target DUTs
+. Clean up old log files from previous test runs
+. Configure R1 and R2 without debug enabled
+. Wait for OSPF adjacency to form
+. Enable OSPF debug logging on R1
+. Verify OSPF debug messages appear in log file
+. Remove log file before disabling debug
+. Disable OSPF debug logging on R1
+. Verify no OSPF debug messages when disabled
+
+
diff --git a/test/case/routing/ospf_debug/topology.svg b/test/case/routing/ospf_debug/topology.svg
new file mode 100644
index 000000000..8128a4ae3
--- /dev/null
+++ b/test/case/routing/ospf_debug/topology.svg
@@ -0,0 +1,63 @@
+
+
+
+
+
diff --git a/test/case/routing/rip_multihop/test.adoc b/test/case/routing/rip_multihop/test.adoc
new file mode 100644
index 000000000..82b27fa95
--- /dev/null
+++ b/test/case/routing/rip_multihop/test.adoc
@@ -0,0 +1,29 @@
+=== RIP Multi-hop
+
+ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/rip_multihop]
+
+==== Description
+
+Verifies RIP functionality across multiple hops with three routers in a line
+topology (R1 -- R2 -- R3). This test ensures:
+- RIP routes propagate through multiple hops
+- R2 (middle router) has two RIP neighbors
+- End-to-end connectivity works across the RIP network
+
+Topology:
+ PC:data1 -- R1 -- R2 -- R3 -- PC:data2
+
+==== Topology
+
+image::topology.svg[RIP Multi-hop topology, align=center, scaledwidth=75%]
+
+==== Sequence
+
+. Set up topology and attach to target DUTs
+. Configure routers
+. Wait for RIP routes to be exchanged
+. Verify R2 has two RIP neighbors
+. Test end-to-end connectivity PC:data1 to R3 loopback
+. Test end-to-end connectivity PC:data2 to R1 loopback
+
+
diff --git a/test/infamy/iface.py b/test/infamy/iface.py
index 4b872ad33..2831bb680 100644
--- a/test/infamy/iface.py
+++ b/test/infamy/iface.py
@@ -98,6 +98,16 @@ def get_phys_address(target, iface):
return get_param(target, iface, "phys-address")
+def get_oper_status(target, iface):
+ """Get interface operational status (up/down/etc)"""
+ return get_param(target, iface, "oper-status")
+
+
+def is_oper_up(target, iface):
+ """Check if interface operational status is 'up'"""
+ return get_oper_status(target, iface) == "up"
+
+
def exist_bridge_multicast_filter(target, group, iface, bridge):
"""Check if a bridge has a multicast filter for group with iface"""
# The interface array is different in restconf/netconf, netconf has
diff --git a/test/infamy/wireguard.py b/test/infamy/wireguard.py
new file mode 100644
index 000000000..ba17c6b90
--- /dev/null
+++ b/test/infamy/wireguard.py
@@ -0,0 +1,90 @@
+"""
+Fetch WireGuard interface status from remote device.
+"""
+import json
+
+
+def get_peers(target, iface):
+ """Get all WireGuard peers from interface as a list"""
+ interface = target.get_iface(iface)
+ if interface is None:
+ return []
+
+ wg = interface.get("wireguard") or interface.get("infix-if-wireguard:wireguard")
+ if wg is None:
+ return []
+
+ # Peers are under 'peer-status' in operational state
+ peer_status = wg.get('peer-status')
+ if peer_status is None:
+ return []
+
+ # The peer-status container has a 'peer' list inside it
+ if isinstance(peer_status, dict):
+ peer_list = peer_status.get('peer', [])
+ if isinstance(peer_list, list):
+ return peer_list
+ # Single peer might not be in a list
+ if peer_list:
+ return [peer_list]
+ return []
+
+ # If peer_status is already a list
+ if isinstance(peer_status, list):
+ return peer_status
+
+ return []
+
+
+def get_peer_by_pubkey(target, iface, public_key):
+ """Get specific WireGuard peer by public key"""
+ peers = get_peers(target, iface)
+
+ for peer in peers:
+ if peer.get('public-key') == public_key:
+ return peer
+
+ return None
+
+
+def is_peer_up(target, iface, public_key):
+ """Check if WireGuard peer connection status is 'up'"""
+ peer = get_peer_by_pubkey(target, iface, public_key)
+ if peer is None:
+ return False
+
+ status = peer.get('connection-status')
+ if status is None:
+ return False
+
+ return status.lower() == 'up'
+
+
+def get_peer_endpoint(target, iface, public_key):
+ """Get WireGuard peer endpoint"""
+ peer = get_peer_by_pubkey(target, iface, public_key)
+ if peer is None:
+ return None
+
+ return peer.get('endpoint')
+
+
+def get_peer_transfer(target, iface, public_key):
+ """Get WireGuard peer transfer statistics (tx, rx in bytes)"""
+ peer = get_peer_by_pubkey(target, iface, public_key)
+ if peer is None:
+ return None, None
+
+ tx = peer.get('transfer-tx')
+ rx = peer.get('transfer-rx')
+
+ return tx, rx
+
+
+def get_peer_handshake(target, iface, public_key):
+ """Get WireGuard peer latest handshake timestamp"""
+ peer = get_peer_by_pubkey(target, iface, public_key)
+ if peer is None:
+ return None
+
+ return peer.get('latest-handshake')