From d2fc6d444bb7fef9ad30dd0fb21ae38708e65e3f Mon Sep 17 00:00:00 2001 From: Stanislas Date: Sun, 2 Aug 2026 22:57:05 +0200 Subject: [PATCH] Add configurable VPN access policies (#1505) ## Summary - add independent install options for internet routing, client-to-client access, and explicit server-side networks - enforce the selected policy across firewalld, nftables, and iptables, including DCO traffic - use destination-scoped NAT for home LAN access and preserve client routes and DNS in split-tunnel mode - document the new defaults and add focused Docker policy coverage Defaults remain internet access enabled, client-to-client access disabled, and server-side network access disabled. Related: #1496 #443 #385 #624 #547 #1436 #1103 #1126 #575 #1434 #1213 #147 --- .github/workflows/docker-test.yml | 89 ++- FAQ.md | 99 +-- README.md | 26 +- docker-compose.yml | 4 + openvpn-install.sh | 1150 ++++++++++++++++++++++------- test/Dockerfile.client | 3 +- test/Dockerfile.server | 5 +- test/client-entrypoint.sh | 111 ++- test/local-network-detection.sh | 92 +++ test/policy-peer-entrypoint.sh | 35 + test/server-entrypoint.sh | 360 ++++++--- 11 files changed, 1498 insertions(+), 476 deletions(-) create mode 100755 test/local-network-detection.sh create mode 100755 test/policy-peer-entrypoint.sh diff --git a/.github/workflows/docker-test.yml b/.github/workflows/docker-test.yml index 051b6fc..94d0645 100644 --- a/.github/workflows/docker-test.yml +++ b/.github/workflows/docker-test.yml @@ -30,6 +30,7 @@ jobs: image: ubuntu:22.04 - name: ubuntu-24.04 image: ubuntu:24.04 + policy_e2e: deny - name: ubuntu-25.10 image: ubuntu:25.10 - name: debian-11 @@ -98,6 +99,7 @@ jobs: name: fedora-42-firewalld image: fedora:42 enable_firewalld: true + policy_e2e: deny tls: name: tls-crypt-v2 sig: crypt-v2 @@ -107,6 +109,7 @@ jobs: name: debian-12-nftables image: debian:12 enable_nftables: true + policy_e2e: deny tls: name: tls-crypt-v2 sig: crypt-v2 @@ -129,6 +132,42 @@ jobs: name: tls-crypt-v2 sig: crypt-v2 key_file: tls-crypt-v2.key + # Test split tunnel with packet-level peer and home-LAN access + - os: + name: ubuntu-24.04-access-policy + image: ubuntu:24.04 + route_internet: n + client_to_client: y + local_networks: 10.55.0.0/24 + policy_e2e: allow + tls: + name: tls-crypt-v2 + sig: crypt-v2 + key_file: tls-crypt-v2.key + - os: + name: fedora-42-firewalld-access-policy + image: fedora:42 + enable_firewalld: true + route_internet: n + client_to_client: y + local_networks: 10.55.0.0/24 + policy_e2e: allow + tls: + name: tls-crypt-v2 + sig: crypt-v2 + key_file: tls-crypt-v2.key + - os: + name: debian-12-nftables-access-policy + image: debian:12 + enable_nftables: true + route_internet: n + client_to_client: y + local_networks: 10.55.0.0/24 + policy_e2e: allow + tls: + name: tls-crypt-v2 + sig: crypt-v2 + key_file: tls-crypt-v2.key name: ${{ matrix.os.name }} steps: @@ -154,12 +193,24 @@ jobs: - name: Create Docker network run: docker network create --subnet=172.28.0.0/24 vpn-test + - name: Create policy test LAN + if: matrix.os.policy_e2e != '' + run: | + docker network create --subnet=10.55.0.0/24 policy-lan + docker run -d \ + --name policy-lan-target \ + --network policy-lan \ + --ip 10.55.0.20 \ + --entrypoint sleep \ + openvpn-client infinity + - name: Create shared volume run: docker volume create shared-config - name: Start OpenVPN server run: | - docker run -d \ + POLICY_E2E=${{ matrix.os.policy_e2e || '' }} + docker create \ --name openvpn-server \ --hostname openvpn-server \ --privileged \ @@ -178,8 +229,17 @@ jobs: -e TLS_KEY_FILE=${{ matrix.tls.key_file }} \ -e CLIENT_IPV6=${{ matrix.os.client_ipv6 && 'y' || 'n' }} \ -e AUTH_MODE=${{ matrix.os.auth_mode || 'pki' }} \ + -e ROUTE_INTERNET=${{ matrix.os.route_internet || 'y' }} \ + -e CLIENT_TO_CLIENT=${{ matrix.os.client_to_client || 'n' }} \ + -e LOCAL_NETWORKS=${{ matrix.os.local_networks || '' }} \ + -e POLICY_E2E="$POLICY_E2E" \ openvpn-server + if [ -n "$POLICY_E2E" ]; then + docker network connect --ip 10.55.0.10 --gw-priority -1 policy-lan openvpn-server + fi + docker start openvpn-server + - name: Wait for server installation and startup run: | echo "Waiting for OpenVPN server to install and client config to be ready..." @@ -252,6 +312,21 @@ jobs: docker run --rm -v shared-config:/shared alpine \ cat /shared/client.ovpn + - name: Start policy test peer + if: matrix.os.policy_e2e != '' + run: | + docker exec policy-lan-target ping -c 3 -W 2 10.55.0.10 + docker run -d \ + --name policy-peer \ + --hostname policy-peer \ + --cap-add=NET_ADMIN \ + --device=/dev/net/tun:/dev/net/tun \ + --network vpn-test \ + --ip 172.28.0.30 \ + -v shared-config:/shared \ + --entrypoint /policy-peer-entrypoint.sh \ + openvpn-client + - name: Start OpenVPN client and run tests run: | docker run \ @@ -262,6 +337,8 @@ jobs: --network vpn-test \ --ip 172.28.0.20 \ -v shared-config:/shared \ + -e POLICY_E2E=${{ matrix.os.policy_e2e || '' }} \ + -e POLICY_LAN_IP=10.55.0.20 \ openvpn-client & # Wait for tests to complete (look for success message) @@ -309,12 +386,14 @@ jobs: - name: Show client logs if: always() - run: docker logs openvpn-client 2>&1 || true + run: | + docker logs openvpn-client 2>&1 || true + docker logs policy-peer 2>&1 || true - name: Cleanup if: always() run: | - docker stop openvpn-server openvpn-client 2>/dev/null || true - docker rm openvpn-server openvpn-client 2>/dev/null || true - docker network rm vpn-test 2>/dev/null || true + docker stop openvpn-server openvpn-client policy-peer policy-lan-target 2>/dev/null || true + docker rm openvpn-server openvpn-client policy-peer policy-lan-target 2>/dev/null || true + docker network rm vpn-test policy-lan 2>/dev/null || true docker volume rm shared-config 2>/dev/null || true diff --git a/FAQ.md b/FAQ.md index 1d7b3ee..3963f74 100644 --- a/FAQ.md +++ b/FAQ.md @@ -86,7 +86,7 @@ down /usr/share/openvpn/contrib/pull-resolv-conf/client.down **Q:** What sysctl and firewall changes are made by the script? -**A:** If firewalld is active, the script uses `firewall-cmd --permanent` to configure port, masquerade, and rich rules. Otherwise, iptables rules are saved at `/etc/iptables/add-openvpn-rules.sh` and `/etc/iptables/rm-openvpn-rules.sh`, managed by `/etc/systemd/system/iptables-openvpn.service`. +**A:** If firewalld is active, the script uses `firewall-cmd --permanent` to configure scoped rich rules. If nftables is active, it creates `/etc/nftables/openvpn.nft`. Otherwise, iptables rules are saved at `/etc/iptables/add-openvpn-rules.sh` and `/etc/iptables/rm-openvpn-rules.sh`, managed by `/etc/systemd/system/iptables-openvpn.service`. Sysctl options are at `/etc/sysctl.d/99-openvpn.conf` @@ -94,7 +94,13 @@ Sysctl options are at `/etc/sysctl.d/99-openvpn.conf` **Q:** How can I access other clients connected to the same OpenVPN server? -**A:** Add `client-to-client` to your `server.conf` +**A:** Enable client-to-client access during installation: + +```bash +./openvpn-install.sh install --client-to-client +``` + +It is disabled by default. The installer configures both OpenVPN and the firewall so the policy also applies when Data Channel Offload (DCO) is active. --- @@ -110,36 +116,19 @@ Sysctl options are at `/etc/sysctl.d/99-openvpn.conf` **Q:** How can I access computers on the OpenVPN server's LAN? -**A:** Two steps are required: +**A:** Specify the LAN during installation: -1. **Push a route to clients** - Add the LAN subnet to `/etc/openvpn/server/server.conf`: +```bash +./openvpn-install.sh install --local-network 192.168.1.0/24 +``` - ``` - push "route 192.168.1.0 255.255.255.0" - ``` +Repeat `--local-network` to expose more than one server-side network. Using `--local-network` alone keeps the default full-tunnel internet routing enabled. Add `--no-route-internet` if only the selected server-side networks should use the VPN. - Replace `192.168.1.0/24` with your actual LAN subnet. +This feature is mainly for OpenVPN servers installed at home. During interactive installation, enabling LAN access shows directly connected private networks as one editable, comma-separated list. Review the list because it can include cloud VPC or container networks. LAN access remains disabled by default, and non-interactive installation never detects networks automatically. -2. **Enable routing back to VPN clients** - Choose one of these options: - - **Option A: Add a static route on your router** (recommended when you can configure your router) +The installer pushes the route, permits only the selected destination, and adds destination-scoped NAT. LAN computers therefore see the connection as coming from the OpenVPN server and do not need a return route to the VPN subnet. - On your LAN router, add a route for the VPN subnet (default `10.8.0.0/24`) pointing to the OpenVPN server's LAN IP. This allows LAN devices to reply to VPN clients without NAT. - - - **Option B: Masquerade VPN traffic to LAN** - - If you can't modify your router, add a masquerade rule so VPN traffic appears to come from the server: - - ```bash - # iptables - iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -d 192.168.1.0/24 -j MASQUERADE - - # or nftables - nft add rule ip nat postrouting ip saddr 10.8.0.0/24 ip daddr 192.168.1.0/24 masquerade - ``` - - Make this persistent by adding it to your firewall scripts. - -Restart OpenVPN after making changes: `systemctl restart openvpn-server@server` +Do not use a LAN CIDR that overlaps the VPN subnet. An overlap with the client's current LAN can also prevent the route from working. --- @@ -180,56 +169,32 @@ To add password-protected clients: --- -**Q:** For my clients - I want to set my internal network to pass through the VPN and the rest to go through my internet? +**Q:** For my clients, how can I route only an internal network through the VPN? -**A:** You would need to edit the `.ovpn` file. You can edit the template out of which those files are created by editing `/etc/openvpn/server/client-template.txt` file and adding +**A:** Disable internet routing and specify the server-side network during installation: -```sh -route-nopull -route 10.0.0.0 255.0.0.0 +```bash +./openvpn-install.sh install \ + --no-route-internet \ + --local-network 10.0.0.0/8 ``` -So for example - here it would route all traffic of `10.0.0.0/8` to the VPN. And the rest through the internet. +The client's normal internet route and DNS remain unchanged. --- -**Q:** How do I configure split-tunnel mode on the server (route only specific networks through VPN for all clients)? +**Q:** How do I configure split-tunnel mode on the server? -**A:** By default, the script configures full-tunnel mode where all client traffic goes through the VPN. To configure split-tunnel (only specific networks routed through VPN), edit `/etc/openvpn/server/server.conf`: +**A:** Use `--no-route-internet`. Add each server-side network that should use the tunnel: -1. Remove or comment out the redirect-gateway line: +```bash +./openvpn-install.sh install \ + --no-route-internet \ + --local-network 10.0.0.0/8 \ + --local-network 192.168.1.0/24 +``` - ``` - #push "redirect-gateway def1 bypass-dhcp" - ``` - -2. Add routes for the networks you want to tunnel: - - ``` - push "route 10.0.0.0 255.0.0.0" - push "route 192.168.1.0 255.255.255.0" - ``` - -3. Optionally remove DNS push directives if you don't want VPN DNS: - - ``` - #push "dhcp-option DNS 1.1.1.1" - ``` - -4. For IPv6, remove or comment out: - - ``` - #push "route-ipv6 2000::/3" - #push "redirect-gateway ipv6" - ``` - - Or add specific IPv6 routes: - - ``` - push "route-ipv6 2001:db8::/32" - ``` - -5. Restart OpenVPN: `systemctl restart openvpn-server@server` +The installer does not push internet routes, leak-blocking directives, or VPN DNS in this mode. --- diff --git a/README.md b/README.md index 68913a9..5fab409 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,23 @@ This script is meant to be run on your own server, whether it's a VPS or a dedic Once set up, you will be able to generate client configuration files for every device you want to connect. -Each client will be able to route its internet traffic through the server, fully encrypted. +Internet routing, access between VPN clients, and access to selected server-side networks can be configured independently. By default, internet routing is enabled and the other paths are disabled. ```mermaid -graph LR +flowchart LR A[Phone] -->|Encrypted| VPN B[Laptop] -->|Encrypted| VPN C[Computer] -->|Encrypted| VPN VPN[OpenVPN Server] - VPN --> I[Internet] + VPN -->|Internet routing
Default: enabled| I[Internet] + VPN -.->|Explicit CIDRs only
Default: disabled| LAN[Home LAN or cloud VPC] + VPN -.->|Client-to-client access
Default: disabled| PEERS[Other VPN clients] ``` +The solid destination path is enabled by default. Dashed destination paths are opt-in. + ## Why OpenVPN? OpenVPN was the de facto standard for open-source VPNs when this script was created. WireGuard came later and is simpler and faster for most use cases. Check out [wireguard-install](https://github.com/angristan/wireguard-install). @@ -44,6 +48,7 @@ That said, OpenVPN still makes sense when you need: - Immediate client disconnect on certificate revocation (via management interface) - Uses [official OpenVPN repositories](https://community.openvpn.net/openvpn/wiki/OpenvpnSoftwareRepos) when possible for the latest stable releases - Firewall rules and forwarding managed seamlessly (native firewalld and nftables support, iptables fallback) +- Independent access policies for internet routing, communication between VPN clients, and selected server-side networks - Configurable VPN subnets (IPv4: default `10.8.0.0/24`, IPv6: default `fd42:42:42:42::/112`) - Configurable tunnel MTU (default: `1500`) - If needed, the script can cleanly remove OpenVPN, including configuration and firewall rules @@ -263,6 +268,12 @@ The `install` command supports many options for customization: # Custom VPN subnet ./openvpn-install.sh install --subnet-ipv4 10.9.0.0 +# Home VPN: access the home LAN without routing internet through the VPN +./openvpn-install.sh install --no-route-internet --local-network 192.168.1.0/24 + +# Allow VPN clients to access each other +./openvpn-install.sh install --client-to-client + # Enable dual-stack (IPv4 + IPv6) for clients ./openvpn-install.sh install --client-ipv4 --client-ipv6 @@ -299,13 +310,22 @@ The `install` command supports many options for customization: - `--no-client-ipv6` - Disable IPv6 for VPN clients - `--subnet-ipv4 ` - IPv4 VPN subnet (default: `10.8.0.0`) - `--subnet-ipv6 ` - IPv6 VPN subnet (default: `fd42:42:42:42::`) +- `--route-internet` / `--no-route-internet` - Enable or disable routing client internet traffic through the VPN (default: enabled) +- `--client-to-client` / `--no-client-to-client` - Allow or isolate communication between VPN clients (default: isolated) +- `--local-network ` - Allow access to a server-side network and add destination-scoped NAT. Repeat for multiple networks (default: none) - `--port ` - OpenVPN port (default: `1194`) - `--port-random` - Use random port (49152-65535) - `--protocol ` - Protocol (default: `udp`) - `--mtu ` - Tunnel MTU (default: `1500`) +Server-side network access is mainly intended for VPN servers installed at home. In interactive mode, the installer suggests directly connected private IPv4 and IPv6 networks as one editable, comma-separated list after you enable LAN access. Review and confirm the list because it can include cloud VPC or container networks. No network is exposed unless you opt in. Non-interactive installs require an explicit `--local-network` for each network. LAN devices see connections as coming from the VPN server because destination-scoped NAT is enabled. + +Local networks must use network-aligned IPv4 or IPv6 CIDRs and must not overlap the VPN pools. Client-side and server-side LANs that overlap can still cause routing conflicts. + **DNS Options:** +DNS settings are pushed only when internet routing through the VPN is enabled. + - `--dns ` - DNS provider (default: `cloudflare`). Options: `system`, `unbound`, `cloudflare`, `quad9`, `quad9-uncensored`, `fdn`, `dnswatch`, `opendns`, `google`, `yandex`, `adguard`, `nextdns`, `custom` - `--dns-primary ` - Custom primary DNS (requires `--dns custom`) - `--dns-secondary ` - Custom secondary DNS (requires `--dns custom`) diff --git a/docker-compose.yml b/docker-compose.yml index f7f0411..78d4fac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,10 @@ services: cgroupns: host devices: - /dev/net/tun:/dev/net/tun + environment: + ROUTE_INTERNET: ${ROUTE_INTERNET:-y} + CLIENT_TO_CLIENT: ${CLIENT_TO_CLIENT:-n} + LOCAL_NETWORKS: ${LOCAL_NETWORKS:-} sysctls: - net.ipv4.ip_forward=1 volumes: diff --git a/openvpn-install.sh b/openvpn-install.sh index 57d70ae..6b98872 100755 --- a/openvpn-install.sh +++ b/openvpn-install.sh @@ -213,6 +213,11 @@ show_install_help() { --no-client-ipv6 Disable IPv6 for VPN clients (default) --subnet-ipv4 IPv4 VPN subnet (default: 10.8.0.0) --subnet-ipv6 IPv6 VPN subnet (default: fd42:42:42:42::) + --route-internet Route client internet traffic through VPN (default) + --no-route-internet Keep client internet traffic outside VPN + --client-to-client Allow VPN clients to access each other + --no-client-to-client Isolate VPN clients from each other (default) + --local-network Allow access to a server-side network (repeatable) --port OpenVPN port (default: 1194) --port-random Use random port (49152-65535) --protocol Protocol: udp or tcp (default: udp) @@ -486,6 +491,11 @@ readonly AUTH_MODES=("pki" "fingerprint") # HMAC algorithms readonly HMAC_ALGS=("SHA256" "SHA384" "SHA512") +# Networks that internet access must not implicitly expose. Explicit local +# networks are allowed before these deny rules are evaluated. +readonly PROTECTED_IPV4_NETWORKS=("10.0.0.0/8" "100.64.0.0/10" "127.0.0.0/8" "169.254.0.0/16" "172.16.0.0/12" "192.168.0.0/16") +readonly PROTECTED_IPV6_NETWORKS=("::1/128" "fc00::/7" "fe80::/10") + # TLS 1.3 cipher suite options readonly TLS13_OPTIONS=("all" "aes-256-only" "aes-128-only" "chacha20-only") @@ -503,6 +513,9 @@ set_installation_defaults() { CLIENT_IPV6="${CLIENT_IPV6:-n}" VPN_SUBNET_IPV4="${VPN_SUBNET_IPV4:-10.8.0.0}" VPN_SUBNET_IPV6="${VPN_SUBNET_IPV6:-fd42:42:42:42::}" + ROUTE_INTERNET="${ROUTE_INTERNET:-y}" + CLIENT_TO_CLIENT="${CLIENT_TO_CLIENT:-n}" + LOCAL_NETWORKS="${LOCAL_NETWORKS:-}" PORT="${PORT:-1194}" PROTOCOL="${PROTOCOL:-udp}" @@ -597,6 +610,281 @@ validate_subnet_ipv6() { fi } +is_valid_ipv4_cidr() { + local cidr="$1" address prefix extra + local -a octets + + [[ $cidr == */* ]] || return 1 + address="${cidr%/*}" + prefix="${cidr##*/}" + [[ $prefix =~ ^(0|[1-9][0-9]?)$ ]] || return 1 + prefix=$((10#$prefix)) + ((prefix >= 1 && prefix <= 32)) || return 1 + + IFS='.' read -r -a octets <<<"$address" + [[ ${#octets[@]} -eq 4 ]] || return 1 + for extra in "${octets[@]}"; do + [[ $extra =~ ^(0|[1-9][0-9]{0,2})$ ]] || return 1 + extra=$((10#$extra)) + ((extra >= 0 && extra <= 255)) || return 1 + done + + local ip mask + ip=$(((10#${octets[0]} << 24) | (10#${octets[1]} << 16) | (10#${octets[2]} << 8) | 10#${octets[3]})) + if ((prefix == 0)); then + mask=0 + else + mask=$(((0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF)) + fi + (((ip & mask) == ip)) +} + +expand_ipv6_address() { + local address="$1" + local -n result_ref="$2" + local left right remainder part + local -a left_parts=() right_parts=() + + [[ $address == *:* ]] || return 1 + [[ $address =~ ^[0-9a-fA-F:]+$ ]] || return 1 + + if [[ $address == *::* ]]; then + remainder="${address#*::}" + [[ $remainder != *::* ]] || return 1 + left="${address%%::*}" + right="${address#*::}" + [[ -z $left ]] || IFS=':' read -r -a left_parts <<<"$left" + [[ -z $right ]] || IFS=':' read -r -a right_parts <<<"$right" + ((${#left_parts[@]} + ${#right_parts[@]} < 8)) || return 1 + else + IFS=':' read -r -a left_parts <<<"$address" + [[ ${#left_parts[@]} -eq 8 ]] || return 1 + fi + + for part in "${left_parts[@]}" "${right_parts[@]}"; do + [[ $part =~ ^[0-9a-fA-F]{1,4}$ ]] || return 1 + done + + result_ref=() + for part in "${left_parts[@]}"; do + result_ref+=("$((16#$part))") + done + while ((${#result_ref[@]} + ${#right_parts[@]} < 8)); do + result_ref+=(0) + done + for part in "${right_parts[@]}"; do + result_ref+=("$((16#$part))") + done + [[ ${#result_ref[@]} -eq 8 ]] +} + +is_valid_ipv6_cidr() { + local cidr="$1" address prefix_text prefix index remaining host_mask + local -a hextets + + [[ $cidr == */* ]] || return 1 + address="${cidr%/*}" + prefix_text="${cidr##*/}" + [[ $prefix_text =~ ^(0|[1-9][0-9]{0,2})$ ]] || return 1 + prefix=$((10#$prefix_text)) + ((prefix >= 1 && prefix <= 128)) || return 1 + expand_ipv6_address "$address" hextets || return 1 + + remaining=$prefix + for index in "${!hextets[@]}"; do + if ((remaining >= 16)); then + remaining=$((remaining - 16)) + elif ((remaining <= 0)); then + ((hextets[index] == 0)) || return 1 + else + host_mask=$(((1 << (16 - remaining)) - 1)) + (((hextets[index] & host_mask) == 0)) || return 1 + remaining=0 + fi + done +} + +is_valid_local_network() { + is_valid_ipv4_cidr "$1" || is_valid_ipv6_cidr "$1" +} + +is_private_ipv4_network() { + local cidr="$1" address prefix first second + is_valid_ipv4_cidr "$cidr" || return 1 + + address="${cidr%/*}" + prefix=$((10#${cidr##*/})) + IFS='.' read -r first second _ <<<"$address" + + case "$first" in + 10) + ((prefix >= 8)) + ;; + 172) + ((second >= 16 && second <= 31 && prefix >= 12)) + ;; + 192) + ((second == 168 && prefix >= 16)) + ;; + *) + return 1 + ;; + esac +} + +is_private_ipv6_network() { + local cidr="$1" address prefix + local -a hextets + is_valid_ipv6_cidr "$cidr" || return 1 + + address="${cidr%/*}" + prefix=$((10#${cidr##*/})) + expand_ipv6_address "$address" hextets || return 1 + ((prefix >= 7 && (hextets[0] & 0xFE00) == 0xFC00)) +} + +interface_has_public_ipv4() { + local interface="$1" address + while read -r _ _ _ address _; do + [[ -n $address ]] || continue + is_private_ipv4_network "${address%/*}/32" || return 0 + done < <(ip -4 -o address show dev "$interface" scope global 2>/dev/null || true) + return 1 +} + +detect_private_local_networks() { + local detect_ipv4="${1:-y}" detect_ipv6="${2:-y}" route network interface + local -a detected_networks=() + + if [[ $detect_ipv4 == "y" ]]; then + while IFS= read -r route; do + [[ " $route " == *" via "* ]] && continue + network="${route%% *}" + is_private_ipv4_network "$network" || continue + [[ $route == *" dev "* ]] || continue + interface="${route#* dev }" + interface="${interface%% *}" + interface_has_public_ipv4 "$interface" && continue + if [[ -n ${VPN_SUBNET_IPV4:-} ]] && ipv4_cidrs_overlap "$network" "${VPN_SUBNET_IPV4}/24"; then + continue + fi + if ((${#detected_networks[@]} == 0)) || [[ " ${detected_networks[*]} " != *" $network "* ]]; then + detected_networks+=("$network") + fi + done < <(ip -4 -o route show type unicast 2>/dev/null || true) + fi + + if [[ $detect_ipv6 == "y" ]]; then + while IFS= read -r route; do + [[ " $route " == *" via "* ]] && continue + network="${route%% *}" + is_private_ipv6_network "$network" || continue + if [[ -n ${VPN_SUBNET_IPV6:-} ]] && ipv6_cidrs_overlap "$network" "${VPN_SUBNET_IPV6}/112"; then + continue + fi + if ((${#detected_networks[@]} == 0)) || [[ " ${detected_networks[*]} " != *" $network "* ]]; then + detected_networks+=("$network") + fi + done < <(ip -6 -o route show type unicast 2>/dev/null || true) + fi + + ((${#detected_networks[@]} > 0)) || return 0 + local IFS=, + printf '%s\n' "${detected_networks[*]}" +} + +add_local_network() { + local network="${1//[[:space:]]/}" + is_valid_local_network "$network" || log_fatal "Invalid local network: $1. Use a network CIDR such as 192.168.1.0/24 or fd00:1::/64." + + if [[ -z $LOCAL_NETWORKS ]]; then + LOCAL_NETWORKS="$network" + elif [[ ",$LOCAL_NETWORKS," != *",$network,"* ]]; then + LOCAL_NETWORKS+=",$network" + fi +} + +normalize_local_networks() { + local configured="${LOCAL_NETWORKS//[[:space:]]/}" network + LOCAL_NETWORKS="" + [[ -z $configured ]] && return + + while IFS= read -r network; do + add_local_network "$network" + done < <(tr ',' '\n' <<<"$configured") +} + +local_networks_for_family() { + local family="$1" network + [[ -z $LOCAL_NETWORKS ]] && return + + while IFS= read -r network; do + if [[ $family == "4" && $network == *.* ]] || [[ $family == "6" && $network == *:* ]]; then + echo "$network" + fi + done < <(tr ',' '\n' <<<"$LOCAL_NETWORKS") +} + +has_local_network_family() { + [[ -n $(local_networks_for_family "$1") ]] +} + +ipv4_prefix_to_netmask() { + local prefix="$1" mask + if ((prefix == 0)); then + mask=0 + else + mask=$(((0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF)) + fi + printf '%d.%d.%d.%d\n' \ + $(((mask >> 24) & 255)) \ + $(((mask >> 16) & 255)) \ + $(((mask >> 8) & 255)) \ + $((mask & 255)) +} + +ipv4_cidrs_overlap() { + local first="$1" second="$2" first_address second_address first_prefix second_prefix prefix mask + local -a first_octets second_octets + first_address="${first%/*}" + second_address="${second%/*}" + first_prefix=$((10#${first##*/})) + second_prefix=$((10#${second##*/})) + prefix=$first_prefix + ((second_prefix < prefix)) && prefix=$second_prefix + IFS='.' read -r -a first_octets <<<"$first_address" + IFS='.' read -r -a second_octets <<<"$second_address" + mask=$(((0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF)) + local first_ip=$(((10#${first_octets[0]} << 24) | (10#${first_octets[1]} << 16) | (10#${first_octets[2]} << 8) | 10#${first_octets[3]})) + local second_ip=$(((10#${second_octets[0]} << 24) | (10#${second_octets[1]} << 16) | (10#${second_octets[2]} << 8) | 10#${second_octets[3]})) + (((first_ip & mask) == (second_ip & mask))) +} + +ipv6_cidrs_overlap() { + local first="$1" second="$2" first_prefix second_prefix prefix index remaining mask + local -a first_hextets second_hextets + first_prefix=$((10#${first##*/})) + second_prefix=$((10#${second##*/})) + prefix=$first_prefix + ((second_prefix < prefix)) && prefix=$second_prefix + expand_ipv6_address "${first%/*}" first_hextets || return 1 + expand_ipv6_address "${second%/*}" second_hextets || return 1 + + remaining=$prefix + for index in "${!first_hextets[@]}"; do + ((remaining <= 0)) && return 0 + if ((remaining >= 16)); then + ((first_hextets[index] == second_hextets[index])) || return 1 + remaining=$((remaining - 16)) + else + mask=$(((0xFFFF << (16 - remaining)) & 0xFFFF)) + (((first_hextets[index] & mask) == (second_hextets[index] & mask))) + return + fi + done + return 0 +} + validate_positive_int() { local value="$1" local name="$2" @@ -643,9 +931,12 @@ validate_configuration() { *) log_fatal "Invalid protocol: $PROTOCOL. Must be 'udp' or 'tcp'." ;; esac - # Validate DNS + # Validate DNS. Split-tunnel installs do not push a DNS server. case "$DNS" in system | unbound | cloudflare | quad9 | quad9-uncensored | fdn | dnswatch | opendns | google | yandex | adguard | nextdns | custom) ;; + "") + [[ $ROUTE_INTERNET == "n" ]] || log_fatal "A DNS provider is required when internet routing is enabled." + ;; *) log_fatal "Invalid DNS provider: $DNS. Valid providers: system, unbound, cloudflare, quad9, quad9-uncensored, fdn, dnswatch, opendns, google, yandex, adguard, nextdns, custom" ;; esac @@ -686,6 +977,31 @@ validate_configuration() { log_fatal "At least one of CLIENT_IPV4 or CLIENT_IPV6 must be 'y'" fi + case "$ROUTE_INTERNET" in + y | n) ;; + *) log_fatal "Invalid ROUTE_INTERNET value: $ROUTE_INTERNET. Must be 'y' or 'n'." ;; + esac + case "$CLIENT_TO_CLIENT" in + y | n) ;; + *) log_fatal "Invalid CLIENT_TO_CLIENT value: $CLIENT_TO_CLIENT. Must be 'y' or 'n'." ;; + esac + + normalize_local_networks + if has_local_network_family 4 && [[ $CLIENT_IPV4 != "y" ]]; then + log_fatal "IPv4 local networks require IPv4 for VPN clients. Use --client-ipv4 or remove the IPv4 local network." + fi + if has_local_network_family 6 && [[ $CLIENT_IPV6 != "y" ]]; then + log_fatal "IPv6 local networks require IPv6 for VPN clients. Use --client-ipv6 or remove the IPv6 local network." + fi + local local_network + while IFS= read -r local_network; do + if [[ $local_network == *.* ]] && ipv4_cidrs_overlap "$local_network" "$VPN_SUBNET_IPV4/24"; then + log_fatal "Local network $local_network overlaps the IPv4 VPN subnet $VPN_SUBNET_IPV4/24." + elif [[ $local_network == *:* ]] && ipv6_cidrs_overlap "$local_network" "${VPN_SUBNET_IPV6}/112"; then + log_fatal "Local network $local_network overlaps the IPv6 VPN subnet ${VPN_SUBNET_IPV6}/112." + fi + done < <(tr ',' '\n' <<<"$LOCAL_NETWORKS") + # Validate ENDPOINT_TYPE case "$ENDPOINT_TYPE" in 4 | 6) ;; @@ -950,6 +1266,27 @@ cmd_install() { VPN_SUBNET_IPV4="$2" shift 2 ;; + --route-internet) + ROUTE_INTERNET=y + shift + ;; + --no-route-internet) + ROUTE_INTERNET=n + shift + ;; + --client-to-client) + CLIENT_TO_CLIENT=y + shift + ;; + --no-client-to-client) + CLIENT_TO_CLIENT=n + shift + ;; + --local-network) + [[ -z "${2:-}" ]] && log_fatal "--local-network requires an argument" + add_local_network "$2" + shift 2 + ;; --port) [[ -z "${2:-}" ]] && log_fatal "--port requires an argument" validate_port "$2" @@ -1157,13 +1494,18 @@ cmd_install() { # Set all defaults for any unset values set_installation_defaults - # Validate configuration values (catches invalid env vars) - validate_configuration - # Detect IPs and set up network config (interactive mode does this in installQuestions) detect_server_ips fi + # Split-tunnel installs leave the client's DNS configuration unchanged. + if [[ $ROUTE_INTERNET == "n" ]]; then + DNS="" + fi + + # Validate both CLI and interactive configuration. + validate_configuration + # Prepare derived network configuration (gateways, etc.) prepare_network_config @@ -2307,6 +2649,45 @@ function installQuestions() { esac fi + # ========================================================================== + # Step 7: Client access policy + # ========================================================================== + log_menu "" + log_prompt "What should VPN clients be allowed to access?" + prompt_yes_no "Route client internet traffic through the VPN?" "y" ROUTE_INTERNET + prompt_yes_no "Allow VPN clients to access each other?" "n" CLIENT_TO_CLIENT + + local local_network_access + prompt_yes_no "Allow VPN clients to access the server's local network? (mainly for home servers)" "n" local_network_access + if [[ $local_network_access == "y" ]]; then + local detected_local_networks + detected_local_networks=$(detect_private_local_networks "$CLIENT_IPV4" "$CLIENT_IPV6") + log_prompt "Enter the server-side networks clients may access." + log_prompt "Use comma-separated CIDRs, for example: 192.168.1.0/24,fd00:1::/64" + if [[ -n $detected_local_networks ]]; then + log_prompt "Detected local networks: $detected_local_networks" + log_prompt "Review the list and remove any network that VPN clients should not access." + fi + until [[ -n $LOCAL_NETWORKS ]]; do + local configured_networks network networks_valid=true + read -rp "Local networks: " -e -i "$detected_local_networks" configured_networks + while IFS= read -r network; do + network="${network//[[:space:]]/}" + if [[ -z $network ]] || ! is_valid_local_network "$network"; then + log_warn "Invalid network CIDR: ${network:-}" + networks_valid=false + break + fi + done < <(tr ',' '\n' <<<"$configured_networks") + if [[ $networks_valid == true ]]; then + LOCAL_NETWORKS="$configured_networks" + normalize_local_networks + fi + done + else + LOCAL_NETWORKS="" + fi + log_menu "" log_prompt "What port do you want OpenVPN to listen to?" log_menu " 1) Default: 1194" @@ -2346,44 +2727,49 @@ function installQuestions() { PROTOCOL="tcp" ;; esac - log_menu "" - log_prompt "What DNS resolvers do you want to use with the VPN?" - local dns_labels=("Current system resolvers (from /etc/resolv.conf)" "Self-hosted DNS Resolver (Unbound)" "Cloudflare (Anycast: worldwide)" "Quad9 (Anycast: worldwide)" "Quad9 uncensored (Anycast: worldwide)" "FDN (France)" "DNS.WATCH (Germany)" "OpenDNS (Anycast: worldwide)" "Google (Anycast: worldwide)" "Yandex Basic (Russia)" "AdGuard DNS (Anycast: worldwide)" "NextDNS (Anycast: worldwide)" "Custom") - local dns_valid=false - until [[ $dns_valid == true ]]; do - select_with_labels "DNS" dns_labels DNS_PROVIDERS "cloudflare" DNS - if [[ $DNS == "unbound" ]] && [[ -e /etc/unbound/unbound.conf ]]; then - log_menu "" - log_prompt "Unbound is already installed." - log_prompt "You can allow the script to configure it in order to use it from your OpenVPN clients" - log_prompt "We will simply add a second server to /etc/unbound/unbound.conf for the OpenVPN subnet." - log_prompt "No changes are made to the current configuration." - log_menu "" + if [[ $ROUTE_INTERNET == "y" ]]; then + log_menu "" + log_prompt "What DNS resolvers do you want to use with the VPN?" + local dns_labels=("Current system resolvers (from /etc/resolv.conf)" "Self-hosted DNS Resolver (Unbound)" "Cloudflare (Anycast: worldwide)" "Quad9 (Anycast: worldwide)" "Quad9 uncensored (Anycast: worldwide)" "FDN (France)" "DNS.WATCH (Germany)" "OpenDNS (Anycast: worldwide)" "Google (Anycast: worldwide)" "Yandex Basic (Russia)" "AdGuard DNS (Anycast: worldwide)" "NextDNS (Anycast: worldwide)" "Custom") + local dns_valid=false + until [[ $dns_valid == true ]]; do + select_with_labels "DNS" dns_labels DNS_PROVIDERS "cloudflare" DNS + if [[ $DNS == "unbound" ]] && [[ -e /etc/unbound/unbound.conf ]]; then + log_menu "" + log_prompt "Unbound is already installed." + log_prompt "You can allow the script to configure it in order to use it from your OpenVPN clients" + log_prompt "We will simply add a second server to /etc/unbound/unbound.conf for the OpenVPN subnet." + log_prompt "No changes are made to the current configuration." + log_menu "" - local unbound_continue - until [[ $unbound_continue =~ ^[yn]$ ]]; do - read -rp "Apply configuration changes to Unbound? [y/n]: " -e unbound_continue - done - if [[ $unbound_continue == "n" ]]; then - unset DNS + local unbound_continue + until [[ $unbound_continue =~ ^[yn]$ ]]; do + read -rp "Apply configuration changes to Unbound? [y/n]: " -e unbound_continue + done + if [[ $unbound_continue == "n" ]]; then + unset DNS + else + dns_valid=true + fi + elif [[ $DNS == "custom" ]]; then + until [[ $DNS1 =~ ^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$ ]]; do + read -rp "Primary DNS: " -e DNS1 + done + until [[ $DNS2 =~ ^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$ ]]; do + read -rp "Secondary DNS (optional): " -e DNS2 + if [[ $DNS2 == "" ]]; then + break + fi + done + dns_valid=true else dns_valid=true fi - elif [[ $DNS == "custom" ]]; then - until [[ $DNS1 =~ ^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$ ]]; do - read -rp "Primary DNS: " -e DNS1 - done - until [[ $DNS2 =~ ^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$ ]]; do - read -rp "Secondary DNS (optional): " -e DNS2 - if [[ $DNS2 == "" ]]; then - break - fi - done - dns_valid=true - else - dns_valid=true - fi - done + done + else + DNS="" + log_info "VPN DNS is not configured because internet routing is disabled." + fi log_menu "" log_prompt "Do you want to allow a single .ovpn profile to be used on multiple devices simultaneously?" log_prompt "Note: Enabling this disables persistent IP addresses for clients." @@ -2629,6 +3015,9 @@ function installOpenVPN() { log_info " CLIENT_IPV6=$CLIENT_IPV6" log_info " VPN_SUBNET_IPV4=$VPN_SUBNET_IPV4" log_info " VPN_SUBNET_IPV6=$VPN_SUBNET_IPV6" + log_info " ROUTE_INTERNET=$ROUTE_INTERNET" + log_info " CLIENT_TO_CLIENT=$CLIENT_TO_CLIENT" + log_info " LOCAL_NETWORKS=${LOCAL_NETWORKS:-none}" log_info " PORT=$PORT" log_info " PROTOCOL=$PROTOCOL" log_info " DNS=$DNS" @@ -2862,6 +3251,9 @@ function installOpenVPN() { if [[ $MULTI_CLIENT == "y" ]]; then echo "duplicate-cn" >>/etc/openvpn/server/server.conf fi + if [[ $CLIENT_TO_CLIENT == "y" ]]; then + echo "client-to-client" >>/etc/openvpn/server/server.conf + fi echo "dev tun" >>/etc/openvpn/server/server.conf # Only add user/group if systemd doesn't handle it (avoids double privilege drop) @@ -2892,152 +3284,168 @@ topology subnet" >>/etc/openvpn/server/server.conf echo "ifconfig-pool-persist ipp.txt" >>/etc/openvpn/server/server.conf fi - # DNS resolvers - case $DNS in - system) - # Locate the proper resolv.conf - # Needed for systems running systemd-resolved - if grep -q "127.0.0.53" "/etc/resolv.conf"; then - RESOLVCONF='/run/systemd/resolve/resolv.conf' - else - RESOLVCONF='/etc/resolv.conf' - fi - # Obtain the resolvers from resolv.conf and use them for OpenVPN - sed -ne 's/^nameserver[[:space:]]\+\([^[:space:]]\+\).*$/\1/p' $RESOLVCONF | while read -r line; do - # Copy IPv4 resolvers if client has IPv4, or IPv6 resolvers if client has IPv6 - if [[ $line =~ ^[0-9.]*$ ]] && [[ $CLIENT_IPV4 == 'y' ]]; then - echo "push \"dhcp-option DNS $line\"" >>/etc/openvpn/server/server.conf - elif [[ $line =~ : ]] && [[ $CLIENT_IPV6 == 'y' ]]; then - echo "push \"dhcp-option DNS $line\"" >>/etc/openvpn/server/server.conf + # DNS resolvers are only pushed when the VPN carries internet traffic. + if [[ $ROUTE_INTERNET == "y" ]]; then + case $DNS in + system) + # Locate the proper resolv.conf + # Needed for systems running systemd-resolved + if grep -q "127.0.0.53" "/etc/resolv.conf"; then + RESOLVCONF='/run/systemd/resolve/resolv.conf' + else + RESOLVCONF='/etc/resolv.conf' fi - done - ;; - unbound) - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo "push \"dhcp-option DNS $VPN_GATEWAY_IPV4\"" >>/etc/openvpn/server/server.conf - fi - if [[ $CLIENT_IPV6 == 'y' ]]; then - echo "push \"dhcp-option DNS $VPN_GATEWAY_IPV6\"" >>/etc/openvpn/server/server.conf - fi - ;; - cloudflare) - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo 'push "dhcp-option DNS 1.0.0.1"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 1.1.1.1"' >>/etc/openvpn/server/server.conf - fi - if [[ $CLIENT_IPV6 == 'y' ]]; then - echo 'push "dhcp-option DNS 2606:4700:4700::1001"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 2606:4700:4700::1111"' >>/etc/openvpn/server/server.conf - fi - ;; - quad9) - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo 'push "dhcp-option DNS 9.9.9.9"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 149.112.112.112"' >>/etc/openvpn/server/server.conf - fi - if [[ $CLIENT_IPV6 == 'y' ]]; then - echo 'push "dhcp-option DNS 2620:fe::fe"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 2620:fe::9"' >>/etc/openvpn/server/server.conf - fi - ;; - quad9-uncensored) - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo 'push "dhcp-option DNS 9.9.9.10"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 149.112.112.10"' >>/etc/openvpn/server/server.conf - fi - if [[ $CLIENT_IPV6 == 'y' ]]; then - echo 'push "dhcp-option DNS 2620:fe::10"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 2620:fe::fe:10"' >>/etc/openvpn/server/server.conf - fi - ;; - fdn) - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo 'push "dhcp-option DNS 80.67.169.40"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 80.67.169.12"' >>/etc/openvpn/server/server.conf - fi - if [[ $CLIENT_IPV6 == 'y' ]]; then - echo 'push "dhcp-option DNS 2001:910:800::40"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 2001:910:800::12"' >>/etc/openvpn/server/server.conf - fi - ;; - dnswatch) - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo 'push "dhcp-option DNS 84.200.69.80"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 84.200.70.40"' >>/etc/openvpn/server/server.conf - fi - if [[ $CLIENT_IPV6 == 'y' ]]; then - echo 'push "dhcp-option DNS 2001:1608:10:25::1c04:b12f"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 2001:1608:10:25::9249:d69b"' >>/etc/openvpn/server/server.conf - fi - ;; - opendns) - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo 'push "dhcp-option DNS 208.67.222.222"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 208.67.220.220"' >>/etc/openvpn/server/server.conf - fi - if [[ $CLIENT_IPV6 == 'y' ]]; then - echo 'push "dhcp-option DNS 2620:119:35::35"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 2620:119:53::53"' >>/etc/openvpn/server/server.conf - fi - ;; - google) - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo 'push "dhcp-option DNS 8.8.8.8"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 8.8.4.4"' >>/etc/openvpn/server/server.conf - fi - if [[ $CLIENT_IPV6 == 'y' ]]; then - echo 'push "dhcp-option DNS 2001:4860:4860::8888"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 2001:4860:4860::8844"' >>/etc/openvpn/server/server.conf - fi - ;; - yandex) - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo 'push "dhcp-option DNS 77.88.8.8"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 77.88.8.1"' >>/etc/openvpn/server/server.conf - fi - if [[ $CLIENT_IPV6 == 'y' ]]; then - echo 'push "dhcp-option DNS 2a02:6b8::feed:0ff"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 2a02:6b8:0:1::feed:0ff"' >>/etc/openvpn/server/server.conf - fi - ;; - adguard) - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo 'push "dhcp-option DNS 94.140.14.14"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 94.140.15.15"' >>/etc/openvpn/server/server.conf - fi - if [[ $CLIENT_IPV6 == 'y' ]]; then - echo 'push "dhcp-option DNS 2a10:50c0::ad1:ff"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 2a10:50c0::ad2:ff"' >>/etc/openvpn/server/server.conf - fi - ;; - nextdns) - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo 'push "dhcp-option DNS 45.90.28.167"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 45.90.30.167"' >>/etc/openvpn/server/server.conf - fi - if [[ $CLIENT_IPV6 == 'y' ]]; then - echo 'push "dhcp-option DNS 2a07:a8c0::"' >>/etc/openvpn/server/server.conf - echo 'push "dhcp-option DNS 2a07:a8c1::"' >>/etc/openvpn/server/server.conf - fi - ;; - custom) - echo "push \"dhcp-option DNS $DNS1\"" >>/etc/openvpn/server/server.conf - if [[ $DNS2 != "" ]]; then - echo "push \"dhcp-option DNS $DNS2\"" >>/etc/openvpn/server/server.conf - fi - ;; - esac + # Obtain the resolvers from resolv.conf and use them for OpenVPN + sed -ne 's/^nameserver[[:space:]]\+\([^[:space:]]\+\).*$/\1/p' $RESOLVCONF | while read -r line; do + # Copy IPv4 resolvers if client has IPv4, or IPv6 resolvers if client has IPv6 + if [[ $line =~ ^[0-9.]*$ ]] && [[ $CLIENT_IPV4 == 'y' ]]; then + echo "push \"dhcp-option DNS $line\"" >>/etc/openvpn/server/server.conf + elif [[ $line =~ : ]] && [[ $CLIENT_IPV6 == 'y' ]]; then + echo "push \"dhcp-option DNS $line\"" >>/etc/openvpn/server/server.conf + fi + done + ;; + unbound) + if [[ $CLIENT_IPV4 == 'y' ]]; then + echo "push \"dhcp-option DNS $VPN_GATEWAY_IPV4\"" >>/etc/openvpn/server/server.conf + fi + if [[ $CLIENT_IPV6 == 'y' ]]; then + echo "push \"dhcp-option DNS $VPN_GATEWAY_IPV6\"" >>/etc/openvpn/server/server.conf + fi + ;; + cloudflare) + if [[ $CLIENT_IPV4 == 'y' ]]; then + echo 'push "dhcp-option DNS 1.0.0.1"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 1.1.1.1"' >>/etc/openvpn/server/server.conf + fi + if [[ $CLIENT_IPV6 == 'y' ]]; then + echo 'push "dhcp-option DNS 2606:4700:4700::1001"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 2606:4700:4700::1111"' >>/etc/openvpn/server/server.conf + fi + ;; + quad9) + if [[ $CLIENT_IPV4 == 'y' ]]; then + echo 'push "dhcp-option DNS 9.9.9.9"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 149.112.112.112"' >>/etc/openvpn/server/server.conf + fi + if [[ $CLIENT_IPV6 == 'y' ]]; then + echo 'push "dhcp-option DNS 2620:fe::fe"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 2620:fe::9"' >>/etc/openvpn/server/server.conf + fi + ;; + quad9-uncensored) + if [[ $CLIENT_IPV4 == 'y' ]]; then + echo 'push "dhcp-option DNS 9.9.9.10"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 149.112.112.10"' >>/etc/openvpn/server/server.conf + fi + if [[ $CLIENT_IPV6 == 'y' ]]; then + echo 'push "dhcp-option DNS 2620:fe::10"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 2620:fe::fe:10"' >>/etc/openvpn/server/server.conf + fi + ;; + fdn) + if [[ $CLIENT_IPV4 == 'y' ]]; then + echo 'push "dhcp-option DNS 80.67.169.40"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 80.67.169.12"' >>/etc/openvpn/server/server.conf + fi + if [[ $CLIENT_IPV6 == 'y' ]]; then + echo 'push "dhcp-option DNS 2001:910:800::40"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 2001:910:800::12"' >>/etc/openvpn/server/server.conf + fi + ;; + dnswatch) + if [[ $CLIENT_IPV4 == 'y' ]]; then + echo 'push "dhcp-option DNS 84.200.69.80"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 84.200.70.40"' >>/etc/openvpn/server/server.conf + fi + if [[ $CLIENT_IPV6 == 'y' ]]; then + echo 'push "dhcp-option DNS 2001:1608:10:25::1c04:b12f"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 2001:1608:10:25::9249:d69b"' >>/etc/openvpn/server/server.conf + fi + ;; + opendns) + if [[ $CLIENT_IPV4 == 'y' ]]; then + echo 'push "dhcp-option DNS 208.67.222.222"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 208.67.220.220"' >>/etc/openvpn/server/server.conf + fi + if [[ $CLIENT_IPV6 == 'y' ]]; then + echo 'push "dhcp-option DNS 2620:119:35::35"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 2620:119:53::53"' >>/etc/openvpn/server/server.conf + fi + ;; + google) + if [[ $CLIENT_IPV4 == 'y' ]]; then + echo 'push "dhcp-option DNS 8.8.8.8"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 8.8.4.4"' >>/etc/openvpn/server/server.conf + fi + if [[ $CLIENT_IPV6 == 'y' ]]; then + echo 'push "dhcp-option DNS 2001:4860:4860::8888"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 2001:4860:4860::8844"' >>/etc/openvpn/server/server.conf + fi + ;; + yandex) + if [[ $CLIENT_IPV4 == 'y' ]]; then + echo 'push "dhcp-option DNS 77.88.8.8"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 77.88.8.1"' >>/etc/openvpn/server/server.conf + fi + if [[ $CLIENT_IPV6 == 'y' ]]; then + echo 'push "dhcp-option DNS 2a02:6b8::feed:0ff"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 2a02:6b8:0:1::feed:0ff"' >>/etc/openvpn/server/server.conf + fi + ;; + adguard) + if [[ $CLIENT_IPV4 == 'y' ]]; then + echo 'push "dhcp-option DNS 94.140.14.14"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 94.140.15.15"' >>/etc/openvpn/server/server.conf + fi + if [[ $CLIENT_IPV6 == 'y' ]]; then + echo 'push "dhcp-option DNS 2a10:50c0::ad1:ff"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 2a10:50c0::ad2:ff"' >>/etc/openvpn/server/server.conf + fi + ;; + nextdns) + if [[ $CLIENT_IPV4 == 'y' ]]; then + echo 'push "dhcp-option DNS 45.90.28.167"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 45.90.30.167"' >>/etc/openvpn/server/server.conf + fi + if [[ $CLIENT_IPV6 == 'y' ]]; then + echo 'push "dhcp-option DNS 2a07:a8c0::"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 2a07:a8c1::"' >>/etc/openvpn/server/server.conf + fi + ;; + custom) + echo "push \"dhcp-option DNS $DNS1\"" >>/etc/openvpn/server/server.conf + if [[ $DNS2 != "" ]]; then + echo "push \"dhcp-option DNS $DNS2\"" >>/etc/openvpn/server/server.conf + fi + ;; + esac + fi - # Redirect gateway settings - always redirect both IPv4 and IPv6 to prevent leaks - # For IPv4: redirect-gateway def1 routes all IPv4 through VPN (or drops it if IPv4 not configured) - # For IPv6: route-ipv6 + redirect-gateway ipv6 routes all IPv6, or block-ipv6 drops it - echo 'push "redirect-gateway def1 bypass-dhcp"' >>/etc/openvpn/server/server.conf - if [[ $CLIENT_IPV6 == "y" ]]; then - echo 'push "route-ipv6 2000::/3"' >>/etc/openvpn/server/server.conf - echo 'push "redirect-gateway ipv6"' >>/etc/openvpn/server/server.conf - else - # Block IPv6 on clients to prevent IPv6 leaks when VPN only handles IPv4 - echo 'push "block-ipv6"' >>/etc/openvpn/server/server.conf + # Push explicit routes for server-side networks. These routes are independent + # from internet routing and are protected by matching firewall rules. + local local_network address prefix netmask + while IFS= read -r local_network; do + address="${local_network%/*}" + prefix="${local_network##*/}" + netmask=$(ipv4_prefix_to_netmask "$prefix") + echo "push \"route $address $netmask\"" >>/etc/openvpn/server/server.conf + done < <(local_networks_for_family 4) + while IFS= read -r local_network; do + echo "push \"route-ipv6 $local_network\"" >>/etc/openvpn/server/server.conf + done < <(local_networks_for_family 6) + + # Full-tunnel mode redirects enabled address families and blocks leaks from + # disabled families. Split-tunnel mode leaves normal client internet routes intact. + if [[ $ROUTE_INTERNET == "y" ]]; then + echo 'push "redirect-gateway def1 bypass-dhcp"' >>/etc/openvpn/server/server.conf + if [[ $CLIENT_IPV6 == "y" ]]; then + echo 'push "route-ipv6 2000::/3"' >>/etc/openvpn/server/server.conf + echo 'push "redirect-gateway ipv6"' >>/etc/openvpn/server/server.conf + else + # Block IPv6 on clients to prevent IPv6 leaks when VPN only handles IPv4. + echo 'push "block-ipv6"' >>/etc/openvpn/server/server.conf + fi fi if [[ -n $MTU ]]; then @@ -3084,6 +3492,24 @@ management /var/run/openvpn-server/server.sock unix verb 3" } >>/etc/openvpn/server/server.conf + # Record installer-owned policy so firewall rules can be removed exactly. + if systemctl is-active --quiet firewalld; then + FIREWALL_BACKEND=firewalld + elif systemctl is-active --quiet nftables; then + FIREWALL_BACKEND=nftables + else + FIREWALL_BACKEND=iptables + fi + { + echo "FIREWALL_BACKEND=$FIREWALL_BACKEND" + echo "ROUTE_INTERNET=$ROUTE_INTERNET" + echo "CLIENT_TO_CLIENT=$CLIENT_TO_CLIENT" + echo "LOCAL_NETWORKS=$LOCAL_NETWORKS" + echo "CLIENT_IPV4=$CLIENT_IPV4" + echo "CLIENT_IPV6=$CLIENT_IPV6" + } >/etc/openvpn/server/openvpn-install.conf + chmod 600 /etc/openvpn/server/openvpn-install.conf + # Create client-config-dir dir run_cmd_fatal "Creating client config directory" mkdir -p /etc/openvpn/server/ccd # Create log dir @@ -3096,19 +3522,22 @@ verb 3" chown -R "$OPENVPN_USER:$OPENVPN_GROUP" /etc/openvpn/server chown "$OPENVPN_USER:$OPENVPN_GROUP" /var/log/openvpn fi + chown root:root /etc/openvpn/server/openvpn-install.conf + chmod 600 /etc/openvpn/server/openvpn-install.conf # Enable routing log_info "Enabling IP forwarding..." run_cmd_fatal "Creating sysctl.d directory" mkdir -p /etc/sysctl.d - # Enable IPv4 forwarding if clients get IPv4 - if [[ $CLIENT_IPV4 == 'y' ]]; then + # Forwarding is needed for internet or server-side network access. OpenVPN + # handles non-DCO client-to-client traffic internally, while DCO traffic is + # still constrained by the firewall rules below. + if [[ $CLIENT_IPV4 == 'y' ]] && { [[ $ROUTE_INTERNET == 'y' ]] || has_local_network_family 4 || [[ $CLIENT_TO_CLIENT == 'y' ]]; }; then echo 'net.ipv4.ip_forward=1' >/etc/sysctl.d/99-openvpn.conf else - echo '# IPv4 forwarding not needed (no IPv4 clients)' >/etc/sysctl.d/99-openvpn.conf + echo '# IPv4 forwarding not required by the selected access policy' >/etc/sysctl.d/99-openvpn.conf fi - # Enable IPv6 forwarding if clients get IPv6 - if [[ $CLIENT_IPV6 == 'y' ]]; then + if [[ $CLIENT_IPV6 == 'y' ]] && { [[ $ROUTE_INTERNET == 'y' ]] || has_local_network_family 6 || [[ $CLIENT_TO_CLIENT == 'y' ]]; }; then echo 'net.ipv6.conf.all.forwarding=1' >>/etc/sysctl.d/99-openvpn.conf fi # Apply sysctl rules @@ -3186,7 +3615,7 @@ verb 3" run_cmd "Starting OpenVPN service" systemctl restart openvpn-server@server fi - if [[ $DNS == "unbound" ]]; then + if [[ $ROUTE_INTERNET == "y" && $DNS == "unbound" ]]; then installUnbound fi @@ -3194,34 +3623,66 @@ verb 3" # Use source-based rules for VPN traffic (works reliably regardless of which tun interface OpenVPN uses) log_info "Configuring firewall rules..." - if systemctl is-active --quiet firewalld; then - # Use firewalld native commands for systems with firewalld active + if [[ $FIREWALL_BACKEND == 'firewalld' ]]; then + # A dedicated source zone identifies VPN traffic. A policy object applies + # destination rules to forwarded traffic; zone rich rules alone only + # govern traffic addressed to the server. log_info "firewalld detected, using firewall-cmd..." - run_cmd "Adding OpenVPN port to firewalld" firewall-cmd --permanent --add-port="$PORT/$PROTOCOL" - run_cmd "Adding masquerade to firewalld" firewall-cmd --permanent --add-masquerade + run_cmd_fatal "Adding OpenVPN port to firewalld" firewall-cmd --permanent --add-port="$PORT/$PROTOCOL" + run_cmd_fatal "Creating OpenVPN firewalld zone" firewall-cmd --permanent --new-zone=openvpn-install + run_cmd_fatal "Creating OpenVPN firewalld policy" firewall-cmd --permanent --new-policy=openvpn-egress + run_cmd_fatal "Setting OpenVPN policy ingress" firewall-cmd --permanent --policy=openvpn-egress --add-ingress-zone=openvpn-install + run_cmd_fatal "Setting OpenVPN policy egress" firewall-cmd --permanent --policy=openvpn-egress --add-egress-zone=ANY + run_cmd_fatal "Setting OpenVPN policy default" firewall-cmd --permanent --policy=openvpn-egress --set-target=DROP - # Add rich rules for VPN traffic (source-based only, as firewalld doesn't reliably - # support interface patterns with direct rules when using nftables backend) - if [[ $CLIENT_IPV4 == 'y' ]]; then - run_cmd "Adding IPv4 VPN subnet rule" firewall-cmd --permanent --add-rich-rule="rule family=\"ipv4\" source address=\"$VPN_SUBNET_IPV4/24\" accept" + if [[ -n $VPN_SUBNET_IPV4 ]]; then + run_cmd_fatal "Adding IPv4 VPN source to firewalld" firewall-cmd --permanent --zone=openvpn-install --add-source="$VPN_SUBNET_IPV4/24" + run_cmd_fatal "Allowing the IPv4 VPN gateway" firewall-cmd --permanent --zone=openvpn-install --add-rich-rule="rule priority=\"-400\" family=\"ipv4\" destination address=\"$VPN_GATEWAY_IPV4/32\" accept" + if [[ $CLIENT_IPV4 == 'y' ]]; then + while IFS= read -r local_network; do + run_cmd_fatal "Allowing local IPv4 network $local_network" firewall-cmd --permanent --policy=openvpn-egress --add-rich-rule="rule priority=\"-300\" family=\"ipv4\" destination address=\"$local_network\" accept" + run_cmd_fatal "Adding NAT for local IPv4 network $local_network" firewall-cmd --permanent --policy=openvpn-egress --add-rich-rule="rule family=\"ipv4\" destination address=\"$local_network\" masquerade" + done < <(local_networks_for_family 4) + if [[ $ROUTE_INTERNET == 'y' ]]; then + for protected_network in "${PROTECTED_IPV4_NETWORKS[@]}"; do + run_cmd_fatal "Protecting IPv4 network $protected_network" firewall-cmd --permanent --policy=openvpn-egress --add-rich-rule="rule priority=\"-200\" family=\"ipv4\" destination address=\"$protected_network\" reject" + done + run_cmd_fatal "Allowing IPv4 internet access" firewall-cmd --permanent --policy=openvpn-egress --add-rich-rule="rule priority=\"-100\" family=\"ipv4\" accept" + run_cmd_fatal "Adding IPv4 internet NAT" firewall-cmd --permanent --policy=openvpn-egress --add-rich-rule="rule family=\"ipv4\" masquerade" + fi + fi fi if [[ $CLIENT_IPV6 == 'y' ]]; then - run_cmd "Adding IPv6 VPN subnet rule" firewall-cmd --permanent --add-rich-rule="rule family=\"ipv6\" source address=\"${VPN_SUBNET_IPV6}/112\" accept" + run_cmd_fatal "Adding IPv6 VPN source to firewalld" firewall-cmd --permanent --zone=openvpn-install --add-source="${VPN_SUBNET_IPV6}/112" + run_cmd_fatal "Allowing the IPv6 VPN gateway" firewall-cmd --permanent --zone=openvpn-install --add-rich-rule="rule priority=\"-400\" family=\"ipv6\" destination address=\"$VPN_GATEWAY_IPV6/128\" accept" + while IFS= read -r local_network; do + run_cmd_fatal "Allowing local IPv6 network $local_network" firewall-cmd --permanent --policy=openvpn-egress --add-rich-rule="rule priority=\"-300\" family=\"ipv6\" destination address=\"$local_network\" accept" + run_cmd_fatal "Adding NAT for local IPv6 network $local_network" firewall-cmd --permanent --policy=openvpn-egress --add-rich-rule="rule family=\"ipv6\" destination address=\"$local_network\" masquerade" + done < <(local_networks_for_family 6) + if [[ $ROUTE_INTERNET == 'y' ]]; then + for protected_network in "${PROTECTED_IPV6_NETWORKS[@]}"; do + run_cmd_fatal "Protecting IPv6 network $protected_network" firewall-cmd --permanent --policy=openvpn-egress --add-rich-rule="rule priority=\"-200\" family=\"ipv6\" destination address=\"$protected_network\" reject" + done + run_cmd_fatal "Allowing IPv6 internet access" firewall-cmd --permanent --policy=openvpn-egress --add-rich-rule="rule priority=\"-100\" family=\"ipv6\" accept" + run_cmd_fatal "Adding IPv6 internet NAT" firewall-cmd --permanent --policy=openvpn-egress --add-rich-rule="rule family=\"ipv6\" masquerade" + fi fi - run_cmd "Reloading firewalld" firewall-cmd --reload - elif systemctl is-active --quiet nftables; then - # Use nftables native rules for systems with nftables active + if [[ $CLIENT_TO_CLIENT == 'y' ]]; then + run_cmd_fatal "Allowing firewalld intra-zone forwarding" firewall-cmd --permanent --zone=openvpn-install --add-forward + fi + + run_cmd_fatal "Reloading firewalld" firewall-cmd --reload + elif [[ $FIREWALL_BACKEND == 'nftables' ]]; then log_info "nftables detected, configuring nftables rules..." run_cmd_fatal "Creating nftables directory" mkdir -p /etc/nftables - # Create nftables rules file { echo "table inet openvpn {" echo " chain input {" echo " type filter hook input priority 0; policy accept;" - if [[ $CLIENT_IPV4 == 'y' ]]; then + if [[ -n $VPN_SUBNET_IPV4 ]]; then echo " iifname \"tun*\" ip saddr $VPN_SUBNET_IPV4/24 accept" fi if [[ $CLIENT_IPV6 == 'y' ]]; then @@ -3231,93 +3692,203 @@ verb 3" echo " }" echo "" echo " chain forward {" - echo " type filter hook forward priority 0; policy accept;" - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo " iifname \"tun*\" ip saddr $VPN_SUBNET_IPV4/24 accept" - echo " oifname \"tun*\" ip daddr $VPN_SUBNET_IPV4/24 accept" + echo " type filter hook forward priority -10; policy accept;" + if [[ -n $VPN_SUBNET_IPV4 ]]; then + echo " oifname \"tun*\" ip daddr $VPN_SUBNET_IPV4/24 ct state established,related accept" + if [[ $CLIENT_IPV4 == 'y' ]]; then + while IFS= read -r local_network; do + echo " iifname \"tun*\" ip saddr $VPN_SUBNET_IPV4/24 ip daddr $local_network accept" + done < <(local_networks_for_family 4) + fi + if [[ $CLIENT_IPV4 == 'y' && $CLIENT_TO_CLIENT == 'y' ]]; then + echo " iifname \"tun*\" ip saddr $VPN_SUBNET_IPV4/24 ip daddr $VPN_SUBNET_IPV4/24 accept" + fi + if [[ $CLIENT_IPV4 == 'y' && $ROUTE_INTERNET == 'y' ]]; then + for protected_network in "${PROTECTED_IPV4_NETWORKS[@]}"; do + echo " iifname \"tun*\" ip saddr $VPN_SUBNET_IPV4/24 ip daddr $protected_network drop" + done + echo " iifname \"tun*\" ip saddr $VPN_SUBNET_IPV4/24 accept" + else + echo " iifname \"tun*\" ip saddr $VPN_SUBNET_IPV4/24 drop" + fi fi if [[ $CLIENT_IPV6 == 'y' ]]; then - echo " iifname \"tun*\" ip6 saddr ${VPN_SUBNET_IPV6}/112 accept" - echo " oifname \"tun*\" ip6 daddr ${VPN_SUBNET_IPV6}/112 accept" + echo " oifname \"tun*\" ip6 daddr ${VPN_SUBNET_IPV6}/112 ct state established,related accept" + while IFS= read -r local_network; do + echo " iifname \"tun*\" ip6 saddr ${VPN_SUBNET_IPV6}/112 ip6 daddr $local_network accept" + done < <(local_networks_for_family 6) + if [[ $CLIENT_TO_CLIENT == 'y' ]]; then + echo " iifname \"tun*\" ip6 saddr ${VPN_SUBNET_IPV6}/112 ip6 daddr ${VPN_SUBNET_IPV6}/112 accept" + fi + if [[ $ROUTE_INTERNET == 'y' ]]; then + for protected_network in "${PROTECTED_IPV6_NETWORKS[@]}"; do + echo " iifname \"tun*\" ip6 saddr ${VPN_SUBNET_IPV6}/112 ip6 daddr $protected_network drop" + done + echo " iifname \"tun*\" ip6 saddr ${VPN_SUBNET_IPV6}/112 accept" + else + echo " iifname \"tun*\" ip6 saddr ${VPN_SUBNET_IPV6}/112 drop" + fi fi echo " }" echo "}" } >/etc/nftables/openvpn.nft - # IPv4 NAT rules (only if clients get IPv4) - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo " -table ip openvpn-nat { - chain postrouting { - type nat hook postrouting priority 100; policy accept; - ip saddr $VPN_SUBNET_IPV4/24 oifname \"$NIC\" masquerade - } -}" >>/etc/nftables/openvpn.nft + if [[ $CLIENT_IPV4 == 'y' ]] && { [[ $ROUTE_INTERNET == 'y' ]] || has_local_network_family 4; }; then + { + echo "" + echo "table ip openvpn-nat {" + echo " chain postrouting {" + echo " type nat hook postrouting priority 100; policy accept;" + while IFS= read -r local_network; do + echo " ip saddr $VPN_SUBNET_IPV4/24 ip daddr $local_network masquerade" + done < <(local_networks_for_family 4) + if [[ $ROUTE_INTERNET == 'y' ]]; then + echo " ip saddr $VPN_SUBNET_IPV4/24 oifname \"$NIC\" masquerade" + fi + echo " }" + echo "}" + } >>/etc/nftables/openvpn.nft fi - # IPv6 NAT rules (only if clients get IPv6) - if [[ $CLIENT_IPV6 == 'y' ]]; then - echo " -table ip6 openvpn-nat { - chain postrouting { - type nat hook postrouting priority 100; policy accept; - ip6 saddr ${VPN_SUBNET_IPV6}/112 oifname \"$NIC\" masquerade - } -}" >>/etc/nftables/openvpn.nft + if [[ $CLIENT_IPV6 == 'y' ]] && { [[ $ROUTE_INTERNET == 'y' ]] || has_local_network_family 6; }; then + { + echo "" + echo "table ip6 openvpn-nat {" + echo " chain postrouting {" + echo " type nat hook postrouting priority 100; policy accept;" + while IFS= read -r local_network; do + echo " ip6 saddr ${VPN_SUBNET_IPV6}/112 ip6 daddr $local_network masquerade" + done < <(local_networks_for_family 6) + if [[ $ROUTE_INTERNET == 'y' ]]; then + echo " ip6 saddr ${VPN_SUBNET_IPV6}/112 oifname \"$NIC\" masquerade" + fi + echo " }" + echo "}" + } >>/etc/nftables/openvpn.nft fi - # Add include to nftables.conf if not already present if ! grep -q 'include.*/etc/nftables/openvpn.nft' /etc/nftables.conf; then - run_cmd "Adding include to nftables.conf" sh -c 'echo "include \"/etc/nftables/openvpn.nft\"" >> /etc/nftables.conf' + run_cmd_fatal "Adding include to nftables.conf" sh -c 'echo "include \"/etc/nftables/openvpn.nft\"" >> /etc/nftables.conf' fi - - # Reload nftables to apply rules - run_cmd "Reloading nftables" systemctl reload nftables + run_cmd_fatal "Reloading nftables" systemctl reload nftables else # Use iptables for systems without firewalld or nftables run_cmd_fatal "Creating iptables directory" mkdir -p /etc/iptables - # Script to add rules - echo "#!/bin/sh" >/etc/iptables/add-openvpn-rules.sh + # Dedicated chains enforce the same policy for userspace and DCO traffic. + { + echo "#!/bin/sh" + echo "set -eu" + echo "if iptables -nL OPENVPN_INSTALL_FORWARD >/dev/null 2>&1; then" + echo " echo 'iptables chain OPENVPN_INSTALL_FORWARD already exists' >&2" + echo " exit 1" + echo "fi" + if [[ $CLIENT_IPV6 == 'y' ]]; then + echo "if ip6tables -nL OPENVPN_INSTALL_FORWARD >/dev/null 2>&1; then" + echo " echo 'ip6tables chain OPENVPN_INSTALL_FORWARD already exists' >&2" + echo " exit 1" + echo "fi" + fi + echo "cleanup() { /etc/iptables/rm-openvpn-rules.sh >/dev/null 2>&1 || true; }" + echo "trap cleanup EXIT HUP INT TERM" + } >/etc/iptables/add-openvpn-rules.sh + { + echo "#!/bin/sh" + echo "set -u" + echo 'remove_rule() { "$@" 2>/dev/null || true; }' + } >/etc/iptables/rm-openvpn-rules.sh - # IPv4 rules (only if clients get IPv4) - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo "iptables -t nat -I POSTROUTING 1 -s $VPN_SUBNET_IPV4/24 -o $NIC -j MASQUERADE -iptables -I INPUT 1 -i tun+ -s $VPN_SUBNET_IPV4/24 -j ACCEPT -iptables -I FORWARD 1 -i tun+ -s $VPN_SUBNET_IPV4/24 -j ACCEPT -iptables -I FORWARD 1 -o tun+ -d $VPN_SUBNET_IPV4/24 -j ACCEPT -iptables -I INPUT 1 -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT" >>/etc/iptables/add-openvpn-rules.sh + if [[ $ENDPOINT_TYPE == '4' ]]; then + echo "iptables -I INPUT 1 -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT" >>/etc/iptables/add-openvpn-rules.sh + echo "remove_rule iptables -D INPUT -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT" >>/etc/iptables/rm-openvpn-rules.sh + else + echo "ip6tables -I INPUT 1 -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT" >>/etc/iptables/add-openvpn-rules.sh + echo "remove_rule ip6tables -D INPUT -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT" >>/etc/iptables/rm-openvpn-rules.sh + fi + + if [[ -n $VPN_SUBNET_IPV4 ]]; then + { + echo "iptables -N OPENVPN_INSTALL_FORWARD" + echo "iptables -I INPUT 1 -i tun+ -s $VPN_SUBNET_IPV4/24 -j ACCEPT" + echo "iptables -I FORWARD 1 -o tun+ -d $VPN_SUBNET_IPV4/24 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT" + echo "iptables -I FORWARD 1 -i tun+ -s $VPN_SUBNET_IPV4/24 -j OPENVPN_INSTALL_FORWARD" + if [[ $CLIENT_IPV4 == 'y' ]]; then + while IFS= read -r local_network; do + echo "iptables -A OPENVPN_INSTALL_FORWARD -d $local_network -j ACCEPT" + echo "iptables -t nat -I POSTROUTING 1 -s $VPN_SUBNET_IPV4/24 -d $local_network -j MASQUERADE" + done < <(local_networks_for_family 4) + fi + if [[ $CLIENT_IPV4 == 'y' && $CLIENT_TO_CLIENT == 'y' ]]; then + echo "iptables -A OPENVPN_INSTALL_FORWARD -d $VPN_SUBNET_IPV4/24 -j ACCEPT" + fi + if [[ $CLIENT_IPV4 == 'y' && $ROUTE_INTERNET == 'y' ]]; then + for protected_network in "${PROTECTED_IPV4_NETWORKS[@]}"; do + echo "iptables -A OPENVPN_INSTALL_FORWARD -d $protected_network -j REJECT" + done + echo "iptables -A OPENVPN_INSTALL_FORWARD -j ACCEPT" + echo "iptables -t nat -I POSTROUTING 1 -s $VPN_SUBNET_IPV4/24 -o $NIC -j MASQUERADE" + else + echo "iptables -A OPENVPN_INSTALL_FORWARD -j REJECT" + fi + } >>/etc/iptables/add-openvpn-rules.sh + + { + echo "remove_rule iptables -D FORWARD -i tun+ -s $VPN_SUBNET_IPV4/24 -j OPENVPN_INSTALL_FORWARD" + echo "remove_rule iptables -D FORWARD -o tun+ -d $VPN_SUBNET_IPV4/24 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT" + echo "remove_rule iptables -D INPUT -i tun+ -s $VPN_SUBNET_IPV4/24 -j ACCEPT" + if [[ $CLIENT_IPV4 == 'y' && $ROUTE_INTERNET == 'y' ]]; then + echo "remove_rule iptables -t nat -D POSTROUTING -s $VPN_SUBNET_IPV4/24 -o $NIC -j MASQUERADE" + fi + if [[ $CLIENT_IPV4 == 'y' ]]; then + while IFS= read -r local_network; do + echo "remove_rule iptables -t nat -D POSTROUTING -s $VPN_SUBNET_IPV4/24 -d $local_network -j MASQUERADE" + done < <(local_networks_for_family 4) + fi + echo "remove_rule iptables -F OPENVPN_INSTALL_FORWARD" + echo "remove_rule iptables -X OPENVPN_INSTALL_FORWARD" + } >>/etc/iptables/rm-openvpn-rules.sh fi - # IPv6 rules (only if clients get IPv6) if [[ $CLIENT_IPV6 == 'y' ]]; then - echo "ip6tables -t nat -I POSTROUTING 1 -s ${VPN_SUBNET_IPV6}/112 -o $NIC -j MASQUERADE -ip6tables -I INPUT 1 -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j ACCEPT -ip6tables -I FORWARD 1 -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j ACCEPT -ip6tables -I FORWARD 1 -o tun+ -d ${VPN_SUBNET_IPV6}/112 -j ACCEPT -ip6tables -I INPUT 1 -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT" >>/etc/iptables/add-openvpn-rules.sh + { + echo "ip6tables -N OPENVPN_INSTALL_FORWARD" + echo "ip6tables -I INPUT 1 -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j ACCEPT" + echo "ip6tables -I FORWARD 1 -o tun+ -d ${VPN_SUBNET_IPV6}/112 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT" + echo "ip6tables -I FORWARD 1 -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j OPENVPN_INSTALL_FORWARD" + while IFS= read -r local_network; do + echo "ip6tables -A OPENVPN_INSTALL_FORWARD -d $local_network -j ACCEPT" + echo "ip6tables -t nat -I POSTROUTING 1 -s ${VPN_SUBNET_IPV6}/112 -d $local_network -j MASQUERADE" + done < <(local_networks_for_family 6) + if [[ $CLIENT_TO_CLIENT == 'y' ]]; then + echo "ip6tables -A OPENVPN_INSTALL_FORWARD -d ${VPN_SUBNET_IPV6}/112 -j ACCEPT" + fi + if [[ $ROUTE_INTERNET == 'y' ]]; then + for protected_network in "${PROTECTED_IPV6_NETWORKS[@]}"; do + echo "ip6tables -A OPENVPN_INSTALL_FORWARD -d $protected_network -j REJECT" + done + echo "ip6tables -A OPENVPN_INSTALL_FORWARD -j ACCEPT" + echo "ip6tables -t nat -I POSTROUTING 1 -s ${VPN_SUBNET_IPV6}/112 -o $NIC -j MASQUERADE" + else + echo "ip6tables -A OPENVPN_INSTALL_FORWARD -j REJECT" + fi + } >>/etc/iptables/add-openvpn-rules.sh + + { + echo "remove_rule ip6tables -D FORWARD -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j OPENVPN_INSTALL_FORWARD" + echo "remove_rule ip6tables -D FORWARD -o tun+ -d ${VPN_SUBNET_IPV6}/112 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT" + echo "remove_rule ip6tables -D INPUT -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j ACCEPT" + if [[ $ROUTE_INTERNET == 'y' ]]; then + echo "remove_rule ip6tables -t nat -D POSTROUTING -s ${VPN_SUBNET_IPV6}/112 -o $NIC -j MASQUERADE" + fi + while IFS= read -r local_network; do + echo "remove_rule ip6tables -t nat -D POSTROUTING -s ${VPN_SUBNET_IPV6}/112 -d $local_network -j MASQUERADE" + done < <(local_networks_for_family 6) + echo "remove_rule ip6tables -F OPENVPN_INSTALL_FORWARD" + echo "remove_rule ip6tables -X OPENVPN_INSTALL_FORWARD" + } >>/etc/iptables/rm-openvpn-rules.sh fi - # Script to remove rules - echo "#!/bin/sh" >/etc/iptables/rm-openvpn-rules.sh - - # IPv4 removal rules - if [[ $CLIENT_IPV4 == 'y' ]]; then - echo "iptables -t nat -D POSTROUTING -s $VPN_SUBNET_IPV4/24 -o $NIC -j MASQUERADE -iptables -D INPUT -i tun+ -s $VPN_SUBNET_IPV4/24 -j ACCEPT -iptables -D FORWARD -i tun+ -s $VPN_SUBNET_IPV4/24 -j ACCEPT -iptables -D FORWARD -o tun+ -d $VPN_SUBNET_IPV4/24 -j ACCEPT -iptables -D INPUT -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT" >>/etc/iptables/rm-openvpn-rules.sh - fi - - # IPv6 removal rules - if [[ $CLIENT_IPV6 == 'y' ]]; then - echo "ip6tables -t nat -D POSTROUTING -s ${VPN_SUBNET_IPV6}/112 -o $NIC -j MASQUERADE -ip6tables -D INPUT -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j ACCEPT -ip6tables -D FORWARD -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j ACCEPT -ip6tables -D FORWARD -o tun+ -d ${VPN_SUBNET_IPV6}/112 -j ACCEPT -ip6tables -D INPUT -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT" >>/etc/iptables/rm-openvpn-rules.sh - fi + echo "trap - EXIT HUP INT TERM" >>/etc/iptables/add-openvpn-rules.sh run_cmd "Making add-openvpn-rules.sh executable" chmod +x /etc/iptables/add-openvpn-rules.sh run_cmd "Making rm-openvpn-rules.sh executable" chmod +x /etc/iptables/rm-openvpn-rules.sh @@ -3341,7 +3912,7 @@ WantedBy=multi-user.target" >/etc/systemd/system/iptables-openvpn.service # Enable service and apply rules run_cmd "Reloading systemd" systemctl daemon-reload run_cmd "Enabling iptables service" systemctl enable iptables-openvpn - run_cmd "Starting iptables service" systemctl start iptables-openvpn + run_cmd_fatal "Starting iptables service" systemctl start iptables-openvpn fi # If the server is behind a NAT, use the correct IP address for the clients to connect to @@ -4446,6 +5017,20 @@ function removeOpenVPN() { # Extract IPv6 subnet (may be empty if IPv6 not enabled) VPN_SUBNET_IPV6=$(grep '^server-ipv6 ' /etc/openvpn/server/server.conf | cut -d " " -f 2 | sed 's|/.*||') + local install_config=/etc/openvpn/server/openvpn-install.conf + local has_policy_manifest=n + if [[ -f $install_config ]]; then + has_policy_manifest=y + FIREWALL_BACKEND=$(grep '^FIREWALL_BACKEND=' "$install_config" | cut -d= -f2-) + ROUTE_INTERNET=$(grep '^ROUTE_INTERNET=' "$install_config" | cut -d= -f2-) + CLIENT_TO_CLIENT=$(grep '^CLIENT_TO_CLIENT=' "$install_config" | cut -d= -f2-) + LOCAL_NETWORKS=$(grep '^LOCAL_NETWORKS=' "$install_config" | cut -d= -f2-) + CLIENT_IPV4=$(grep '^CLIENT_IPV4=' "$install_config" | cut -d= -f2-) + CLIENT_IPV6=$(grep '^CLIENT_IPV6=' "$install_config" | cut -d= -f2-) + VPN_GATEWAY_IPV4="${VPN_SUBNET_IPV4%.*}.1" + VPN_GATEWAY_IPV6="${VPN_SUBNET_IPV6}1" + fi + # Stop OpenVPN log_info "Stopping OpenVPN service..." run_cmd "Disabling OpenVPN service" systemctl disable openvpn-server@server @@ -4455,20 +5040,23 @@ function removeOpenVPN() { # Remove firewall rules log_info "Removing firewall rules..." - if systemctl is-active --quiet firewalld && firewall-cmd --list-ports | grep -q "$PORT/$PROTOCOL_BASE"; then - # firewalld was used + if systemctl is-active --quiet firewalld && { [[ $has_policy_manifest == 'y' && $FIREWALL_BACKEND == 'firewalld' ]] || { [[ $has_policy_manifest == 'n' ]] && firewall-cmd --list-ports | grep -q "$PORT/$PROTOCOL_BASE"; }; }; then run_cmd "Removing OpenVPN port from firewalld" firewall-cmd --permanent --remove-port="$PORT/$PROTOCOL_BASE" - run_cmd "Removing masquerade from firewalld" firewall-cmd --permanent --remove-masquerade - # Remove IPv4 rich rule if configured - if [[ -n $VPN_SUBNET_IPV4 ]]; then - firewall-cmd --permanent --remove-rich-rule="rule family=\"ipv4\" source address=\"$VPN_SUBNET_IPV4/24\" accept" 2>/dev/null || true - fi - # Remove IPv6 rich rule if configured - if [[ -n $VPN_SUBNET_IPV6 ]]; then - firewall-cmd --permanent --remove-rich-rule="rule family=\"ipv6\" source address=\"${VPN_SUBNET_IPV6}/112\" accept" 2>/dev/null || true + if [[ $has_policy_manifest == 'y' ]]; then + firewall-cmd --permanent --delete-policy=openvpn-egress 2>/dev/null || true + firewall-cmd --permanent --delete-zone=openvpn-install 2>/dev/null || true + else + # Compatibility with installations created before policy manifests. + run_cmd "Removing masquerade from firewalld" firewall-cmd --permanent --remove-masquerade + if [[ -n $VPN_SUBNET_IPV4 ]]; then + firewall-cmd --permanent --remove-rich-rule="rule family=\"ipv4\" source address=\"$VPN_SUBNET_IPV4/24\" accept" 2>/dev/null || true + fi + if [[ -n $VPN_SUBNET_IPV6 ]]; then + firewall-cmd --permanent --remove-rich-rule="rule family=\"ipv6\" source address=\"${VPN_SUBNET_IPV6}/112\" accept" 2>/dev/null || true + fi fi run_cmd "Reloading firewalld" firewall-cmd --reload - elif [[ -f /etc/nftables/openvpn.nft ]]; then + elif [[ $has_policy_manifest == 'y' && $FIREWALL_BACKEND == 'nftables' && -f /etc/nftables/openvpn.nft ]] || [[ $has_policy_manifest == 'n' && -f /etc/nftables/openvpn.nft ]]; then # nftables was used # Delete tables (suppress errors in case tables don't exist) nft delete table inet openvpn 2>/dev/null || true @@ -4476,7 +5064,7 @@ function removeOpenVPN() { nft delete table ip6 openvpn-nat 2>/dev/null || true run_cmd "Removing include from nftables.conf" sed -i '/include.*openvpn\.nft/d' /etc/nftables.conf run_cmd "Removing nftables rules file" rm -f /etc/nftables/openvpn.nft - elif [[ -f /etc/systemd/system/iptables-openvpn.service ]]; then + elif [[ $has_policy_manifest == 'y' && $FIREWALL_BACKEND == 'iptables' && -f /etc/systemd/system/iptables-openvpn.service ]] || [[ $has_policy_manifest == 'n' && -f /etc/systemd/system/iptables-openvpn.service ]]; then # iptables was used run_cmd "Stopping iptables service" systemctl stop iptables-openvpn run_cmd "Disabling iptables service" systemctl disable iptables-openvpn @@ -4599,4 +5187,6 @@ function manageMenu() { # ============================================================================= # Main Entry Point # ============================================================================= -parse_args "$@" +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + parse_args "$@" +fi diff --git a/test/Dockerfile.client b/test/Dockerfile.client index 51be779..3fb1bc3 100644 --- a/test/Dockerfile.client +++ b/test/Dockerfile.client @@ -19,7 +19,8 @@ RUN mkdir -p /dev/net # Copy test scripts COPY test/client-entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +COPY test/policy-peer-entrypoint.sh /policy-peer-entrypoint.sh +RUN chmod +x /entrypoint.sh /policy-peer-entrypoint.sh WORKDIR /etc/openvpn diff --git a/test/Dockerfile.server b/test/Dockerfile.server index 973115f..2886b18 100644 --- a/test/Dockerfile.server +++ b/test/Dockerfile.server @@ -68,7 +68,8 @@ RUN chmod +x /opt/openvpn-install.sh # Copy test scripts COPY test/server-entrypoint.sh /entrypoint.sh COPY test/validate-output.sh /opt/test/validate-output.sh -RUN chmod +x /entrypoint.sh /opt/test/validate-output.sh +COPY test/local-network-detection.sh /opt/test/local-network-detection.sh +RUN chmod +x /entrypoint.sh /opt/test/validate-output.sh /opt/test/local-network-detection.sh # Create systemd service for the test script # PassEnvironment passes Docker env vars (-e) from PID 1 to the service @@ -80,7 +81,7 @@ RUN printf '%s\n' \ '[Service]' \ 'Type=oneshot' \ 'Environment=HOME=/root' \ - 'PassEnvironment=AUTH_MODE TLS_SIG TLS_KEY_FILE TLS_VERSION_MIN TLS13_CIPHERSUITES CLIENT_IPV6 VPN_SUBNET_IPV6 WAIT_TIMEOUT_SIGNAL WAIT_TIMEOUT_CONNECT WAIT_TIMEOUT_REVOKE' \ + 'PassEnvironment=AUTH_MODE TLS_SIG TLS_KEY_FILE TLS_VERSION_MIN TLS13_CIPHERSUITES CLIENT_IPV6 VPN_SUBNET_IPV6 ROUTE_INTERNET CLIENT_TO_CLIENT LOCAL_NETWORKS POLICY_E2E WAIT_TIMEOUT_SIGNAL WAIT_TIMEOUT_CONNECT WAIT_TIMEOUT_REVOKE' \ 'WorkingDirectory=/root' \ 'ExecStart=/entrypoint.sh' \ 'RemainAfterExit=yes' \ diff --git a/test/client-entrypoint.sh b/test/client-entrypoint.sh index e4e9234..e4a0b37 100755 --- a/test/client-entrypoint.sh +++ b/test/client-entrypoint.sh @@ -131,10 +131,13 @@ wait_for_revoked_reconnect_rejected() { test_dns_resolution() { local label="$1" + local test_name="github.com" local success=false + # This verifies recursive DNS connectivity. Use an unsigned zone so the test + # does not depend on DNSSEC key retrieval over GitHub runner networks. echo "$label: Testing DNS resolution via Unbound ($VPN_GATEWAY)..." for i in $(seq 1 10); do - DIG_OUTPUT=$(dig @"$VPN_GATEWAY" example.com +short +time=5 2>&1) + DIG_OUTPUT=$(dig @"$VPN_GATEWAY" "$test_name" +short +time=5 2>&1) if [ -n "$DIG_OUTPUT" ] && ! echo "$DIG_OUTPUT" | grep -qi "timed out\|SERVFAIL\|connection refused"; then success=true break @@ -147,7 +150,7 @@ test_dns_resolution() { echo "PASS: DNS resolution through Unbound works" else echo "FAIL: DNS resolution through Unbound failed after 10 attempts" - dig @"$VPN_GATEWAY" example.com +time=5 || true + dig @"$VPN_GATEWAY" "$test_name" +time=5 || true exit 1 fi } @@ -220,13 +223,42 @@ if [ "${CLIENT_IPV6:-n}" = "y" ]; then fi fi -# Test 2: Ping VPN gateway (IPv4) -echo "Test 2: Pinging VPN gateway (IPv4) ($VPN_GATEWAY)..." +# Test 2: Verify pushed routes match the access policy. +echo "Test 2: Checking access policy routes..." +if [ "${ROUTE_INTERNET:-y}" = "y" ]; then + if ip route show | grep -q '^0.0.0.0/1 .* tun0' && ip route show | grep -q '^128.0.0.0/1 .* tun0'; then + echo "PASS: Internet routes use the VPN" + else + echo "FAIL: VPN internet routes are missing" + ip route show + exit 1 + fi +else + if ip route show | grep -qE '^(0\.0\.0\.0/1|128\.0\.0\.0/1) .* tun0'; then + echo "FAIL: Internet route uses the VPN in split-tunnel mode" + ip route show + exit 1 + fi + echo "PASS: Internet routes remain outside the VPN" +fi + +if [ -n "${LOCAL_NETWORKS:-}" ]; then + while IFS= read -r local_network; do + if [[ $local_network == *.* ]] && ! ip route show "$local_network" | grep -q 'tun0'; then + echo "FAIL: Local network route is missing for $local_network" + ip route show + exit 1 + fi + done < <(tr ',' '\n' <<<"$LOCAL_NETWORKS") +fi + +# Test 3: Ping VPN gateway (IPv4) +echo "Test 3: Pinging VPN gateway (IPv4) ($VPN_GATEWAY)..." wait_for_gateway_ping "VPN gateway (IPv4)" -# Test 2b: Ping VPN gateway (IPv6, if enabled) +# Test 3b: Ping VPN gateway (IPv6, if enabled) if [ "${CLIENT_IPV6:-n}" = "y" ]; then - echo "Test 2b: Pinging VPN gateway (IPv6) ($VPN_GATEWAY_IPV6)..." + echo "Test 3b: Pinging VPN gateway (IPv6) ($VPN_GATEWAY_IPV6)..." if ping6 -c 5 "$VPN_GATEWAY_IPV6"; then echo "PASS: Can ping VPN gateway (IPv6)" else @@ -235,8 +267,67 @@ if [ "${CLIENT_IPV6:-n}" = "y" ]; then fi fi -# Test 3: DNS resolution through Unbound -test_dns_resolution "Test 3" +# Packet-level access policy tests use a second VPN client and a LAN-only host. +if [ -n "${POLICY_E2E:-}" ]; then + echo "Test 4: Checking packet-level access policy..." + wait_for_file /shared/policy-peer-ip "policy peer VPN address" + POLICY_PEER_IP=$(cat /shared/policy-peer-ip) + POLICY_LAN_IP="${POLICY_LAN_IP:-10.55.0.20}" + + if [ "$POLICY_E2E" = "allow" ]; then + if ping -c 3 -W 2 "$POLICY_PEER_IP" >/dev/null; then + echo "PASS: Client-to-client packets are allowed" + else + echo "FAIL: Cannot reach allowed VPN peer $POLICY_PEER_IP" + exit 1 + fi + if ping -c 3 -W 2 "$POLICY_LAN_IP" >/dev/null; then + echo "PASS: LAN packets and destination-scoped NAT work" + else + echo "FAIL: Cannot reach allowed LAN host $POLICY_LAN_IP" + exit 1 + fi + elif [ "$POLICY_E2E" = "deny" ]; then + if ping -c 1 -W 2 "$POLICY_PEER_IP" >/dev/null; then + echo "FAIL: Isolated VPN peer $POLICY_PEER_IP is reachable" + exit 1 + fi + echo "PASS: Client-to-client packets are blocked" + if ping -c 1 -W 2 "$POLICY_LAN_IP" >/dev/null; then + echo "FAIL: Unexposed LAN host $POLICY_LAN_IP is reachable" + exit 1 + fi + echo "PASS: Unexposed LAN packets are blocked" + else + echo "FAIL: Unknown POLICY_E2E value: $POLICY_E2E" + exit 1 + fi + + if [ "${ROUTE_INTERNET:-y}" = "y" ]; then + PUBLIC_DNS_OUTPUT="" + for _ in $(seq 1 5); do + PUBLIC_DNS_OUTPUT=$(dig @1.1.1.1 example.com +short +tcp +time=5 +tries=1 2>&1) || true + if grep -qE '^[0-9]+(\.[0-9]+){3}$' <<<"$PUBLIC_DNS_OUTPUT"; then + break + fi + PUBLIC_DNS_OUTPUT="" + sleep 2 + done + if [ -n "$PUBLIC_DNS_OUTPUT" ]; then + echo "PASS: Direct internet packets traverse VPN forwarding and NAT" + else + echo "FAIL: Direct public DNS query through the VPN failed" + exit 1 + fi + fi +fi + +# Test 5: DNS resolution through Unbound in full-tunnel mode. +if [ "${ROUTE_INTERNET:-y}" = "y" ]; then + test_dns_resolution "Test 5" +else + echo "Test 5: SKIP: VPN DNS is disabled in split-tunnel mode" +fi echo "" echo "=== Initial connectivity tests PASSED ===" @@ -269,7 +360,9 @@ sleep 5 echo "Test: Pinging VPN gateway after renewal ($VPN_GATEWAY)..." wait_for_gateway_ping "VPN gateway after renewal" -test_dns_resolution "Test: Post-renewal DNS" +if [ "${ROUTE_INTERNET:-y}" = "y" ]; then + test_dns_resolution "Test: Post-renewal DNS" +fi echo "" echo "=== Post-renewal connectivity tests PASSED ===" diff --git a/test/local-network-detection.sh b/test/local-network-detection.sh new file mode 100755 index 0000000..96b51af --- /dev/null +++ b/test/local-network-detection.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# shellcheck disable=SC1091,SC2034 +# SC1091: The installer path is provided by the test environment. +# SC2034: VPN subnet globals are consumed by sourced installer functions. +set -euo pipefail + +INSTALLER=${1:-/opt/openvpn-install.sh} +TEMP_DIR=$(mktemp -d) +trap 'rm -rf "$TEMP_DIR"' EXIT + +cat >"$TEMP_DIR/ip" <<'EOF' +#!/bin/bash +case "$*" in +"-4 -o route show type unicast") + cat <<'ROUTES' +default via 167.172.176.1 dev public0 +10.8.0.0/24 dev tun-test proto kernel scope link +10.19.0.0/16 dev public0 proto kernel scope link src 10.19.0.5 +10.135.0.0/16 dev eth1 proto kernel scope link src 10.135.0.2 +10.135.0.0/16 dev eth1 proto kernel scope link src 10.135.0.2 +10.200.0.0/16 via 10.135.0.1 dev eth1 +100.64.0.0/10 dev tailscale0 proto kernel scope link +169.254.0.0/16 dev eth1 proto kernel scope link +172.20.0.0/16 dev docker0 proto kernel scope link +192.168.50.0/24 dev lan0 proto kernel scope link +203.0.113.0/24 dev public0 proto kernel scope link +ROUTES + ;; +"-6 -o route show type unicast") + cat <<'ROUTES' +default via fe80::1 dev public0 +fc00:1::/64 via fd12:3456::1 dev lan0 +fd12:3456::/64 dev lan0 proto kernel metric 256 +fe80::/64 dev public0 proto kernel metric 256 +2001:db8::/64 dev public0 proto kernel metric 256 +ROUTES + ;; +"-4 -o address show dev public0 scope global") + cat <<'ADDRESSES' +2: public0 inet 203.0.113.10/24 brd 203.0.113.255 scope global public0 +2: public0 inet 10.19.0.5/16 brd 10.19.255.255 scope global public0 +ADDRESSES + ;; +"-4 -o address show dev eth1 scope global") + echo "3: eth1 inet 10.135.0.2/16 brd 10.135.255.255 scope global eth1" + ;; +esac +EOF +chmod +x "$TEMP_DIR/ip" + +export FORCE_COLOR=0 LOG_FILE="" NON_INTERACTIVE_INSTALL=n OUTPUT_FORMAT=table +# shellcheck source=../openvpn-install.sh +source "$INSTALLER" +PATH="$TEMP_DIR:$PATH" + +VPN_SUBNET_IPV4=10.8.0.0 +VPN_SUBNET_IPV6=fd42:42:42:42:: + +assert_equal() { + local expected="$1" actual="$2" description="$3" + if [[ $actual != "$expected" ]]; then + echo "FAIL: $description" >&2 + echo "Expected: $expected" >&2 + echo "Actual: $actual" >&2 + exit 1 + fi +} + +assert_equal \ + "10.135.0.0/16,172.20.0.0/16,192.168.50.0/24,fd12:3456::/64" \ + "$(detect_private_local_networks y y)" \ + "detects unique, directly connected RFC1918 and ULA networks" +assert_equal \ + "10.135.0.0/16,172.20.0.0/16,192.168.50.0/24" \ + "$(detect_private_local_networks y n)" \ + "honors IPv4-only client configuration" +assert_equal \ + "fd12:3456::/64" \ + "$(detect_private_local_networks n y)" \ + "honors IPv6-only client configuration" +assert_equal "" "$(detect_private_local_networks n n)" "returns an empty list when both families are disabled" + +if is_private_ipv4_network 10.0.0.0/7; then + echo "FAIL: IPv4 network broader than RFC1918 space was accepted" >&2 + exit 1 +fi +if is_private_ipv6_network fc00::/6; then + echo "FAIL: IPv6 network broader than ULA space was accepted" >&2 + exit 1 +fi + +echo "PASS: Local network candidate detection" diff --git a/test/policy-peer-entrypoint.sh b/test/policy-peer-entrypoint.sh new file mode 100755 index 0000000..db4e932 --- /dev/null +++ b/test/policy-peer-entrypoint.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" +elapsed=0 + +while [ ! -f /shared/policy-peer.ovpn ]; do + if [ "$elapsed" -ge "$WAIT_TIMEOUT" ]; then + echo "FAIL: Timed out waiting for peer client configuration" + exit 1 + fi + echo "Waiting for peer client configuration... (${elapsed}/${WAIT_TIMEOUT}s)" + sleep 2 + elapsed=$((elapsed + 2)) +done + +openvpn --config /shared/policy-peer.ovpn --daemon --log /var/log/openvpn-policy-peer.log + +elapsed=0 +until ip -4 addr show tun0 2>/dev/null | grep -q 'inet '; do + if [ "$elapsed" -ge "$WAIT_TIMEOUT" ]; then + echo "FAIL: Timed out waiting for peer VPN connection" + cat /var/log/openvpn-policy-peer.log 2>/dev/null || true + exit 1 + fi + echo "Waiting for peer VPN connection... (${elapsed}/${WAIT_TIMEOUT}s)" + sleep 2 + elapsed=$((elapsed + 2)) +done + +PEER_IP=$(ip -4 -o addr show tun0 | awk '{print $4}' | cut -d/ -f1) +printf '%s\n' "$PEER_IP" >/shared/policy-peer-ip +echo "Policy peer connected with VPN address $PEER_IP" + +exec tail -f /var/log/openvpn-policy-peer.log diff --git a/test/server-entrypoint.sh b/test/server-entrypoint.sh index 6f1f7c9..25aa0a9 100755 --- a/test/server-entrypoint.sh +++ b/test/server-entrypoint.sh @@ -3,6 +3,8 @@ set -e echo "=== OpenVPN Server Container ===" +/opt/test/local-network-detection.sh /opt/openvpn-install.sh + # Create TUN device if it doesn't exist if [ ! -c /dev/net/tun ]; then mkdir -p /dev/net @@ -57,10 +59,28 @@ if grep -q $'\033' "$NO_COLOR_OUTPUT"; then fi echo "PASS: --no-color help output has no ANSI escape sequences" +INVALID_NETWORK_OUTPUT="/tmp/invalid-local-network.log" +if /opt/openvpn-install.sh install --local-network 192.168.1.1/24 >"$INVALID_NETWORK_OUTPUT" 2>&1; then + echo "FAIL: Host-address CIDR was accepted as a local network" + exit 1 +elif grep -q "Invalid local network" "$INVALID_NETWORK_OUTPUT"; then + echo "PASS: Invalid local network CIDR is rejected" +else + echo "FAIL: Expected local network validation error" + cat "$INVALID_NETWORK_OUTPUT" + exit 1 +fi + # Calculate VPN gateway from subnet (first usable IP) VPN_GATEWAY="${VPN_SUBNET_IPV4%.*}.1" export VPN_GATEWAY +# Access policy configuration +ROUTE_INTERNET="${ROUTE_INTERNET:-y}" +CLIENT_TO_CLIENT="${CLIENT_TO_CLIENT:-n}" +LOCAL_NETWORKS="${LOCAL_NETWORKS:-}" +POLICY_E2E="${POLICY_E2E:-}" + # IPv6 configuration (optional) # CLIENT_IPV6: y/n to enable IPv6 for VPN clients # VPN_SUBNET_IPV6: IPv6 subnet (ULA prefix, e.g., fd42:42:42:42::) @@ -95,6 +115,18 @@ INSTALL_CMD+=(--subnet-ipv4 "$VPN_SUBNET_IPV4") INSTALL_CMD+=(--mtu 1400) INSTALL_CMD+=(--client testclient) +if [ "$ROUTE_INTERNET" = "n" ]; then + INSTALL_CMD+=(--no-route-internet) +fi +if [ "$CLIENT_TO_CLIENT" = "y" ]; then + INSTALL_CMD+=(--client-to-client) +fi +if [ -n "$LOCAL_NETWORKS" ]; then + while IFS= read -r local_network; do + INSTALL_CMD+=(--local-network "$local_network") + done < <(tr ',' '\n' <<<"$LOCAL_NETWORKS") +fi + # Add IPv6 client support if enabled if [ "$CLIENT_IPV6" = "y" ]; then INSTALL_CMD+=(--client-ipv6) @@ -197,6 +229,65 @@ fi echo "All required files present" +# ===================================================== +# Verify access policy configuration +# ===================================================== +echo "" +echo "=== Verifying Access Policy Configuration ===" + +if [ "$ROUTE_INTERNET" = "y" ]; then + if grep -q '^push "redirect-gateway def1 bypass-dhcp"' /etc/openvpn/server/server.conf; then + echo "PASS: Internet default route is pushed" + else + echo "FAIL: Internet default route is missing" + exit 1 + fi +else + if grep -q 'redirect-gateway\|block-ipv6' /etc/openvpn/server/server.conf; then + echo "FAIL: Internet or leak-blocking routes exist in split-tunnel mode" + exit 1 + fi + echo "PASS: Client internet routes remain outside the VPN" +fi + +if [ "$CLIENT_TO_CLIENT" = "y" ]; then + grep -q '^client-to-client$' /etc/openvpn/server/server.conf || { + echo "FAIL: client-to-client directive is missing" + exit 1 + } +else + if grep -q '^client-to-client$' /etc/openvpn/server/server.conf; then + echo "FAIL: client-to-client is enabled by default" + exit 1 + fi +fi + +if [ -n "$LOCAL_NETWORKS" ]; then + while IFS= read -r local_network; do + if [[ $local_network == *.* ]]; then + local_address="${local_network%/*}" + grep -q "^push \"route $local_address " /etc/openvpn/server/server.conf || { + echo "FAIL: Local IPv4 route for $local_network is missing" + exit 1 + } + else + grep -q "^push \"route-ipv6 $local_network\"" /etc/openvpn/server/server.conf || { + echo "FAIL: Local IPv6 route for $local_network is missing" + exit 1 + } + fi + done < <(tr ',' '\n' <<<"$LOCAL_NETWORKS") +fi + +for setting in "ROUTE_INTERNET=$ROUTE_INTERNET" "CLIENT_TO_CLIENT=$CLIENT_TO_CLIENT" "LOCAL_NETWORKS=$LOCAL_NETWORKS"; do + grep -Fxq "$setting" /etc/openvpn/server/openvpn-install.conf || { + echo "FAIL: Policy manifest is missing $setting" + exit 1 + } +done + +echo "PASS: Access policy configuration is correct" + # ===================================================== # Verify management interface configuration # ===================================================== @@ -253,6 +344,17 @@ else exit 1 fi +if [ -n "$POLICY_E2E" ]; then + echo "Creating second VPN client for packet-level policy tests..." + bash /opt/openvpn-install.sh client add policy-peer --cert-days 3650 + if [ ! -f /root/policy-peer.ovpn ]; then + echo "FAIL: Policy peer client configuration was not generated" + exit 1 + fi + cp /root/policy-peer.ovpn /shared/policy-peer.ovpn + sed -i 's/^remote .*/remote openvpn-server 1194/' /shared/policy-peer.ovpn +fi + # Copy client config to shared volume for initial connectivity tests cp /root/testclient.ovpn /shared/client.ovpn sed -i 's/^remote .*/remote openvpn-server 1194/' /shared/client.ovpn @@ -264,6 +366,9 @@ echo "Client config copied to /shared/client.ovpn" echo "VPN_GATEWAY=$VPN_GATEWAY" echo "CLIENT_IPV6=$CLIENT_IPV6" echo "AUTH_MODE=$AUTH_MODE" + echo "ROUTE_INTERNET=$ROUTE_INTERNET" + echo "CLIENT_TO_CLIENT=$CLIENT_TO_CLIENT" + echo "LOCAL_NETWORKS=$LOCAL_NETWORKS" if [ "$CLIENT_IPV6" = "y" ]; then echo "VPN_SUBNET_IPV6=$VPN_SUBNET_IPV6" echo "VPN_GATEWAY_IPV6=$VPN_GATEWAY_IPV6" @@ -599,87 +704,117 @@ echo "Post-renewal client tests passed" # ===================================================== # Verify Unbound DNS resolver (started by systemd via install script) # ===================================================== -echo "=== Verifying Unbound DNS Resolver ===" +if [ "$ROUTE_INTERNET" = "y" ]; then + echo "=== Verifying Unbound DNS Resolver ===" -if [ -f /etc/unbound/unbound.conf ]; then - # Verify Unbound is running (started by systemctl in install script) - echo "Checking Unbound service status..." - for _ in $(seq 1 30); do - if pgrep -x unbound >/dev/null; then - echo "PASS: Unbound is running" - break + if [ -f /etc/unbound/unbound.conf ]; then + # Verify Unbound is running (started by systemctl in install script) + echo "Checking Unbound service status..." + for _ in $(seq 1 30); do + if pgrep -x unbound >/dev/null; then + echo "PASS: Unbound is running" + break + fi + sleep 1 + done + if ! pgrep -x unbound >/dev/null; then + echo "FAIL: Unbound is not running" + systemctl status unbound 2>&1 || true + journalctl -u unbound --no-pager -n 50 2>&1 || true + exit 1 fi - sleep 1 - done - if ! pgrep -x unbound >/dev/null; then - echo "FAIL: Unbound is not running" - systemctl status unbound 2>&1 || true - journalctl -u unbound --no-pager -n 50 2>&1 || true + else + echo "FAIL: /etc/unbound/unbound.conf not found" exit 1 fi + + echo "" + echo "=== Verifying Unbound Installation ===" + + # Verify Unbound config exists in conf.d directory + UNBOUND_OPENVPN_CONF="/etc/unbound/unbound.conf.d/openvpn.conf" + if [ -f "$UNBOUND_OPENVPN_CONF" ]; then + echo "PASS: Found Unbound config at $UNBOUND_OPENVPN_CONF" + else + echo "FAIL: OpenVPN Unbound config not found at $UNBOUND_OPENVPN_CONF" + echo "Contents of /etc/unbound/:" + ls -la /etc/unbound/ + ls -la /etc/unbound/unbound.conf.d/ 2>/dev/null || true + exit 1 + fi + + # Verify Unbound listens on VPN gateway + if grep -q "interface: $VPN_GATEWAY" "$UNBOUND_OPENVPN_CONF"; then + echo "PASS: Unbound configured to listen on $VPN_GATEWAY" + else + echo "FAIL: Unbound not configured for $VPN_GATEWAY" + cat "$UNBOUND_OPENVPN_CONF" + exit 1 + fi + + # Verify OpenVPN pushes correct DNS + if grep -q "push \"dhcp-option DNS $VPN_GATEWAY\"" /etc/openvpn/server/server.conf; then + echo "PASS: OpenVPN configured to push Unbound DNS" + else + echo "FAIL: OpenVPN not configured to push Unbound DNS" + grep "dhcp-option DNS" /etc/openvpn/server/server.conf || echo "No DNS push found" + exit 1 + fi + + echo "=== Unbound Installation Verified ===" + echo "" else - echo "FAIL: /etc/unbound/unbound.conf not found" - exit 1 + if grep -q '^push "dhcp-option DNS ' /etc/openvpn/server/server.conf; then + echo "FAIL: DNS is pushed while internet routing is disabled" + exit 1 + fi + echo "PASS: VPN DNS setup is skipped in split-tunnel mode" fi -echo "" -echo "=== Verifying Unbound Installation ===" - -# Verify Unbound config exists in conf.d directory -UNBOUND_OPENVPN_CONF="/etc/unbound/unbound.conf.d/openvpn.conf" -if [ -f "$UNBOUND_OPENVPN_CONF" ]; then - echo "PASS: Found Unbound config at $UNBOUND_OPENVPN_CONF" -else - echo "FAIL: OpenVPN Unbound config not found at $UNBOUND_OPENVPN_CONF" - echo "Contents of /etc/unbound/:" - ls -la /etc/unbound/ - ls -la /etc/unbound/unbound.conf.d/ 2>/dev/null || true - exit 1 -fi - -# Verify Unbound listens on VPN gateway -if grep -q "interface: $VPN_GATEWAY" "$UNBOUND_OPENVPN_CONF"; then - echo "PASS: Unbound configured to listen on $VPN_GATEWAY" -else - echo "FAIL: Unbound not configured for $VPN_GATEWAY" - cat "$UNBOUND_OPENVPN_CONF" - exit 1 -fi - -# Verify OpenVPN pushes correct DNS -if grep -q "push \"dhcp-option DNS $VPN_GATEWAY\"" /etc/openvpn/server/server.conf; then - echo "PASS: OpenVPN configured to push Unbound DNS" -else - echo "FAIL: OpenVPN not configured to push Unbound DNS" - grep "dhcp-option DNS" /etc/openvpn/server/server.conf || echo "No DNS push found" - exit 1 -fi - -echo "=== Unbound Installation Verified ===" -echo "" - # Verify OpenVPN server (started by systemd via install script) echo "Verifying OpenVPN server..." # Verify firewall rules exist echo "Verifying firewall rules..." if systemctl is-active --quiet firewalld; then - # firewalld is active - verify masquerade is enabled - echo "firewalld detected, checking masquerade..." - for _ in $(seq 1 10); do - if firewall-cmd --query-masquerade 2>/dev/null; then - echo "PASS: firewalld masquerade is enabled" - break - fi - sleep 1 - done - if ! firewall-cmd --query-masquerade 2>/dev/null; then - echo "FAIL: firewalld masquerade is not enabled" - echo "Current firewalld config:" - firewall-cmd --list-all 2>&1 || true + echo "firewalld detected, checking scoped policy rules..." + if ! firewall-cmd --get-policies | grep -qw openvpn-egress; then + echo "FAIL: firewalld OpenVPN policy is missing" + exit 1 + fi + if ! firewall-cmd --zone=openvpn-install --query-source="$VPN_SUBNET_IPV4/24"; then + echo "FAIL: firewalld OpenVPN source zone is missing" + exit 1 + fi + if [ "$(firewall-cmd --permanent --policy=openvpn-egress --get-target)" != "DROP" ]; then + echo "FAIL: firewalld OpenVPN policy does not default to DROP" + exit 1 + fi + FIREWALLD_POLICY_RULES=$(firewall-cmd --policy=openvpn-egress --list-rich-rules) + if [ "$ROUTE_INTERNET" = "y" ]; then + if grep -q 'family="ipv4" masquerade' <<<"$FIREWALLD_POLICY_RULES"; then + echo "PASS: firewalld has policy-scoped internet NAT" + else + echo "FAIL: firewalld policy-scoped internet NAT is missing" + printf '%s\n' "$FIREWALLD_POLICY_RULES" + exit 1 + fi + if grep -q 'destination address="10.0.0.0/8" reject' <<<"$FIREWALLD_POLICY_RULES"; then + echo "PASS: firewalld private-network isolation is configured" + else + echo "FAIL: firewalld private-network isolation is missing" + printf '%s\n' "$FIREWALLD_POLICY_RULES" + exit 1 + fi + fi + if [ "$CLIENT_TO_CLIENT" = "y" ] && ! firewall-cmd --zone=openvpn-install --query-forward; then + echo "FAIL: firewalld client-to-client forwarding is missing" + exit 1 + fi + if firewall-cmd --query-masquerade 2>/dev/null; then + echo "FAIL: firewalld zone-wide masquerade should not be enabled" exit 1 fi - # Verify port is open if firewall-cmd --list-ports | grep -q "1194/udp"; then echo "PASS: OpenVPN port is open in firewalld" else @@ -687,15 +822,6 @@ if systemctl is-active --quiet firewalld; then firewall-cmd --list-ports exit 1 fi - # Verify VPN subnet rich rule exists (source-based rules work reliably across firewalld backends) - if firewall-cmd --list-rich-rules | grep -q "source address=\"$VPN_SUBNET_IPV4/24\""; then - echo "PASS: VPN subnet rich rule is configured" - else - echo "FAIL: VPN subnet rich rule not found in firewalld" - echo "Current rich rules:" - firewall-cmd --list-rich-rules - exit 1 - fi elif systemctl is-active --quiet nftables; then # nftables mode - verify OpenVPN tables exist echo "nftables detected, checking OpenVPN tables..." @@ -712,20 +838,25 @@ elif systemctl is-active --quiet nftables; then nft list ruleset 2>&1 || true exit 1 fi - # Verify NAT table exists - if nft list table ip openvpn-nat >/dev/null 2>&1; then - echo "PASS: nftables 'ip openvpn-nat' table exists" - else - echo "FAIL: nftables 'ip openvpn-nat' table not found" - nft list ruleset 2>&1 || true - exit 1 + if [ "$ROUTE_INTERNET" = "y" ] || [ -n "$LOCAL_NETWORKS" ]; then + if nft list table ip openvpn-nat >/dev/null 2>&1 && nft list table ip openvpn-nat | grep -q "masquerade"; then + echo "PASS: nftables scoped NAT is configured" + else + echo "FAIL: nftables scoped NAT is missing" + nft list ruleset 2>&1 || true + exit 1 + fi fi - # Verify masquerade rule exists - if nft list table ip openvpn-nat | grep -q "masquerade"; then - echo "PASS: nftables masquerade rule exists" - else - echo "FAIL: nftables masquerade rule not found" - nft list table ip openvpn-nat 2>&1 || true + if [ "$ROUTE_INTERNET" = "y" ]; then + if nft list table inet openvpn | grep -q "ip daddr 10.0.0.0/8 drop"; then + echo "PASS: nftables private-network isolation is configured" + else + echo "FAIL: nftables private-network isolation is missing" + nft list table inet openvpn + exit 1 + fi + elif ! nft list table inet openvpn | grep -q "ip saddr $VPN_SUBNET_IPV4/24 drop"; then + echo "FAIL: nftables split-tunnel default drop is missing" exit 1 fi # Verify include in nftables.conf @@ -737,20 +868,32 @@ elif systemctl is-active --quiet nftables; then exit 1 fi else - # iptables mode - verify NAT rules - echo "iptables mode, checking NAT rules..." - for _ in $(seq 1 10); do - if iptables -t nat -L POSTROUTING -n | grep -q "$VPN_SUBNET_IPV4"; then - echo "PASS: NAT POSTROUTING rule for $VPN_SUBNET_IPV4/24 exists" - break + echo "iptables mode, checking policy rules..." + if [ "$ROUTE_INTERNET" = "y" ] || [ -n "$LOCAL_NETWORKS" ]; then + for _ in $(seq 1 10); do + iptables -t nat -L POSTROUTING -n | grep -q "$VPN_SUBNET_IPV4" && break + sleep 1 + done + if ! iptables -t nat -L POSTROUTING -n | grep -q "$VPN_SUBNET_IPV4"; then + echo "FAIL: Expected scoped NAT rule was not found" + iptables -t nat -L POSTROUTING -n -v + exit 1 fi - sleep 1 - done - if ! iptables -t nat -L POSTROUTING -n | grep -q "$VPN_SUBNET_IPV4"; then - echo "FAIL: NAT POSTROUTING rule for $VPN_SUBNET_IPV4/24 not found" - echo "Current NAT rules:" - iptables -t nat -L POSTROUTING -n -v - systemctl status iptables-openvpn 2>&1 || true + fi + if [ "$ROUTE_INTERNET" = "y" ]; then + if iptables -S OPENVPN_INSTALL_FORWARD | grep -q -- "-d 10.0.0.0/8 -j REJECT"; then + echo "PASS: iptables private-network isolation is configured" + else + echo "FAIL: iptables private-network isolation is missing" + iptables -S OPENVPN_INSTALL_FORWARD + exit 1 + fi + elif ! iptables -S OPENVPN_INSTALL_FORWARD | grep -q -- "-A OPENVPN_INSTALL_FORWARD -j REJECT"; then + echo "FAIL: iptables split-tunnel default reject is missing" + exit 1 + fi + if [ "$CLIENT_TO_CLIENT" = "y" ] && ! iptables -S OPENVPN_INSTALL_FORWARD | grep -q -- "-d $VPN_SUBNET_IPV4/24 -j ACCEPT"; then + echo "FAIL: iptables client-to-client allow rule is missing" exit 1 fi fi @@ -930,10 +1073,8 @@ echo "=== Certificate Revocation Tests PASSED ===" echo "" echo "=== Testing List Client Certificates ===" -# At this point we have 3 client certificates: -# - testclient (Valid) - the renewed certificate -# - testclient (Revoked) - the old certificate revoked during renewal -# - revoketest (Revoked) - the revoked certificate +# At this point PKI mode has three lifecycle-test certificates, plus the +# optional policy peer used by packet-level access tests. LIST_OUTPUT="/tmp/list-clients-output.log" (bash /opt/openvpn-install.sh client list) 2>&1 | tee "$LIST_OUTPUT" || true @@ -956,8 +1097,9 @@ fi # Verify certificate count (varies by auth mode) if [ "$AUTH_MODE" = "pki" ]; then - # PKI mode: 3 certs (testclient valid, testclient revoked from renewal, revoketest revoked) - if grep -q "Found 3 client certificate(s)" "$LIST_OUTPUT"; then + EXPECTED_CLIENT_COUNT=3 + [ -n "$POLICY_E2E" ] && EXPECTED_CLIENT_COUNT=4 + if grep -q "Found $EXPECTED_CLIENT_COUNT client certificate(s)" "$LIST_OUTPUT"; then echo "PASS: List shows correct certificate count" else echo "FAIL: List does not show correct certificate count" @@ -993,10 +1135,10 @@ fi # Verify client count in JSON (varies by auth mode) JSON_CLIENT_COUNT=$(jq '.clients | length' "$LIST_JSON_OUTPUT") if [ "$AUTH_MODE" = "pki" ]; then - if [ "$JSON_CLIENT_COUNT" -eq 3 ]; then + if [ "$JSON_CLIENT_COUNT" -eq "$EXPECTED_CLIENT_COUNT" ]; then echo "PASS: Client list JSON has correct count ($JSON_CLIENT_COUNT)" else - echo "FAIL: Client list JSON has wrong count: $JSON_CLIENT_COUNT (expected 3)" + echo "FAIL: Client list JSON has wrong count: $JSON_CLIENT_COUNT (expected $EXPECTED_CLIENT_COUNT)" cat "$LIST_JSON_OUTPUT" exit 1 fi