Upstream Changes Report¶
This is a technical document generated by
check-upstream.sh. It is intended for automated LLM analysis of upstream project changes.
Agent Task¶
Analyze the upstream changes below and update the project documentation accordingly.
How to work¶
- First check whether the commits listed under Commits were already addressed by a previous documentation update (e.g. search this repository's git log for earlier commits referencing the same upstream commit hashes or PR numbers, such as prior "Update docs for upstream ..." commits). If every listed commit is already reflected in the docs, state that plainly and stop — a full scan is not required in that case.
- Otherwise, read the Full Diff at the end of this document.
- Read the current upstream source in the
openvpn-install/directory. - Read all documentation in
docs/of this repository. - Compare the diff against the docs. For each change determine whether it affects any documented behavior: default values, versions, CLI arguments, paths, installation steps, configuration options, supported platforms, or features.
- If changes are needed — edit the docs directly. If no changes are needed (e.g. internal tests only) — state so with a brief reason.
Rules for writing documentation¶
- Audience: people of any skill level. Use clear, simple language.
- Single source of truth: every fact appears in exactly one place. All other mentions must be cross-reference links (
[text](path#anchor)), never repeated text. - Strictly from the code: never add information from external sources, general knowledge, or assumptions. Every statement must be directly traceable to a specific line or file in the project.
- Accuracy over completeness: if something is ambiguous or unclear from the code, do not guess. Omit it or mark it (see below).
- Mark uncertain places: any generated text that may be inaccurate or requires human verification must be wrapped in an admonition:
!!! warning "Requires verification"with a note explaining what exactly needs to be confirmed and why. - Readability: structure content with headings, lists, and code blocks so it is easy to scan. Prefer short sentences.
- Repo: https://github.com/angristan/openvpn-install.git
- Since: 2026-08-02
- Build date: 2026-09-02
- Checked: 2026-09-25 08:50 UTC
- Status: 2 new commit(s) detected
Commits¶
- ad22fd9 (2026-08-07) Fix interactive install initialization (#1511)
- d2fc6d4 (2026-08-02) Add configurable VPN access policies (#1505)
Changed Files¶
.github/workflows/docker-test.yml
docker-compose.yml
FAQ.md
openvpn-install.sh
README.md
test/client-entrypoint.sh
test/Dockerfile.client
test/Dockerfile.server
test/interactive-install-routing.sh
test/local-network-detection.sh
test/policy-peer-entrypoint.sh
test/server-entrypoint.sh
Diff Summary¶
.github/workflows/docker-test.yml | 89 ++-
FAQ.md | 99 +--
README.md | 26 +-
docker-compose.yml | 4 +
openvpn-install.sh | 1155 ++++++++++++++++++++++++++---------
test/Dockerfile.client | 3 +-
test/Dockerfile.server | 6 +-
test/client-entrypoint.sh | 111 +++-
test/interactive-install-routing.sh | 45 ++
test/local-network-detection.sh | 92 +++
test/policy-peer-entrypoint.sh | 35 ++
test/server-entrypoint.sh | 353 +++++++----
12 files changed, 1543 insertions(+), 475 deletions(-)
Full Diff¶
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:
-
-1. **Push a route to clients** - Add the LAN subnet to `/etc/openvpn/server/server.conf`:
-
- ```
- push "route 192.168.1.0 255.255.255.0"
- ```
-
- Replace `192.168.1.0/24` with your actual LAN subnet.
+**A:** Specify the LAN during installation:
-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)
-
- 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
+./openvpn-install.sh install --local-network 192.168.1.0/24
+```
- ```bash
- # iptables
- iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -d 192.168.1.0/24 -j MASQUERADE
+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.
- # or nftables
- nft add rule ip nat postrouting ip saddr 10.8.0.0/24 ip daddr 192.168.1.0/24 masquerade
- ```
+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.
- Make this persistent by adding it to your firewall scripts.
+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.
-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)?
-
-**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`:
-
-1. Remove or comment out the redirect-gateway line:
-
- ```
- #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"
- ```
+**Q:** How do I configure split-tunnel mode on the server?
- Or add specific IPv6 routes:
+**A:** Use `--no-route-internet`. Add each server-side network that should use the tunnel:
- ```
- push "route-ipv6 2001:db8::/32"
- ```
+```bash
+./openvpn-install.sh install \
+ --no-route-internet \
+ --local-network 10.0.0.0/8 \
+ --local-network 192.168.1.0/24
+```
-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<br/>Default: enabled| I[Internet]
+ VPN -.->|Explicit CIDRs only<br/>Default: disabled| LAN[Home LAN or cloud VPC]
+ VPN -.->|Client-to-client access<br/>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 <x.x.x.0>` - IPv4 VPN subnet (default: `10.8.0.0`)
- `--subnet-ipv6 <prefix>` - 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 <CIDR>` - Allow access to a server-side network and add destination-scoped NAT. Repeat for multiple networks (default: none)
- `--port <num>` - OpenVPN port (default: `1194`)
- `--port-random` - Use random port (49152-65535)
- `--protocol <udp|tcp>` - Protocol (default: `udp`)
- `--mtu <size>` - 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 <provider>` - DNS provider (default: `cloudflare`). Options: `system`, `unbound`, `cloudflare`, `quad9`, `quad9-uncensored`, `fdn`, `dnswatch`, `opendns`, `google`, `yandex`, `adguard`, `nextdns`, `custom`
- `--dns-primary <ip>` - Custom primary DNS (requires `--dns custom`)
- `--dns-secondary <ip>` - 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..f31428b 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 <x.x.x.0> IPv4 VPN subnet (default: 10.8.0.0)
--subnet-ipv6 <prefix> 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 <CIDR> Allow access to a server-side network (repeatable)
--port <num> OpenVPN port (default: 1194)
--port-random Use random port (49152-65535)
--protocol <proto> 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
@@ -1512,8 +1854,7 @@ cmd_interactive() {
if isOpenVPNInstalled; then
manageMenu
else
- installQuestions
- installOpenVPN
+ cmd_install --interactive
fi
}
@@ -2307,6 +2648,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:-<empty>}"
+ 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 +2726,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 ""
-
- 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
+ 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
+ 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 +3014,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 +3250,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 +3283,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 +3491,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 +3521,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 +3614,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 +3622,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
-
- # 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"
+ 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
+
+ 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,94 +3691,204 @@ 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
-
- # 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
- 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
+ # 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
+
+ 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
- # Script to remove rules
- echo "#!/bin/sh" >/etc/iptables/rm-openvpn-rules.sh
+ 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
- # 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
+ {
+ 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 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
+ {
+ 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
+ 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 +3911,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 +5016,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 +5039,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 +5063,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 +5186,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..1f2ee10 100644
--- a/test/Dockerfile.server
+++ b/test/Dockerfile.server
@@ -68,7 +68,9 @@ 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/interactive-install-routing.sh /opt/test/interactive-install-routing.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/interactive-install-routing.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 +82,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/interactive-install-routing.sh b/test/interactive-install-routing.sh
new file mode 100755
index 0000000..d848ac3
--- /dev/null
+++ b/test/interactive-install-routing.sh
@@ -0,0 +1,45 @@
+#!/bin/bash
+# shellcheck disable=SC1091,SC2034
+# SC1091: The installer path is provided by the test environment.
+# SC2034: Configuration globals are consumed by sourced installer functions.
+set -euo pipefail
+
+INSTALLER=${1:-/opt/openvpn-install.sh}
+
+export FORCE_COLOR=0 LOG_FILE="" NON_INTERACTIVE_INSTALL=n OUTPUT_FORMAT=table
+# shellcheck source=../openvpn-install.sh
+source "$INSTALLER"
+
+fail() {
+ echo "FAIL: $1" >&2
+ exit 1
+}
+
+# Exercise command orchestration without touching the host system.
+isOpenVPNInstalled() {
+ return 1
+}
+
+installQuestions() {
+ CLIENT_IPV4=y
+ CLIENT_IPV6=n
+ VPN_SUBNET_IPV4=10.8.0.0
+ ROUTE_INTERNET=y
+}
+
+validate_configuration() {
+ CONFIG_VALIDATED=y
+}
+
+installOpenVPN() {
+ [[ ${CONFIG_VALIDATED:-n} == y ]] || fail "interactive configuration was not validated"
+ [[ ${VPN_GATEWAY_IPV4:-} == 10.8.0.1 ]] || fail "IPv4 gateway was not prepared before installation"
+ [[ ${IPV6_SUPPORT:-} == n ]] || fail "legacy IPv6 support value was not prepared before installation"
+ INSTALL_CALLED=y
+}
+
+cmd_interactive
+
+[[ ${INSTALL_CALLED:-n} == y ]] || fail "interactive installation was not started"
+
+echo "PASS: Interactive command uses the canonical installation flow"
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..49d0ad7 100755
--- a/test/server-entrypoint.sh
+++ b/test/server-entrypoint.sh
@@ -3,6 +3,9 @@ set -e
echo "=== OpenVPN Server Container ==="
+/opt/test/interactive-install-routing.sh /opt/openvpn-install.sh
+/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 +60,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 +116,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 +230,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 +345,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 +367,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 +705,117 @@ echo "Post-renewal client tests passed"
# =====================================================
# Verify Unbound DNS resolver (started by systemd via install script)
# =====================================================
-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 [ "$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
+ 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
-else
- echo "FAIL: /etc/unbound/unbound.conf not found"
- exit 1
-fi
-echo ""
-echo "=== Verifying Unbound Installation ==="
+ 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 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 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
-# 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"
+ echo "=== Unbound Installation Verified ==="
+ echo ""
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
+ 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 "=== 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
+ 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
- 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
+ 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 +823,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 +839,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 +869,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 +1074,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 +1098,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 +1136,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