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