diff --git a/scenarios/wireguard/.gitignore b/scenarios/wireguard/.gitignore new file mode 100644 index 0000000..ce6c6cd --- /dev/null +++ b/scenarios/wireguard/.gitignore @@ -0,0 +1,7 @@ +server/runtime/* +!server/runtime/.gitkeep + +client/wg0.conf + +*.key +*.conf.generated diff --git a/scenarios/wireguard/README.md b/scenarios/wireguard/README.md new file mode 100644 index 0000000..c2206ed --- /dev/null +++ b/scenarios/wireguard/README.md @@ -0,0 +1,118 @@ +## WireGuard External Service Example + +This scenario connects one external service to NSE2 through a WireGuard tunnel. +NSE2 runs the WireGuard server alongside the example nodes `n1` and `n2`. +The external host runs the WireGuard client and an application that shares the client's network namespace. + +This example uses the [LinuxServer.io WireGuard container](https://github.com/linuxserver/docker-wireguard). +Refer to its documentation for additional configuration options and container requirements. + + +```mermaid +flowchart LR + subgraph NSE2["NSE2 Host"] + n1["Node n1"] + n2["Node n2"] + server["WireGuard Server
Transparent Proxy"] + + n1 <--> server + n2 <--> server + end + + subgraph External["External Host"] + client["WireGuard Client"] + app["External Application"] + client --- app + end + + server <-->|"WireGuard Tunnel"| client +``` + +Traffic addressed to either of the `wg-server` Docker addresses is DNATed by `server/setup-iptables.sh` to the external peer's tunnel address. +The external application shares the WireGuard client's network namespace and therefore requires no additional routing configuration. + +This example supports one external WireGuard peer. + +### Configuration + +Before starting the server, review the following values in `compose.yml`: + +- `SERVERURL` must be an address through which the external host can reach the NSE2 host. Use `host.docker.internal` when testing both sides on the same machine. +- `SERVERPORT` must match the UDP port published by the `wg-server` service. +- `ALLOWEDIPS` must include every NSE2 network that the external service should be able to reach through the tunnel. + + +### Setup + +Start the NSE2-side services: + +```sh +docker compose up -d +``` + +The WireGuard server generates its keys and the external peer configuration +under `server/runtime`. The generated client configuration is: + +```text +server/runtime/peer_external/peer_external.conf +``` + +Copy this file to `client/wg0.conf` on the external host: + +```sh +scp server/runtime/peer_external/peer_external.conf \ + user@external-host:/path/to/examples/wireguard/client/wg0.conf +``` + +The configuration contains the external peer's private key and must not be +committed to Git. Restrict its permissions on the external host: + +```sh +chmod 600 client/wg0.conf +``` + +Start the WireGuard client and external application: + +```sh +docker compose -f compose-client.yml up -d +``` + + +### Testing + +Check that the tunnel has established a handshake: + +```sh +docker compose exec wg-server wg show +docker compose -f compose-client.yml exec wg-client wg show +``` + +The example application listens on port 80. Test it from both NSE2 nodes: + +```sh +docker compose exec n1 curl --fail http://172.30.0.2 +docker compose exec n2 curl --fail http://172.31.0.2 +``` + +Both requests should return: + +```text +Hello from external WireGuard service! +``` + +Within the Compose networks, Docker DNS can resolve the WireGuard server by its service name. +The application can therefore also be reached through `wg-server`: + +```sh +docker compose exec n1 curl --fail http://wg-server +docker compose exec n2 curl --fail http://wg-server +``` + +The generated client configuration routes the NSE2 Docker networks listed in `ALLOWEDIPS` through the WireGuard tunnel. +It also uses the DNS server configured by the WireGuard container, allowing the external side to resolve and connect to `n1` and `n2` by name. + +```sh +docker compose -f compose-client.yml exec wg-client ping n1 +docker compose -f compose-client.yml exec wg-client ping n2 +``` + diff --git a/scenarios/wireguard/compose-client.yml b/scenarios/wireguard/compose-client.yml new file mode 100644 index 0000000..1a85491 --- /dev/null +++ b/scenarios/wireguard/compose-client.yml @@ -0,0 +1,40 @@ +name: wireguard-external-service-client + +services: + wg-client: + image: lscr.io/linuxserver/wireguard:latest + container_name: external-wg-client + cap_add: + - NET_ADMIN + - SYS_MODULE + environment: + PUID: ${PUID:-1000} + PGID: ${PGID:-1000} + TZ: ${TZ:-Etc/UTC} + volumes: + - ./client/wg0.conf:/config/wg_confs/wg0.conf:ro + - /lib/modules:/lib/modules:ro + sysctls: + - net.ipv4.conf.all.src_valid_mark=1 + restart: unless-stopped + # Optional. Only needed when server and client run on the same host. Then + # the SERVER_URL in the .env needs to be `host.docker.internal` since that + # resolves to localhost of the host. + extra_hosts: + - "host.docker.internal:host-gateway" + + app: + image: alpine:latest + container_name: external-app + network_mode: service:wg-client + command: + - sh + - -c + - | + apk add --no-cache python3 + mkdir -p /var/www + echo '

Hello from external WireGuard service!

' > /var/www/index.html + python3 -m http.server 80 --directory /var/www + depends_on: + - wg-client + restart: unless-stopped diff --git a/scenarios/wireguard/compose.yml b/scenarios/wireguard/compose.yml new file mode 100644 index 0000000..6f1fcde --- /dev/null +++ b/scenarios/wireguard/compose.yml @@ -0,0 +1,86 @@ +name: wireguard-external-service-scenario + +services: + wg-server: + image: lscr.io/linuxserver/wireguard:latest + container_name: wg-server + cap_add: + - NET_ADMIN + - SYS_MODULE + environment: + PUID: 1000 + PGID: 1000 + TZ: Etc/UTC + PEERS: external + PERSISTENTKEEPALIVE_PEERS: external + + # Address and port written to the generated client configuration. + # Use host.docker.internal for same-host testing; otherwise, use an + # address reachable from the external host. + SERVERURL: host.docker.internal + SERVERPORT: 51820 + + # Networks the generated client configuration routes through WireGuard. + # Add any NSE2 network that the external service must be able to reach. + ALLOWEDIPS: 10.192.122.0/24,172.30.0.0/24,172.31.0.0/24 + + INTERNAL_SUBNET: 10.192.122.0 + volumes: + - ./server/runtime:/config + - ./server/templates/server.conf:/config/templates/server.conf:ro + - ./server/setup-iptables.sh:/config/setup-iptables.sh:ro + - /lib/modules:/lib/modules:ro + ports: + # The published UDP port must match SERVERPORT. + - "51820:51820/udp" + sysctls: + - net.ipv4.conf.all.src_valid_mark=1 + restart: unless-stopped + networks: + wg_n1: + ipv4_address: 172.30.0.2 + wg_n2: + ipv4_address: 172.31.0.2 + n1: + container_name: n1 + hostname: n1 + cap_add: + - NET_ADMIN + privileged: 'true' + volumes: + - ./compose.yml:/compose.yml:ro + environment: + NODE_ID: 1 + TYPE: Host + networks: + wg_n1: + ipv4_address: 172.30.0.3 + entrypoint: sh -c "apk add iproute2 bash curl && tail -f /dev/null" + image: alpine + + n2: + container_name: n2 + hostname: n2 + cap_add: + - NET_ADMIN + privileged: 'true' + volumes: + - ./compose.yml:/compose.yml:ro + environment: + NODE_ID: 2 + TYPE: Host + networks: + wg_n2: + ipv4_address: 172.31.0.3 + entrypoint: sh -c "apk add iproute2 bash curl && tail -f /dev/null" + image: alpine + +networks: + wg_n1: + ipam: + config: + - subnet: 172.30.0.0/24 + wg_n2: + ipam: + config: + - subnet: 172.31.0.0/24 diff --git a/scenarios/wireguard/server/runtime/.gitkeep b/scenarios/wireguard/server/runtime/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scenarios/wireguard/server/setup-iptables.sh b/scenarios/wireguard/server/setup-iptables.sh new file mode 100755 index 0000000..3e65bd0 --- /dev/null +++ b/scenarios/wireguard/server/setup-iptables.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# Configures iptables forwarding and NAT for a WireGuard gateway container. +# It automatically detects all attached Docker network interfaces, allows +# traffic between them and WireGuard, and redirects traffic addressed to the +# gateway's Docker IPs to its single WireGuard peer. +# Rules are added idempotently on "up" and removed on "down". + +set -euo pipefail + +if (( $# < 1 || $# > 2 )); then + echo "Usage: $0 {up|down} [wireguard-interface]" >&2 + exit 2 +fi + +ACTION="$1" +WG_INTERFACE="${2:-wg0}" +STATE_FILE="/run/iptables-${WG_INTERFACE}.state" + +# All container interfaces except loopback and WireGuard. +mapfile -t LAN_INTERFACES < <( + find /sys/class/net -mindepth 1 -maxdepth 1 -printf '%f\n' | + grep -vE "^(lo|${WG_INTERFACE})$" | + sort +) + +if [[ ${#LAN_INTERFACES[@]} -eq 0 ]]; then + echo "No Docker network interfaces found" >&2 + exit 1 +fi + +add_rule() { + local table="$1" + shift + + iptables -w -t "$table" -C "$@" 2>/dev/null || + iptables -w -t "$table" -A "$@" +} + +remove_rule() { + local table="$1" + shift + + while iptables -w -t "$table" -C "$@" 2>/dev/null; do + iptables -w -t "$table" -D "$@" + done +} + +configure_rules() { + # Determine the tunnel IP of the gateway's single WireGuard peer. + mapfile -t peer_ips < <( + wg show "$WG_INTERFACE" allowed-ips | + grep -oE '[0-9]+(\.[0-9]+){3}/32' | + cut -d/ -f1 + ) + + if [[ ${#peer_ips[@]} -ne 1 ]]; then + echo "Expected exactly one peer /32 address, found ${#peer_ips[@]}" >&2 + exit 1 + fi + + TARGET_PEER_IP="${peer_ips[0]}" + WIREGUARD_PORT="$(wg show "$WG_INTERFACE" listen-port)" + + if [[ -z "$WIREGUARD_PORT" || "$WIREGUARD_PORT" == "0" ]]; then + echo "Could not determine the WireGuard listening port" >&2 + exit 1 + fi + + # PostDown runs after the WireGuard interface is removed, so preserve the + # values required to remove the rules. + printf '%s\n%s\n' \ + "$TARGET_PEER_IP" \ + "$WIREGUARD_PORT" > "$STATE_FILE" + + for lan_interface in "${LAN_INTERFACES[@]}"; do + # Allow WireGuard peers to access this Docker network. + add_rule filter FORWARD \ + -i "$WG_INTERFACE" -o "$lan_interface" \ + -j ACCEPT + + # Allow response traffic from this Docker network back through WireGuard. + add_rule filter FORWARD \ + -i "$lan_interface" -o "$WG_INTERFACE" \ + -m conntrack --ctstate ESTABLISHED,RELATED \ + -j ACCEPT + + # Hide WireGuard source addresses behind this interface's address. + add_rule nat POSTROUTING \ + -o "$lan_interface" \ + -j MASQUERADE + + # Keep the outer WireGuard traffic on the gateway itself. + # This rule must precede the catch-all DNAT rule below. + add_rule nat PREROUTING \ + -i "$lan_interface" \ + -p udp --dport "$WIREGUARD_PORT" \ + -j ACCEPT + + # Redirect all other traffic addressed to the gateway to its peer. + add_rule nat PREROUTING \ + -i "$lan_interface" \ + -m addrtype --dst-type LOCAL \ + -j DNAT --to-destination "$TARGET_PEER_IP" + + # Allow DNAT-translated traffic to reach the peer through WireGuard. + add_rule filter FORWARD \ + -i "$lan_interface" -o "$WG_INTERFACE" \ + -d "$TARGET_PEER_IP" \ + -j ACCEPT + done + + # Allow response traffic from the remote peer. + add_rule filter FORWARD \ + -i "$WG_INTERFACE" \ + -s "$TARGET_PEER_IP" \ + -m conntrack --ctstate ESTABLISHED,RELATED \ + -j ACCEPT + + # Ensure the peer sends responses back through this gateway. + add_rule nat POSTROUTING \ + -o "$WG_INTERFACE" \ + -d "$TARGET_PEER_IP" \ + -j MASQUERADE +} + +remove_rules() { + if [[ ! -f "$STATE_FILE" ]]; then + echo "State file not found: $STATE_FILE" >&2 + exit 1 + fi + + mapfile -t state < "$STATE_FILE" + TARGET_PEER_IP="${state[0]}" + WIREGUARD_PORT="${state[1]}" + + # Remove shared peer rules first. + remove_rule nat POSTROUTING \ + -o "$WG_INTERFACE" \ + -d "$TARGET_PEER_IP" \ + -j MASQUERADE + + remove_rule filter FORWARD \ + -i "$WG_INTERFACE" \ + -s "$TARGET_PEER_IP" \ + -m conntrack --ctstate ESTABLISHED,RELATED \ + -j ACCEPT + + # Remove interface-specific rules in reverse order. + for lan_interface in "${LAN_INTERFACES[@]}"; do + remove_rule filter FORWARD \ + -i "$lan_interface" -o "$WG_INTERFACE" \ + -d "$TARGET_PEER_IP" \ + -j ACCEPT + + remove_rule nat PREROUTING \ + -i "$lan_interface" \ + -m addrtype --dst-type LOCAL \ + -j DNAT --to-destination "$TARGET_PEER_IP" + + remove_rule nat PREROUTING \ + -i "$lan_interface" \ + -p udp --dport "$WIREGUARD_PORT" \ + -j ACCEPT + + remove_rule nat POSTROUTING \ + -o "$lan_interface" \ + -j MASQUERADE + + remove_rule filter FORWARD \ + -i "$lan_interface" -o "$WG_INTERFACE" \ + -m conntrack --ctstate ESTABLISHED,RELATED \ + -j ACCEPT + + remove_rule filter FORWARD \ + -i "$WG_INTERFACE" -o "$lan_interface" \ + -j ACCEPT + done + + rm -f "$STATE_FILE" +} + +case "$ACTION" in + up) + configure_rules + ;; + down) + remove_rules + ;; + *) + echo "Usage: $0 {up|down} [wireguard-interface]" >&2 + exit 2 + ;; +esac diff --git a/scenarios/wireguard/server/templates/server.conf b/scenarios/wireguard/server/templates/server.conf new file mode 100644 index 0000000..2c6948f --- /dev/null +++ b/scenarios/wireguard/server/templates/server.conf @@ -0,0 +1,7 @@ +[Interface] +Address = ${INTERFACE}.1 +ListenPort = 51820 +PrivateKey = $(cat /config/server/privatekey-server) + +PostUp = /config/setup-iptables.sh up %i +PostDown = /config/setup-iptables.sh down %i