Showing preview only (280K chars total). Download the full file or copy to clipboard to get everything.
Repository: angristan/openvpn-install
Branch: master
Commit: 63448d542def
Files: 24
Total size: 269.9 KB
Directory structure:
gitextract_d63ixilj/
├── .editorconfig
├── .github/
│ ├── FUNDING.yml
│ ├── issue_template.md
│ ├── linters/
│ │ └── .markdown-lint.yml
│ ├── pull_request_template.md
│ └── workflows/
│ ├── do-test.yml
│ ├── docker-test.yml
│ ├── lint.yml
│ └── update-easyrsa-hash.yml
├── .trivyignore
├── AGENTS.md
├── FAQ.md
├── LICENSE
├── Makefile
├── README.md
├── biome.json
├── docker-compose.yml
├── openvpn-install.sh
├── renovate.json
└── test/
├── Dockerfile.client
├── Dockerfile.server
├── client-entrypoint.sh
├── server-entrypoint.sh
└── validate-output.sh
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
[*.sh]
indent_style = tab
indent_size = 4
================================================
FILE: .github/FUNDING.yml
================================================
ko_fi: stanislas
custom: https://coindrop.to/stanislas
================================================
FILE: .github/issue_template.md
================================================
<!---
❗️ Please read ❗️
➡️ If you need help with OpenVPN itself, please use the community forums (https://forums.openvpn.net/) or Stack Overflow (https://stackoverflow.com/questions/tagged/openvpn)
➡️ For the script, prefer opening a discussion thread for help: https://github.com/angristan/openvpn-install/discussions
💡 It helps keep the issue tracker clean and focused on bugs and feature requests.
🙏 Please include as much information as possible, and make sure you're running the latest version of the script.
✍️ Please state the Linux distribution you're using and its version, as well as the OpenVPN version.
✋ For feature requests, remember that this script is meant to be simple and easy to use. If you want to add a lot of options, it's better to fork the project.
--->
================================================
FILE: .github/linters/.markdown-lint.yml
================================================
{
"MD013": null,
"MD045": null,
"MD040": null,
"MD036": null,
"MD041": null,
"MD060": null,
}
================================================
FILE: .github/pull_request_template.md
================================================
<!---
❗️ Please read ❗️
➡️ Please make sure you've followed the guidelines: https://github.com/angristan/openvpn-install#contributing
✅ Please make sure your changes are tested and working
🗣️ Please avoid large PRs, and discuss changes in a GitHub issue first
✋ If the changes are too big and not in line with the project, they will probably be rejected. Remember that this script is meant to be simple and easy to use! And that added features increase maintenance burden.
--->
================================================
FILE: .github/workflows/do-test.yml
================================================
# DigitalOcean E2E tests (manual trigger only)
# Primary CI testing is now done via Docker in docker-test.yml
# This workflow is kept for real-world VM testing when needed
on:
workflow_dispatch:
name: Test
permissions:
contents: read
jobs:
install:
runs-on: ubuntu-latest
if: github.repository == 'angristan/openvpn-install' && github.actor == 'angristan'
strategy:
matrix:
os-image:
- debian-12-x64
- debian-13-x64
- ubuntu-22-04-x64
- ubuntu-24-04-x64
- fedora-42-x64
# - centos-stream-9-x64 # yum oomkill
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup doctl
uses: digitalocean/action-doctl@135ac0aa0eed4437d547c6f12c364d3006b42824 # v2.5.1
with:
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
- name: Create server
run: doctl compute droplet create "openvpn-action-${GITHUB_RUN_ID}-${GITHUB_RUN_NUMBER}-${{ matrix.os-image }}" --size s-1vcpu-1gb --image "${{ matrix.os-image }}" --region lon1 --enable-ipv6 --ssh-keys be:66:76:61:a8:71:93:aa:e3:19:ba:d8:0d:d2:2d:d4 --wait
- name: Get server ID
run: echo "value=$(doctl compute droplet list -o json | jq -r '.[] | select(.name == "'"openvpn-action-${GITHUB_RUN_ID}-${GITHUB_RUN_NUMBER}-${{ matrix.os-image }}"'").id')" >> "$GITHUB_OUTPUT"
id: server_id
- name: Move server to dedicated project
run: doctl projects resources assign "$DIGITALOCEAN_PROJECT_ID" --resource=do:droplet:"$SERVER_ID"
env:
DIGITALOCEAN_PROJECT_ID: ${{ secrets.DIGITALOCEAN_PROJECT_ID }}
SERVER_ID: ${{ steps.server_id.outputs.value }}
- name: Wait for server to boot
run: sleep 90
- name: Get server IP
run: echo "value=$(doctl compute droplet list -o json | jq -r '.[] | select(.name == "'"openvpn-action-${GITHUB_RUN_ID}-${GITHUB_RUN_NUMBER}-${{ matrix.os-image }}"'").networks.v4 | .[] | select(.type == "'"public"'").ip_address')" >> "$GITHUB_OUTPUT"
id: server_ip
- name: Get server OS
run: echo "value=$(echo "${{ matrix.os-image }}" | cut -d '-' -f1)" >> "$GITHUB_OUTPUT"
id: server_os
- name: Setup remote server (Debian/Ubuntu)
if: steps.server_os.outputs.value == 'debian' || steps.server_os.outputs.value == 'ubuntu'
uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0
with:
host: ${{ steps.server_ip.outputs.value }}
username: root
key: ${{ secrets.SSH_KEY }}
script: set -x && apt-get update && apt-get -o DPkg::Lock::Timeout=120 install -y git
- name: Setup remote server (Fedora)
if: steps.server_os.outputs.value == 'fedora'
uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0
with:
host: ${{ steps.server_ip.outputs.value }}
username: root
key: ${{ secrets.SSH_KEY }}
script: set -x && dnf install -y git
- name: Setup remote server (CentOS)
if: steps.server_os.outputs.value == 'centos'
uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0
with:
host: ${{ steps.server_ip.outputs.value }}
username: root
key: ${{ secrets.SSH_KEY }}
script: set -x && yum install -y git
- name: Download repo and checkout current commit
uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0
with:
host: ${{ steps.server_ip.outputs.value }}
username: root
key: ${{ secrets.SSH_KEY }}
script: set -x && git clone https://github.com/angristan/openvpn-install.git && cd openvpn-install && git checkout ${{ github.sha }}
- name: Run openvpn-install.sh in headless mode
uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0
with:
host: ${{ steps.server_ip.outputs.value }}
username: root
key: ${{ secrets.SSH_KEY }}
script: 'set -x && bash -x ~/openvpn-install/openvpn-install.sh install && ps aux | grep openvpn | grep -v grep > /dev/null 2>&1 && echo "Success: OpenVPN is running" && exit 0 || echo "Failure: OpenVPN is not running" && exit 1'
- name: Delete server
run: doctl compute droplet delete -f "openvpn-action-${GITHUB_RUN_ID}-${GITHUB_RUN_NUMBER}-${{ matrix.os-image }}"
if: always()
================================================
FILE: .github/workflows/docker-test.yml
================================================
---
on:
push:
branches: [master]
pull_request:
workflow_dispatch:
name: Docker Test
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
docker-test:
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os:
- name: ubuntu-18.04
image: ubuntu:18.04
- name: ubuntu-20.04
image: ubuntu:20.04
- name: ubuntu-22.04
image: ubuntu:22.04
- name: ubuntu-24.04
image: ubuntu:24.04
- name: ubuntu-25.10
image: ubuntu:25.10
- name: debian-11
image: debian:11
- name: debian-12
image: debian:12
- name: centos-stream-9
image: quay.io/centos/centos:stream9
- name: centos-stream-10
image: quay.io/centos/centos:stream10
- name: fedora-42
image: fedora:42
- name: fedora-43
image: fedora:43
- name: rocky-8
image: rockylinux/rockylinux:8
- name: rocky-9
image: rockylinux/rockylinux:9
- name: rocky-10
image: rockylinux/rockylinux:10
- name: almalinux-8
image: almalinux:8
- name: almalinux-9
image: almalinux:9
- name: almalinux-10
image: almalinux:10
- name: archlinux
image: archlinux:latest
- name: opensuse-leap-16.0
image: opensuse/leap:16.0
- name: opensuse-tumbleweed
image: opensuse/tumbleweed
- name: oraclelinux-8
image: oraclelinux:8
- name: oraclelinux-9
image: oraclelinux:9
- name: oraclelinux-10
image: oraclelinux:10
- name: amazonlinux-2023
image: amazonlinux:2023
# Default TLS settings (tls-crypt-v2)
tls:
- name: tls-crypt-v2
sig: crypt-v2
key_file: tls-crypt-v2.key
# Additional TLS types tested on Ubuntu 24.04 only
include:
- os:
name: ubuntu-24.04-tls-crypt
image: ubuntu:24.04
tls:
name: tls-crypt
sig: crypt
key_file: tls-crypt.key
- os:
name: ubuntu-24.04-tls-auth
image: ubuntu:24.04
tls:
name: tls-auth
sig: auth
key_file: tls-auth.key
# Test firewalld support on Fedora
- os:
name: fedora-42-firewalld
image: fedora:42
enable_firewalld: true
tls:
name: tls-crypt-v2
sig: crypt-v2
key_file: tls-crypt-v2.key
# Test nftables support on Debian
- os:
name: debian-12-nftables
image: debian:12
enable_nftables: true
tls:
name: tls-crypt-v2
sig: crypt-v2
key_file: tls-crypt-v2.key
# Test IPv6 dual-stack support on Ubuntu
- os:
name: ubuntu-24.04-dual-stack
image: ubuntu:24.04
client_ipv6: true
tls:
name: tls-crypt-v2
sig: crypt-v2
key_file: tls-crypt-v2.key
# Test peer-fingerprint authentication mode (OpenVPN 2.6+)
- os:
name: ubuntu-24.04-fingerprint
image: ubuntu:24.04
auth_mode: fingerprint
tls:
name: tls-crypt-v2
sig: crypt-v2
key_file: tls-crypt-v2.key
name: ${{ matrix.os.name }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build server image
run: |
docker build \
--build-arg BASE_IMAGE=${{ matrix.os.image }} \
--build-arg ENABLE_FIREWALLD=${{ matrix.os.enable_firewalld && 'y' || 'n' }} \
--build-arg ENABLE_NFTABLES=${{ matrix.os.enable_nftables && 'y' || 'n' }} \
-t openvpn-server \
-f test/Dockerfile.server .
- name: Build client image
run: docker build -t openvpn-client -f test/Dockerfile.client .
- name: Create Docker network
run: docker network create --subnet=172.28.0.0/24 vpn-test
- name: Create shared volume
run: docker volume create shared-config
- name: Start OpenVPN server
run: |
docker run -d \
--name openvpn-server \
--hostname openvpn-server \
--privileged \
--cgroupns=host \
--device=/dev/net/tun:/dev/net/tun \
--sysctl net.ipv4.ip_forward=1 \
--sysctl net.ipv6.conf.all.forwarding=1 \
--network vpn-test \
--ip 172.28.0.10 \
-v shared-config:/shared \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
--tmpfs /run \
--tmpfs /run/lock \
--stop-signal SIGRTMIN+3 \
-e TLS_SIG=${{ matrix.tls.sig }} \
-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' }} \
openvpn-server
- name: Wait for server installation and startup
run: |
echo "Waiting for OpenVPN server to install and client config to be ready..."
for i in {1..90}; do
# Get service status (properly handle non-zero exit codes)
# systemctl is-active returns exit code 3 for "inactive"/"failed", so capture output without checking exit code
SERVICE_STATUS="$(docker exec openvpn-server systemctl is-active openvpn-test.service 2>/dev/null)" || true
[ -z "$SERVICE_STATUS" ] && SERVICE_STATUS="unknown"
# Fail fast if service failed
if [ "$SERVICE_STATUS" = "failed" ]; then
echo "ERROR: openvpn-test.service failed during installation"
docker exec openvpn-server systemctl status openvpn-test.service 2>&1 || true
docker exec openvpn-server journalctl -u openvpn-test.service --no-pager -n 100 2>&1 || true
exit 1
fi
# Check if OpenVPN server is running and client config exists
# The service will be "activating" while waiting for client tests - that's expected
OPENVPN_RUNNING=false
CONFIG_EXISTS=false
if docker exec openvpn-server pgrep -f "openvpn.*server.conf" > /dev/null 2>&1; then
OPENVPN_RUNNING=true
fi
if docker exec openvpn-server test -f /shared/client.ovpn 2>/dev/null; then
CONFIG_EXISTS=true
fi
if [ "$OPENVPN_RUNNING" = true ] && [ "$CONFIG_EXISTS" = true ]; then
echo "OpenVPN server is running and client config is ready!"
break
fi
echo "Waiting... ($i/90) - Service: $SERVICE_STATUS, OpenVPN running: $OPENVPN_RUNNING, Config exists: $CONFIG_EXISTS"
sleep 5
done
# Final verification with retry (handles race condition during cert renewal restart)
OPENVPN_STARTED=false
for retry in {1..5}; do
if docker exec openvpn-server pgrep -f "openvpn.*server.conf" > /dev/null 2>&1; then
OPENVPN_STARTED=true
break
fi
echo "Waiting for OpenVPN process... (retry $retry/5)"
sleep 2
done
if [ "$OPENVPN_STARTED" = false ]; then
echo "ERROR: OpenVPN server failed to start"
docker exec openvpn-server systemctl status openvpn-server@server 2>&1 || true
docker exec openvpn-server journalctl -u openvpn-test.service --no-pager -n 100 2>&1 || true
exit 1
fi
if ! docker exec openvpn-server test -f /shared/client.ovpn 2>/dev/null; then
echo "ERROR: Client config not generated"
docker exec openvpn-server journalctl -u openvpn-test.service --no-pager -n 100 2>&1 || true
exit 1
fi
echo "Server ready for client connection!"
- name: Verify client config was generated
run: |
docker run --rm -v shared-config:/shared alpine \
ls -la /shared/
docker run --rm -v shared-config:/shared alpine \
cat /shared/client.ovpn
- name: Start OpenVPN client and run tests
run: |
docker run \
--name openvpn-client \
--hostname openvpn-client \
--cap-add=NET_ADMIN \
--device=/dev/net/tun:/dev/net/tun \
--network vpn-test \
--ip 172.28.0.20 \
-v shared-config:/shared \
openvpn-client &
# Wait for tests to complete (look for success message)
# Extended timeout for revocation e2e tests
for i in {1..180}; do
if docker logs openvpn-client 2>&1 | grep -q "ALL TESTS PASSED"
then
echo "Tests passed!"
exit 0
fi
if docker logs openvpn-client 2>&1 | grep -q "FAIL:"; then
echo "Tests failed!"
docker logs openvpn-client
exit 1
fi
echo "Waiting for tests... ($i/180)"
sleep 2
done
echo "Timeout waiting for tests"
docker logs openvpn-client
exit 1
- name: Show server logs
if: always()
run: docker logs openvpn-server 2>&1 || true
- name: Show systemd journal logs
if: always()
run: |
echo "=== openvpn-test.service status ==="
docker exec openvpn-server systemctl status openvpn-test.service 2>&1 || true
echo ""
echo "=== openvpn-test.service journal ==="
docker exec openvpn-server journalctl -u openvpn-test.service --no-pager -n 100 2>&1 || true
echo ""
echo "=== openvpn-server@server.service journal ==="
docker exec openvpn-server journalctl -u openvpn-server@server.service --no-pager -n 50 2>&1 || true
- name: Show install script log
if: always()
run: |
docker cp openvpn-server:/root/openvpn-install.log /tmp/openvpn-install.log 2>/dev/null && \
cat /tmp/openvpn-install.log || echo "No install log found"
- name: Show client logs
if: always()
run: docker logs openvpn-client 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 volume rm shared-config 2>/dev/null || true
================================================
FILE: .github/workflows/lint.yml
================================================
on:
push:
branches: [master]
pull_request:
workflow_dispatch:
name: Lint
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
super-linter:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- name: Lint Code Base
uses: super-linter/super-linter@d5b0a2ab116623730dd094f15ddc1b6b25bf7b99 # v8.3.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .github/workflows/update-easyrsa-hash.yml
================================================
name: Update Easy-RSA SHA256
# Note: This workflow commits and pushes changes to openvpn-install.sh.
# Uses PAT to trigger CI on the resulting commit. Infinite recursion is prevented
# by the 'renovate/' branch prefix check - CI commits don't re-trigger this workflow.
# Requires: Create a PAT with 'contents: write' scope and add as repository secret 'PAT'
on:
pull_request:
types: [opened, synchronize]
paths:
- "openvpn-install.sh"
permissions:
contents: read
jobs:
update-hash:
if: startsWith(github.head_ref, 'renovate/')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.PAT }}
persist-credentials: false
- name: Extract version and update SHA256
run: |
VERSION=$(grep -oP 'EASYRSA_VERSION="\K[^"]+' openvpn-install.sh)
if [ -z "$VERSION" ]; then
echo "Error: Failed to extract EASYRSA_VERSION"
exit 1
fi
echo "Easy-RSA version: $VERSION"
CURRENT_SHA=$(grep -oP 'EASYRSA_SHA256="\K[^"]+' openvpn-install.sh)
if [ -z "$CURRENT_SHA" ]; then
echo "Error: Failed to extract EASYRSA_SHA256"
exit 1
fi
echo "Current SHA256: $CURRENT_SHA"
TARBALL_URL="https://github.com/OpenVPN/easy-rsa/releases/download/v${VERSION}/EasyRSA-${VERSION}.tgz"
if ! curl -fsSL "$TARBALL_URL" -o /tmp/easyrsa.tgz; then
echo "Error: Failed to download Easy-RSA tarball from $TARBALL_URL"
exit 1
fi
NEW_SHA=$(sha256sum /tmp/easyrsa.tgz | cut -d' ' -f1)
echo "New SHA256: $NEW_SHA"
if [ "$CURRENT_SHA" != "$NEW_SHA" ]; then
sed -i "s|EASYRSA_SHA256=\"$CURRENT_SHA\"|EASYRSA_SHA256=\"$NEW_SHA\"|" openvpn-install.sh
echo "SHA256 updated"
echo "HASH_CHANGED=true" >> "$GITHUB_ENV"
else
echo "SHA256 already correct"
fi
- name: Commit changes
if: env.HASH_CHANGED == 'true'
env:
PAT: ${{ secrets.PAT }}
run: |
if ! git diff --quiet openvpn-install.sh; then
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git remote set-url origin "https://x-access-token:${PAT}@github.com/${{ github.repository }}"
git add openvpn-install.sh
git commit -m "chore: update Easy-RSA SHA256 hash"
git push
else
echo "No changes to commit"
fi
================================================
FILE: .trivyignore
================================================
# Test containers require root for OpenVPN NET_ADMIN capability
AVD-DS-0002
# Test containers don't need healthcheck
AVD-DS-0026
# False positive: yum clean all is present in the conditional but Trivy doesn't detect it
AVD-DS-0015
================================================
FILE: AGENTS.md
================================================
- Use gh CLI to interact with GitHub
- Test locally using the Docker setup when needed
- When doing changes, check if README/FAQ and tests needs to be updated
- Remember the script and documentation needs to be accessible to a moderately technical audience
- Keep PR description concise (no test plan)
- Don't use gh cli to post comments on the developer's behalf
- Don't amend commits and force push unless told otherwise
================================================
FILE: FAQ.md
================================================
# FAQ
**Q:** The script has been updated since I installed OpenVPN. How do I update?
**A:** You can't. Managing updates and new features from the script would require way too much work. Your only solution is to uninstall OpenVPN and reinstall with the updated script.
You can, of course, it's even recommended, update the `openvpn` package with your package manager.
---
**Q:** How do I renew certificates before they expire?
**A:** Use the CLI commands to renew certificates:
```bash
# Renew a client certificate
./openvpn-install.sh client renew alice
# Renew with custom validity period (365 days)
./openvpn-install.sh client renew alice --cert-days 365
# Renew the server certificate
./openvpn-install.sh server renew
```
For client renewals, a new `.ovpn` file will be generated that you need to distribute to the client. For server renewals, the OpenVPN service will need to be restarted (the script will prompt you).
---
**Q:** How do I check for DNS leaks?
**A:** Go to [browserleaks.com](https://browserleaks.com/dns) or [ipleak.net](https://ipleak.net/) (both perform IPv4 and IPv6 check) with your browser. Your IP should not show up (test without and without the VPN). The DNS servers should be the ones you selected during the setup, not your IP address nor your ISP's DNS servers' addresses.
---
**Q:** How do I fix DNS leaks?
**A:** On Windows 10 DNS leaks are blocked by default with the `block-outside-dns` option.
On Linux you need to add these lines to your `.ovpn` file based on your Distribution.
Debian 9, 10 and Ubuntu 16.04, 18.04
```
script-security 2
up /etc/openvpn/update-resolv-conf
down /etc/openvpn/update-resolv-conf
```
CentOS 6, 7
```
script-security 2
up /usr/share/doc/openvpn-2.4.8/contrib/pull-resolv-conf/client.up
down /usr/share/doc/openvpn-2.4.8/contrib/pull-resolv-conf/client.down
```
CentOS 8, Fedora 30, 31
```
script-security 2
up /usr/share/doc/openvpn/contrib/pull-resolv-conf/client.up
down /usr/share/doc/openvpn/contrib/pull-resolv-conf/client.down
```
Arch Linux
```
script-security 2
up /usr/share/openvpn/contrib/pull-resolv-conf/client.up
down /usr/share/openvpn/contrib/pull-resolv-conf/client.down
```
---
**Q:** IPv6 is not working on my Hetzner VM
**A:** This an issue on their side. See <https://angristan.xyz/fix-ipv6-hetzner-cloud/>
---
**Q:** DNS is not working on my Linux client
**A:** See "How do I fix DNS leaks?" question
---
**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`.
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`
---
**Q:** My router can't connect
**A:**
- `Options error: No closing quotation (") in config.ovpn:46` :
type `yes` when asked to customize encryption settings and choose `tls-auth`
---
**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.
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
# iptables
iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -d 192.168.1.0/24 -j MASQUERADE
# or nftables
nft add rule ip nat postrouting ip saddr 10.8.0.0/24 ip daddr 192.168.1.0/24 masquerade
```
Make this persistent by adding it to your firewall scripts.
Restart OpenVPN after making changes: `systemctl restart openvpn-server@server`
---
**Q:** How can I add multiple users in one go?
**A:** Here is a sample Bash script to achieve this:
```bash
#!/bin/bash
userlist=(user1 user2 user3)
for user in "${userlist[@]}"; do
./openvpn-install.sh client add "$user"
done
```
From a list in a text file:
```bash
#!/bin/bash
while read -r user; do
./openvpn-install.sh client add "$user"
done < users.txt
```
To add password-protected clients:
```bash
#!/bin/bash
./openvpn-install.sh client add alice --password "secretpass123"
```
---
**Q:** How do I change the default `.ovpn` file created for future clients?
**A:** You can edit the template out of which `.ovpn` files are created by editing `/etc/openvpn/server/client-template.txt`
---
**Q:** For my clients - I want to set my internal network to pass through the VPN and the rest to go through my internet?
**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
```sh
route-nopull
route 10.0.0.0 255.0.0.0
```
So for example - here it would route all traffic of `10.0.0.0/8` to the VPN. And the rest through the internet.
---
**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"
```
Or add specific IPv6 routes:
```
push "route-ipv6 2001:db8::/32"
```
5. Restart OpenVPN: `systemctl restart openvpn-server@server`
---
**Q:** I have enabled IPv6 and my VPN client gets an IPv6 address. Why do I reach the sites or other dual-stacked destinations via IPv4 only?
**A:** This is because inside the tunnel you don't get a publicly routable IPv6 address, instead you get an ULA (Unlique Local Lan) address. Operating systems don't prefer this all the time. You can fix this in your operating system policies as it's unrelated to the VPN itself:
Windows (commands needs to run cmd.exe as Administrator):
```
netsh interface ipv6 add prefixpolicy fd00::/8 3 1
```
Linux:
edit `/etc/gai.conf` and uncomment the following line and also change its value to `1`:
```
label fc00::/7 1
```
This will not work properly unless you add you your VPN server `server.conf` one or two lines to push at least 1 (one) IPv6 DNS server. Most providers have IPv6 servers as well, add two more lines of `push "dhcp-option DNS <IPv6>"`
---
**Q:** How can I run OpenVPN on port 443 alongside a web server?
**A:** Use OpenVPN's `port-share` feature to multiplex both services on the same port. When OpenVPN receives non-VPN traffic, it forwards it to your web server.
1. During installation, select **TCP** and port **443**
2. Configure your web server to listen on a different port (e.g., 8443)
3. Add to `/etc/openvpn/server/server.conf`:
```
port-share 127.0.0.1 8443
```
4. Restart OpenVPN: `systemctl restart openvpn-server@server`
This is useful when your network only allows outbound connections on port 443. Note that TCP has worse performance than UDP for VPN traffic due to head-of-line blocking, so only use this when necessary.
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2013 Nyr
Copyright (c) 2016 Stanislas Lange (angristan)
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: Makefile
================================================
.PHONY: test test-build test-up test-down test-logs test-clean
# Run the full test suite
test: test-build test-up
@echo "Waiting for tests to complete..."
@for i in $$(seq 1 180); do \
if docker logs openvpn-client 2>&1 | grep -q "ALL TESTS PASSED"; then \
echo "✓ Tests passed!"; \
$(MAKE) test-down; \
exit 0; \
fi; \
if docker logs openvpn-client 2>&1 | grep -q "FAIL:"; then \
echo "✗ Tests failed!"; \
docker logs openvpn-client; \
$(MAKE) test-down; \
exit 1; \
fi; \
echo "Waiting... ($$i/180)"; \
sleep 2; \
done; \
echo "Timeout waiting for tests"; \
$(MAKE) test-down; \
exit 1
# Build test containers
test-build:
BASE_IMAGE=$(BASE_IMAGE) docker compose build
# Start test containers
test-up:
docker compose up -d
# Stop and remove test containers
test-down:
docker compose down -v --remove-orphans
# View logs
test-logs:
docker compose logs -f
# View server logs only
test-logs-server:
docker logs -f openvpn-server
# View client logs only
test-logs-client:
docker logs -f openvpn-client
# Full cleanup
test-clean: test-down
docker rmi openvpn-install-openvpn-server openvpn-install-openvpn-client 2>/dev/null || true
docker volume prune -f
# Interactive shell into server container
test-shell-server:
docker exec -it openvpn-server /bin/bash
# Interactive shell into client container
test-shell-client:
docker exec -it openvpn-client /bin/bash
# Test specific distributions
test-ubuntu-18.04:
$(MAKE) test BASE_IMAGE=ubuntu:18.04
test-ubuntu-20.04:
$(MAKE) test BASE_IMAGE=ubuntu:20.04
test-ubuntu-22.04:
$(MAKE) test BASE_IMAGE=ubuntu:22.04
test-ubuntu-24.04:
$(MAKE) test BASE_IMAGE=ubuntu:24.04
test-debian-11:
$(MAKE) test BASE_IMAGE=debian:11
test-debian-12:
$(MAKE) test BASE_IMAGE=debian:12
test-fedora-40:
$(MAKE) test BASE_IMAGE=fedora:40
test-fedora-41:
$(MAKE) test BASE_IMAGE=fedora:41
test-rocky-8:
$(MAKE) test BASE_IMAGE=rockylinux:8
test-rocky-9:
$(MAKE) test BASE_IMAGE=rockylinux:9
test-almalinux-8:
$(MAKE) test BASE_IMAGE=almalinux:8
test-almalinux-9:
$(MAKE) test BASE_IMAGE=almalinux:9
test-oracle-8:
$(MAKE) test BASE_IMAGE=oraclelinux:8
test-oracle-9:
$(MAKE) test BASE_IMAGE=oraclelinux:9
test-amazon-2023:
$(MAKE) test BASE_IMAGE=amazonlinux:2023
test-arch:
$(MAKE) test BASE_IMAGE=archlinux:latest
test-centos-stream-9:
$(MAKE) test BASE_IMAGE=quay.io/centos/centos:stream9
test-opensuse-leap:
$(MAKE) test BASE_IMAGE=opensuse/leap:16.0
test-opensuse-tumbleweed:
$(MAKE) test BASE_IMAGE=opensuse/tumbleweed
# Test all distributions (runs sequentially)
test-all:
$(MAKE) test-ubuntu-18.04
$(MAKE) test-ubuntu-20.04
$(MAKE) test-ubuntu-22.04
$(MAKE) test-ubuntu-24.04
$(MAKE) test-debian-11
$(MAKE) test-debian-12
$(MAKE) test-fedora-40
$(MAKE) test-fedora-41
$(MAKE) test-rocky-8
$(MAKE) test-rocky-9
$(MAKE) test-almalinux-8
$(MAKE) test-almalinux-9
$(MAKE) test-oracle-8
$(MAKE) test-oracle-9
$(MAKE) test-amazon-2023
$(MAKE) test-arch
$(MAKE) test-centos-stream-9
$(MAKE) test-opensuse-leap
$(MAKE) test-opensuse-tumbleweed
================================================
FILE: README.md
================================================
# openvpn-install
[](https://saythanks.io/to/angristan)
OpenVPN installer for Debian, Ubuntu, Fedora, openSUSE, CentOS, Amazon Linux, Arch Linux, Oracle Linux, Rocky Linux and AlmaLinux.
This script will let you setup and manage your own secure VPN server in just a few seconds.
## What is this?
This script is meant to be run on your own server, whether it's a VPS or a dedicated server, or even a computer at home.
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.
```mermaid
graph LR
A[Phone] -->|Encrypted| VPN
B[Laptop] -->|Encrypted| VPN
C[Computer] -->|Encrypted| VPN
VPN[OpenVPN Server]
VPN --> I[Internet]
```
## 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).
That said, OpenVPN still makes sense when you need:
- **TCP support**: works in restrictive environments where UDP is blocked (corporate networks, airports, hotels, etc.)
- **Password-protected private keys**: WireGuard configs store the private key in plain text
- **Legacy compatibility**: clients exist for pretty much every platform, including older systems
## Features
- Installs and configures a ready-to-use OpenVPN server
- CLI interface for automation and scripting (non-interactive mode with JSON output)
- Certificate renewal for both client and server certificates
- List and monitor connected clients
- 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)
- 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
- Customisable encryption settings, enhanced default settings (see [Security and Encryption](#security-and-encryption) below)
- Uses latest OpenVPN features when available (see [Security and Encryption](#security-and-encryption) below)
- Variety of DNS resolvers to be pushed to the clients
- Choice to use a self-hosted resolver with Unbound (supports already existing Unbound installations)
- Choice between TCP and UDP
- Flexible IPv4/IPv6 support:
- IPv4 or IPv6 server endpoint (how clients connect)
- IPv4-only, IPv6-only, or dual-stack clients (VPN addressing and internet access)
- All combinations supported: 4→4, 4→4/6, 4→6, 6→4, 6→6, 6→4/6
- Automatic leak prevention: blocks undesired protocol in single-stack modes
- Unprivileged mode: run as `nobody`/`nogroup`
- Block DNS leaks on Windows 10
- Randomised server certificate name
- Choice to protect clients with a password (private key encryption)
- Option to allow multiple devices to use the same client profile simultaneously (disables persistent IP addresses)
- **Peer fingerprint authentication** (OpenVPN 2.6+): Simplified WireGuard-like authentication without a CA
- Many other little things!
## Compatibility
The script supports these Linux distributions:
| | Support |
| ------------------- | ------- |
| AlmaLinux >= 8 | ✅ 🤖 |
| Amazon Linux 2023 | ✅ 🤖 |
| Arch Linux | ✅ 🤖 |
| CentOS Stream >= 8 | ✅ 🤖 |
| Debian >= 11 | ✅ 🤖 |
| Fedora >= 40 | ✅ 🤖 |
| openSUSE Leap >= 16 | ✅ 🤖 |
| openSUSE Tumbleweed | ✅ 🤖 |
| Oracle Linux >= 8 | ✅ 🤖 |
| Rocky Linux >= 8 | ✅ 🤖 |
| Ubuntu >= 18.04 | ✅ 🤖 |
To be noted:
- The script is regularly tested against the distributions marked with a 🤖 only.
- It's only tested on `amd64` architecture.
- The script requires `systemd`.
### Recommended providers
- [Vultr](https://umami.stanislas.cloud/q/1HH9Thp8i): Worldwide locations, IPv6 support, starting at \$2.5/month
- [Hetzner](https://umami.stanislas.cloud/q/HdzaOJWq7): Worldwide locations, IPv6, 20 TB of traffic, starting at €3.59/month
- [Digital Ocean](https://umami.stanislas.cloud/q/sEVh1l79B): Worldwide locations, IPv6 support, starting at \$4/month
## Usage
First, download the script on your server and make it executable:
```bash
curl -O https://raw.githubusercontent.com/angristan/openvpn-install/master/openvpn-install.sh
chmod +x openvpn-install.sh
```
You need to run the script as root and have the TUN module enabled.
### Interactive Mode
The easiest way to get started is the interactive menu:
```bash
./openvpn-install.sh interactive
```
This will guide you through installation and client management.
In your home directory, you will have `.ovpn` files. These are the client configuration files. Download them from your server (using `scp` for example) and connect using your favorite OpenVPN client.
If you have any question, head to the [FAQ](#faq) first. And if you need help, you can open a [discussion](https://github.com/angristan/openvpn-install/discussions). Please search existing issues and discussions first.
### CLI Mode
> [!WARNING]
> API compatibility is not guaranteed. Breaking changes may occur between versions. If you use this script programmatically (e.g., in automation or CI/CD), pin to a specific commit rather than using the master branch.
For automation and scripting, use the CLI interface:
```bash
# Install with defaults
./openvpn-install.sh install
# Add a client
./openvpn-install.sh client add alice
# List clients
./openvpn-install.sh client list
# Revoke a client (immediately disconnects if connected)
./openvpn-install.sh client revoke alice
```
#### Commands
```text
openvpn-install <command> [options]
Commands:
install Install and configure OpenVPN server
uninstall Remove OpenVPN server
client Manage client certificates
server Server management
interactive Launch interactive menu
Global Options:
--verbose Show detailed output
--log <path> Log file path (default: openvpn-install.log)
--no-log Disable file logging
--no-color Disable colored output
-h, --help Show help
```
Run `./openvpn-install.sh <command> --help` for command-specific options.
#### Client Management
```bash
# Add a new client
./openvpn-install.sh client add alice
# Add a password-protected client
./openvpn-install.sh client add bob --password
# Revoke a client
./openvpn-install.sh client revoke alice
# Renew a client certificate
./openvpn-install.sh client renew bob --cert-days 365
```
List all clients:
```text
$ ./openvpn-install.sh client list
══ Client Certificates ══
[INFO] Found 3 client certificate(s)
Name Status Expiry Remaining
---- ------ ------ ---------
alice Valid 2035-01-15 3650 days
bob Valid 2035-01-15 3650 days
charlie Revoked 2035-01-15 unknown
```
JSON output for scripting:
```text
$ ./openvpn-install.sh client list --format json | jq
{
"clients": [
{
"name": "alice",
"status": "valid",
"expiry": "2035-01-15",
"days_remaining": 3650
},
{
"name": "bob",
"status": "valid",
"expiry": "2035-01-15",
"days_remaining": 3650
},
{
"name": "charlie",
"status": "revoked",
"expiry": "2035-01-15",
"days_remaining": null
}
]
}
```
#### Server Management
```bash
# Renew server certificate
./openvpn-install.sh server renew
# Uninstall OpenVPN
./openvpn-install.sh uninstall
```
Show connected clients (data refreshes every 60 seconds):
```text
$ ./openvpn-install.sh server status
══ Connected Clients ══
[INFO] Found 2 connected client(s)
Name Real Address VPN IP Connected Since Transfer
---- ------------ ------ --------------- --------
alice 203.0.113.45:52341 10.8.0.2 2025-01-15 14:32 ↓1.2M ↑500K
bob 198.51.100.22:41892 10.8.0.3 2025-01-15 09:15 ↓800K ↑200K
[INFO] Note: Data refreshes every 60 seconds.
```
#### Install Options
The `install` command supports many options for customization:
```bash
# Custom port and protocol
./openvpn-install.sh install --port 443 --protocol tcp
# Custom DNS provider
./openvpn-install.sh install --dns quad9
# Custom encryption settings
./openvpn-install.sh install --cipher AES-256-GCM --cert-type rsa --rsa-bits 4096
# Custom VPN subnet
./openvpn-install.sh install --subnet-ipv4 10.9.0.0
# Enable dual-stack (IPv4 + IPv6) for clients
./openvpn-install.sh install --client-ipv4 --client-ipv6
# IPv6-only clients (no IPv4)
./openvpn-install.sh install --no-client-ipv4 --client-ipv6
# IPv6 endpoint (server listens on IPv6, clients connect via IPv6)
./openvpn-install.sh install --endpoint-type 6 --endpoint 2001:db8::1
# Custom IPv6 subnet for dual-stack setup
./openvpn-install.sh install --client-ipv6 --subnet-ipv6 fd00:1234:5678::
# Skip initial client creation
./openvpn-install.sh install --no-client
# Full example with multiple options
./openvpn-install.sh install \
--port 443 \
--protocol tcp \
--dns cloudflare \
--cipher AES-256-GCM \
--client mydevice \
--client-cert-days 365
```
**Network Options:**
- `--endpoint <host>` - Public IP or hostname for clients (default: auto-detected)
- `--endpoint-type <4|6>` - Endpoint IP version (default: `4`)
- `--ip <addr>` - Server listening IP (default: auto-detected)
- `--client-ipv4` - Enable IPv4 for VPN clients (default: enabled)
- `--no-client-ipv4` - Disable IPv4 for VPN clients
- `--client-ipv6` - Enable IPv6 for VPN clients (default: disabled)
- `--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::`)
- `--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`)
**DNS Options:**
- `--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`)
**Security Options:**
- `--cipher <cipher>` - Data cipher (default: `AES-128-GCM`). Options: `AES-128-GCM`, `AES-192-GCM`, `AES-256-GCM`, `AES-128-CBC`, `AES-192-CBC`, `AES-256-CBC`, `CHACHA20-POLY1305`
- `--cert-type <ecdsa|rsa>` - Certificate type (default: `ecdsa`)
- `--cert-curve <curve>` - ECDSA curve (default: `prime256v1`). Options: `prime256v1`, `secp384r1`, `secp521r1`
- `--rsa-bits <2048|3072|4096>` - RSA key size (default: `2048`)
- `--hmac <alg>` - HMAC algorithm (default: `SHA256`). Options: `SHA256`, `SHA384`, `SHA512`
- `--tls-sig <mode>` - TLS mode (default: `crypt-v2`). Options: `crypt-v2`, `crypt`, `auth`
- `--auth-mode <mode>` - Authentication mode (default: `pki`). Options: `pki` (CA-based), `fingerprint` (peer-fingerprint, requires OpenVPN 2.6+)
- `--tls-version-min <1.2|1.3>` - Minimum TLS version (default: `1.2`)
- `--tls-ciphersuites <list>` - TLS 1.3 cipher suites, colon-separated (default: `TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256`)
- `--tls-groups <list>` - Key exchange groups, colon-separated (default: `X25519:prime256v1:secp384r1:secp521r1`)
- `--server-cert-days <n>` - Server cert validity in days (default: `3650`)
**Client Options:**
- `--client <name>` - Initial client name (default: `client`)
- `--client-password [pass]` - Password-protect client key (default: no password)
- `--client-cert-days <n>` - Client cert validity in days (default: `3650`)
- `--no-client` - Skip initial client creation
**Other Options:**
- `--multi-client` - Allow same cert on multiple devices (default: disabled)
#### Automation Examples
**Batch client creation:**
```bash
#!/bin/bash
for user in alice bob charlie; do
./openvpn-install.sh client add "$user"
done
```
**Create clients from a file:**
```bash
#!/bin/bash
while read -r user; do
./openvpn-install.sh client add "$user"
done < users.txt
```
**JSON output for scripting:**
```bash
# Get client list as JSON
./openvpn-install.sh client list --format json | jq '.clients[] | select(.status == "valid")'
# Get connected clients as JSON
./openvpn-install.sh server status --format json
```
## Fork
This script is based on the great work of [Nyr and its contributors](https://github.com/Nyr/openvpn-install).
Since 2016, the two scripts have diverged and are not alike anymore, especially under the hood. The main goal of the script was enhanced security. But since then, the script has been completely rewritten and a lot a features have been added. The script is only compatible with recent distributions though, so if you need to use a very old server or client, I advise using Nyr's script.
## FAQ
More Q&A in [FAQ.md](FAQ.md).
**Q:** Which provider do you recommend?
**A:** I recommend these:
- [Vultr](https://www.vultr.com/?ref=8948982-8H): Worldwide locations, IPv6 support, starting at \$2.5/month
- [Hetzner](https://hetzner.cloud/?ref=ywtlvZsjgeDq): Worldwide locations, IPv6, 20 TB of traffic, starting at €3.59/month
- [Digital Ocean](https://m.do.co/c/ed0ba143fe53): Worldwide locations, IPv6 support, starting at \$4/month
---
**Q:** Which OpenVPN client do you recommend?
**A:** If possible, an official OpenVPN 2.4 client.
- Windows: [The official OpenVPN community client](https://openvpn.net/index.php/download/community-downloads.html).
- Linux: The `openvpn` package from your distribution. There is an [official APT repository](https://community.openvpn.net/openvpn/wiki/OpenvpnSoftwareRepos) for Debian/Ubuntu based distributions.
- macOS: [Tunnelblick](https://tunnelblick.net/), [Viscosity](https://www.sparklabs.com/viscosity/), [OpenVPN for Mac](https://openvpn.net/client-connect-vpn-for-mac-os/).
- Android: [OpenVPN for Android](https://play.google.com/store/apps/details?id=de.blinkt.openvpn).
- iOS: [The official OpenVPN Connect client](https://itunes.apple.com/us/app/openvpn-connect/id590379981).
---
**Q:** Am I safe from the NSA by using your script?
**A:** Please review your threat models. Even if this script has security in mind and uses state-of-the-art encryption, you shouldn't be using a VPN if you want to hide from the NSA.
---
**Q:** Is there an OpenVPN documentation?
**A:** Yes, please head to the [OpenVPN Manual](https://openvpn.net/community-docs/community-articles/openvpn-2-6-manual.html), which references all the options.
---
More Q&A in [FAQ.md](FAQ.md).
## Contributing
### Discuss changes
Please open an issue before submitting a PR if you want to discuss a change, especially if it's a big one.
## Security and Encryption
> [!NOTE]
> This script was created in 2016 when OpenVPN's defaults were quite weak. Back then, customising encryption settings was essential for a secure setup. Since then, OpenVPN has significantly improved its defaults, but the script still offers customisation options.
OpenVPN 2.3 and earlier shipped with outdated defaults like Blowfish (BF-CBC), TLS 1.0, and SHA1. Each major release since has brought significant improvements:
- **OpenVPN 2.4** (2016): Added ECDSA, ECDH, AES-GCM, NCP (cipher negotiation), and tls-crypt
- **OpenVPN 2.5** (2020): Default cipher changed from BF-CBC to AES-256-GCM:AES-128-GCM, added ChaCha20-Poly1305, tls-crypt-v2, and TLS 1.3 support
- **OpenVPN 2.6** (2023): TLS 1.2 minimum by default, compression blocked by default, `--peer-fingerprint` for PKI-less setups, and DCO kernel acceleration
If you want more information about an option mentioned below, head to the [OpenVPN manual](https://community.openvpn.net/openvpn/wiki/Openvpn24ManPage). It is very complete.
Certificate and PKI management is handled by [Easy-RSA](https://github.com/OpenVPN/easy-rsa). Default parameters are in the [vars.example](https://github.com/OpenVPN/easy-rsa/blob/v3.2.2/easyrsa3/vars.example) file.
### Compression
This script used to support LZ4 and LZO compression algorithms, but discouraged their use due to the [VORACLE attack](https://community.openvpn.net/Security%20Announcements/VORACLE) vulnerability.
OpenVPN 2.6+ defaults `--allow-compression` to `no`, blocking even server-pushed compression. Now that OpenVPN is removing compression support entirely, this script no longer supports it.
### TLS version
> [!NOTE]
> OpenVPN 2.6+ defaults to TLS 1.2 minimum. Prior versions accepted TLS 1.0 by default.
OpenVPN 2.5 and earlier accepted TLS 1.0 by default, which is nearly [20 years old](https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_1.0).
This script defaults to `tls-version-min 1.2` for compatibility with all OpenVPN 2.4+ clients. You can optionally set `tls-version-min 1.3` for environments where all clients support TLS 1.3.
**TLS 1.3 support** was added in OpenVPN 2.5 and requires OpenSSL 1.1.1+. TLS 1.3 offers improved security and performance with a simplified handshake.
The script configures TLS 1.3 cipher suites via `--tls-ciphersuites` (separate from the TLS 1.2 `--tls-cipher` option). The default TLS 1.3 cipher suites are:
- `TLS_AES_256_GCM_SHA384`
- `TLS_AES_128_GCM_SHA256`
- `TLS_CHACHA20_POLY1305_SHA256`
TLS 1.2 is supported since OpenVPN 2.3.3. TLS 1.3 is supported since OpenVPN 2.5.
### Certificate
OpenVPN uses an RSA certificate with a 2048 bits key by default.
OpenVPN 2.4 added support for ECDSA. Elliptic curve cryptography is faster, lighter and more secure.
This script provides:
- ECDSA: `prime256v1`/`secp384r1`/`secp521r1` curves
- RSA: `2048`/`3072`/`4096` bits keys
It defaults to ECDSA with `prime256v1`.
OpenVPN uses `SHA-256` as the signature hash by default, and so does the script. It provides no other choice as of now.
### Authentication Mode
The script supports two authentication modes:
#### PKI Mode (default)
Traditional Certificate Authority (CA) based authentication. The server and all clients have certificates signed by the same CA. Client revocation is handled via Certificate Revocation Lists (CRL).
This is the recommended mode for larger deployments where you need:
- Centralized certificate management
- Standard CRL-based revocation
- Compatibility with all OpenVPN versions
#### Peer Fingerprint Mode (OpenVPN 2.6+)
A simplified WireGuard-like authentication model using SHA256 certificate fingerprints instead of a CA chain. Each peer (server and clients) has a self-signed certificate, and peers authenticate each other by verifying fingerprints.
```bash
# Install with fingerprint mode
./openvpn-install.sh install --auth-mode fingerprint
```
Benefits:
- Simpler setup: No CA infrastructure needed
- Easier to understand: Similar to SSH's `known_hosts` model
- Ideal for small setups: Home networks, labs, small teams
How it works:
1. Server generates a self-signed certificate and stores its fingerprint
2. Each client generates a self-signed certificate
3. Client fingerprints are added to the server's `<peer-fingerprint>` block
4. Clients verify the server using the server's fingerprint
5. Revocation removes the fingerprint from the server config (no CRL needed)
Trade-off: Revoking a client requires reloading OpenVPN (fingerprints are in server.conf). In PKI mode, the CRL file is re-read automatically on new connections.
### Data channel
> [!NOTE]
> The default data channel cipher changed in OpenVPN 2.5. Prior versions defaulted to `BF-CBC`, while OpenVPN 2.5+ defaults to `AES-256-GCM:AES-128-GCM`. OpenVPN 2.6+ also includes `CHACHA20-POLY1305` in the default cipher list when available.
By default, OpenVPN 2.4 and earlier used `BF-CBC` as the data channel cipher. Blowfish is an old (1993) and weak algorithm. Even the official OpenVPN documentation admits it.
> The default is BF-CBC, an abbreviation for Blowfish in Cipher Block Chaining mode.
>
> Using BF-CBC is no longer recommended, because of its 64-bit block size. This small block size allows attacks based on collisions, as demonstrated by SWEET32. See <https://community.openvpn.net/openvpn/wiki/SWEET32> for details.
> Security researchers at INRIA published an attack on 64-bit block ciphers, such as 3DES and Blowfish. They show that they are able to recover plaintext when the same data is sent often enough, and show how they can use cross-site scripting vulnerabilities to send data of interest often enough. This works over HTTPS, but also works for HTTP-over-OpenVPN. See <https://sweet32.info/> for a much better and more elaborate explanation.
>
> OpenVPN's default cipher, BF-CBC, is affected by this attack.
Indeed, AES is today's standard. It's the fastest and more secure cipher available today. [SEED](https://en.wikipedia.org/wiki/SEED) and [Camellia](<https://en.wikipedia.org/wiki/Camellia_(cipher)>) are not vulnerable to date but are slower than AES and relatively less trusted.
> Of the currently supported ciphers, OpenVPN currently recommends using AES-256-CBC or AES-128-CBC. OpenVPN 2.4 and newer will also support GCM. For 2.4+, we recommend using AES-256-GCM or AES-128-GCM.
AES-256 is 40% slower than AES-128, and there isn't any real reason to use a 256 bits key over a 128 bits key with AES. (Source: [1](http://security.stackexchange.com/questions/14068/why-most-people-use-256-bit-encryption-instead-of-128-bit),[2](http://security.stackexchange.com/questions/6141/amount-of-simple-operations-that-is-safely-out-of-reach-for-all-humanity/6149#6149)). Moreover, AES-256 is more vulnerable to [Timing attacks](https://en.wikipedia.org/wiki/Timing_attack).
AES-GCM is an [AEAD cipher](https://en.wikipedia.org/wiki/Authenticated_encryption) which means it simultaneously provides confidentiality, integrity, and authenticity assurances on the data.
ChaCha20-Poly1305 is another AEAD cipher that provides similar security to AES-GCM. It is particularly useful on devices without hardware AES acceleration (AES-NI), such as older CPUs and many ARM-based devices, where it can be significantly faster than AES.
The script supports the following ciphers:
- `AES-128-GCM`
- `AES-192-GCM`
- `AES-256-GCM`
- `AES-128-CBC`
- `AES-192-CBC`
- `AES-256-CBC`
- `CHACHA20-POLY1305` (requires OpenVPN 2.5+)
And defaults to `AES-128-GCM`.
OpenVPN 2.4 added a feature called "NCP": _Negotiable Crypto Parameters_. It means you can provide a cipher suite like with HTTPS. It is set to `AES-256-GCM:AES-128-GCM` by default and overrides the `--cipher` parameter when used with an OpenVPN 2.4 client. For the sake of simplicity, the script sets `--cipher` (fallback for non-NCP clients), `--data-ciphers` (modern OpenVPN 2.5+ naming), and `--ncp-ciphers` (legacy alias for OpenVPN 2.4 compatibility) to the cipher chosen above.
### Control channel
OpenVPN 2.4 will negotiate the best cipher available by default (e.g ECDHE+AES-256-GCM)
#### TLS 1.2 ciphers (`--tls-cipher`)
The script proposes the following options, depending on the certificate:
- ECDSA:
- `TLS-ECDHE-ECDSA-WITH-AES-128-GCM-SHA256`
- `TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384`
- `TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256` (requires OpenVPN 2.5+)
- RSA:
- `TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256`
- `TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384`
- `TLS-ECDHE-RSA-WITH-CHACHA20-POLY1305-SHA256` (requires OpenVPN 2.5+)
It defaults to `TLS-ECDHE-*-WITH-AES-128-GCM-SHA256`.
#### TLS 1.3 ciphers (`--tls-ciphersuites`)
When TLS 1.3 is negotiated, a separate set of cipher suites is used. These are configured via `--tls-ciphersuites` and use OpenSSL naming conventions:
- `TLS_AES_256_GCM_SHA384`
- `TLS_AES_128_GCM_SHA256`
- `TLS_CHACHA20_POLY1305_SHA256`
By default, all three cipher suites are enabled. TLS 1.3 cipher suites are simpler because they don't include the key exchange algorithm (which is negotiated separately via key shares).
### Key exchange
OpenVPN historically defaulted to 2048-bit DH parameters for key exchange. This script used to offer both DH (with configurable key sizes) and ECDH as alternatives.
OpenVPN 2.4 added ECDH support, and OpenVPN 2.7 made `dh none` (ECDH) the default, as finite-field DH is being deprecated. Since ECDH is now universally supported and preferred, this script no longer offers traditional DH.
The script configures `tls-groups` with the following preference list:
```
X25519:prime256v1:secp384r1:secp521r1
```
- **X25519**: Fast, modern curve (Curve25519), widely supported
- **prime256v1**: NIST P-256, most compatible
- **secp384r1**: NIST P-384, higher security
- **secp521r1**: NIST P-521, highest security
You can customize this with `--tls-groups`.
### HMAC digest algorithm
From the OpenVPN wiki, about `--auth`:
> Authenticate data channel packets and (if enabled) tls-auth control channel packets with HMAC using message digest algorithm alg. (The default is SHA1 ). HMAC is a commonly used message authentication algorithm (MAC) that uses a data string, a secure hash algorithm, and a key, to produce a digital signature.
>
> If an AEAD cipher mode (e.g. GCM) is chosen, the specified --auth algorithm is ignored for the data channel, and the authentication method of the AEAD cipher is used instead. Note that alg still specifies the digest used for tls-auth.
The script provides the following choices:
- `SHA256`
- `SHA384`
- `SHA512`
It defaults to `SHA256`.
### `tls-auth`, `tls-crypt`, and `tls-crypt-v2`
From the OpenVPN wiki, about `tls-auth`:
> Add an additional layer of HMAC authentication on top of the TLS control channel to mitigate DoS attacks and attacks on the TLS stack.
>
> In a nutshell, --tls-auth enables a kind of "HMAC firewall" on OpenVPN's TCP/UDP port, where TLS control channel packets bearing an incorrect HMAC signature can be dropped immediately without response.
About `tls-crypt`:
> Encrypt and authenticate all control channel packets with the key from keyfile. (See --tls-auth for more background.)
>
> Encrypting (and authenticating) control channel packets:
>
> - provides more privacy by hiding the certificate used for the TLS connection,
> - makes it harder to identify OpenVPN traffic as such,
> - provides "poor-man's" post-quantum security, against attackers who will never know the pre-shared key (i.e. no forward secrecy).
So both provide an additional layer of security and mitigate DoS attacks. They aren't used by default by OpenVPN.
`tls-crypt` is an OpenVPN 2.4 feature that provides encryption in addition to authentication (unlike `tls-auth`). It is more privacy-friendly.
`tls-crypt-v2` is an OpenVPN 2.5 feature that builds on `tls-crypt` by using **per-client keys** instead of a shared key. Each client receives a unique key derived from a server key. This provides:
- **Better security**: If a client key is compromised, other clients are not affected
- **Easier key management**: Client keys can be revoked individually without regenerating the server key
- **Scalability**: Better suited for large deployments with many clients
The script supports all three options:
- `tls-crypt-v2` (default): Per-client keys for better security
- `tls-crypt`: Shared key for all clients, compatible with OpenVPN 2.4+
- `tls-auth`: HMAC authentication only (no encryption), compatible with older clients
### Certificate type verification (`remote-cert-tls`)
The server is configured with `remote-cert-tls client`, which requires connecting peers to have a certificate with the "TLS Web Client Authentication" extended key usage. This prevents a server certificate from being used to impersonate a client.
Similarly, clients are configured with `remote-cert-tls server` to ensure they only connect to servers presenting valid server certificates. This protects against an attacker with a valid client certificate setting up a rogue server.
### Data Channel Offload (DCO)
[Data Channel Offload](https://openvpn.net/as-docs/openvpn-data-channel-offload.html) (DCO) is a kernel acceleration feature that significantly improves OpenVPN performance by keeping data channel encryption/decryption in kernel space, eliminating costly context switches between user and kernel space for each packet.
DCO was merged into the Linux kernel 6.16 (April 2025).
**Requirements:**
- OpenVPN 2.6.0 or later
- Linux kernel 6.16+ (built-in) or `ovpn-dco` kernel module
- UDP protocol (TCP is not supported)
- AEAD cipher (`AES-128-GCM`, `AES-256-GCM`, or `CHACHA20-POLY1305`)
The script's default settings (AES-128-GCM, UDP) are DCO-compatible. When DCO is available and the configuration is compatible, OpenVPN will automatically use it for improved performance.
**Note:** DCO must be supported on both the server and the client for full acceleration. Client support is available in OpenVPN 2.6+ (Linux, Windows, FreeBSD) and OpenVPN Connect 3.4+ (Windows). macOS does not currently support DCO, but clients can still connect to DCO-enabled servers with partial performance benefits on the server-side.
The script will display the DCO availability status during installation.
## Say thanks
You can [say thanks](https://saythanks.io/to/angristan) if you want!
## Credits & Licence
Many thanks to the [contributors](https://github.com/Angristan/OpenVPN-install/graphs/contributors) and Nyr's original work.
This project is under the [MIT Licence](https://raw.githubusercontent.com/Angristan/openvpn-install/master/LICENSE)
## Star History
[](https://star-history.com/#angristan/openvpn-install&Date)
================================================
FILE: biome.json
================================================
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 2
}
}
================================================
FILE: docker-compose.yml
================================================
---
services:
openvpn-server:
build:
context: .
dockerfile: test/Dockerfile.server
args:
BASE_IMAGE: ${BASE_IMAGE:-ubuntu:24.04}
ENABLE_FIREWALLD: ${ENABLE_FIREWALLD:-n}
ENABLE_NFTABLES: ${ENABLE_NFTABLES:-n}
container_name: openvpn-server
hostname: openvpn-server
privileged: true
cgroupns: host
devices:
- /dev/net/tun:/dev/net/tun
sysctls:
- net.ipv4.ip_forward=1
volumes:
- shared-config:/shared
- /sys/fs/cgroup:/sys/fs/cgroup:rw
tmpfs:
- /run
- /run/lock
networks:
vpn-test:
ipv4_address: 172.28.0.10
stop_signal: SIGRTMIN+3
healthcheck:
test: ["CMD", "pgrep", "openvpn"]
interval: 5s
timeout: 3s
retries: 30
openvpn-client:
build:
context: .
dockerfile: test/Dockerfile.client
container_name: openvpn-client
hostname: openvpn-client
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
volumes:
- shared-config:/shared
networks:
vpn-test:
ipv4_address: 172.28.0.20
depends_on:
openvpn-server:
condition: service_healthy
volumes:
shared-config:
networks:
vpn-test:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/24
================================================
FILE: openvpn-install.sh
================================================
#!/bin/bash
# shellcheck disable=SC1091,SC2034
# SC1091: Not following /etc/os-release (sourced dynamically)
# SC2034: Variables used indirectly or exported for subprocesses
# Secure OpenVPN server installer for Debian, Ubuntu, CentOS, Amazon Linux 2023, Fedora, Oracle Linux, Arch Linux, Rocky Linux and AlmaLinux.
# https://github.com/angristan/openvpn-install
# Configuration constants
readonly DEFAULT_CERT_VALIDITY_DURATION_DAYS=3650 # 10 years
readonly DEFAULT_CRL_VALIDITY_DURATION_DAYS=5475 # 15 years
readonly EASYRSA_VERSION="3.2.6"
readonly EASYRSA_SHA256="c2572990ce91112eef8d1b8e4a3b58790da95b68501785c621f69121dfbd22d7"
# =============================================================================
# Logging Configuration
# =============================================================================
# Set VERBOSE=1 to see command output, VERBOSE=0 (default) for quiet mode
# Set LOG_FILE to customize log location (default: openvpn-install.log in current dir)
# Set LOG_FILE="" to disable file logging
VERBOSE=${VERBOSE:-0}
LOG_FILE=${LOG_FILE:-openvpn-install.log}
OUTPUT_FORMAT=${OUTPUT_FORMAT:-table} # table or json - json suppresses log output
# Color definitions (disabled if not a terminal, unless FORCE_COLOR=1)
if [[ -t 1 ]] || [[ $FORCE_COLOR == "1" ]]; then
readonly COLOR_RESET='\033[0m'
readonly COLOR_RED='\033[0;31m'
readonly COLOR_GREEN='\033[0;32m'
readonly COLOR_YELLOW='\033[0;33m'
readonly COLOR_BLUE='\033[0;34m'
readonly COLOR_CYAN='\033[0;36m'
readonly COLOR_DIM='\033[0;90m'
readonly COLOR_BOLD='\033[1m'
else
readonly COLOR_RESET=''
readonly COLOR_RED=''
readonly COLOR_GREEN=''
readonly COLOR_YELLOW=''
readonly COLOR_BLUE=''
readonly COLOR_CYAN=''
readonly COLOR_DIM=''
readonly COLOR_BOLD=''
fi
# Write to log file (no colors, with timestamp)
_log_to_file() {
if [[ -n "$LOG_FILE" ]]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >>"$LOG_FILE"
fi
}
# Logging functions
log_info() {
[[ $OUTPUT_FORMAT == "json" ]] && return
echo -e "${COLOR_BLUE}[INFO]${COLOR_RESET} $*"
_log_to_file "[INFO] $*"
}
log_warn() {
[[ $OUTPUT_FORMAT == "json" ]] && return
echo -e "${COLOR_YELLOW}[WARN]${COLOR_RESET} $*"
_log_to_file "[WARN] $*"
}
log_error() {
echo -e "${COLOR_RED}[ERROR]${COLOR_RESET} $*" >&2
_log_to_file "[ERROR] $*"
if [[ -n "$LOG_FILE" ]]; then
echo -e "${COLOR_YELLOW} Check the log file for details: ${LOG_FILE}${COLOR_RESET}" >&2
fi
}
log_fatal() {
echo -e "${COLOR_RED}[ERROR]${COLOR_RESET} $*" >&2
_log_to_file "[FATAL] $*"
if [[ -n "$LOG_FILE" ]]; then
echo -e "${COLOR_YELLOW} Check the log file for details: ${LOG_FILE}${COLOR_RESET}" >&2
_log_to_file "Script exited with error"
fi
exit 1
}
log_success() {
[[ $OUTPUT_FORMAT == "json" ]] && return
echo -e "${COLOR_GREEN}[OK]${COLOR_RESET} $*"
_log_to_file "[OK] $*"
}
log_debug() {
if [[ $VERBOSE -eq 1 && $OUTPUT_FORMAT != "json" ]]; then
echo -e "${COLOR_DIM}[DEBUG]${COLOR_RESET} $*"
fi
_log_to_file "[DEBUG] $*"
}
log_prompt() {
# For user-facing prompts/questions (no prefix, just cyan)
# Skip display in non-interactive mode
if [[ $NON_INTERACTIVE_INSTALL != "y" ]]; then
echo -e "${COLOR_CYAN}$*${COLOR_RESET}"
fi
_log_to_file "[PROMPT] $*"
}
log_header() {
# For section headers
# Skip display in non-interactive mode
if [[ $NON_INTERACTIVE_INSTALL != "y" ]]; then
echo ""
echo -e "${COLOR_BOLD}${COLOR_BLUE}=== $* ===${COLOR_RESET}"
echo ""
fi
_log_to_file "=== $* ==="
}
log_menu() {
# For menu options - only show in interactive mode
if [[ $NON_INTERACTIVE_INSTALL != "y" ]]; then
echo "$@"
fi
}
# Run a command with optional output suppression
# Usage: run_cmd "description" command [args...]
run_cmd() {
local desc="$1"
shift
# Display the command being run
echo -e "${COLOR_DIM}> $*${COLOR_RESET}"
_log_to_file "[CMD] $*"
if [[ $VERBOSE -eq 1 ]]; then
if [[ -n "$LOG_FILE" ]]; then
"$@" 2>&1 | tee -a "$LOG_FILE"
else
"$@"
fi
else
if [[ -n "$LOG_FILE" ]]; then
"$@" >>"$LOG_FILE" 2>&1
else
"$@" >/dev/null 2>&1
fi
fi
local ret=$?
if [[ $ret -eq 0 ]]; then
log_debug "$desc completed successfully"
else
log_error "$desc failed with exit code $ret"
fi
return $ret
}
# Run a command that must succeed, exit on failure
# Usage: run_cmd_fatal "description" command [args...]
run_cmd_fatal() {
local desc="$1"
shift
if ! run_cmd "$desc" "$@"; then
log_fatal "$desc failed"
fi
}
# =============================================================================
# CLI Configuration
# =============================================================================
readonly SCRIPT_NAME="openvpn-install"
# =============================================================================
# Help Text Functions
# =============================================================================
show_help() {
cat <<-EOF
OpenVPN installer and manager
Usage: $SCRIPT_NAME <command> [options]
Commands:
install Install and configure OpenVPN server
uninstall Remove OpenVPN server
client Manage client certificates
server Server management
interactive Launch interactive menu
Global Options:
--verbose Show detailed output
--log <path> Log file path (default: openvpn-install.log)
--no-log Disable file logging
--no-color Disable colored output
-h, --help Show help
Run '$SCRIPT_NAME <command> --help' for command-specific help.
EOF
}
show_install_help() {
cat <<-EOF
Install and configure OpenVPN server
Usage: $SCRIPT_NAME install [options]
Options:
-i, --interactive Run interactive install wizard
Network Options:
--endpoint <host> Public IP or hostname for clients (auto-detected)
--endpoint-type <4|6> Endpoint IP version: 4 or 6 (default: 4)
--ip <addr> Server listening IP (auto-detected)
--client-ipv4 Enable IPv4 for VPN clients (default: enabled)
--no-client-ipv4 Disable IPv4 for VPN clients
--client-ipv6 Enable IPv6 for VPN clients
--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::)
--port <num> OpenVPN port (default: 1194)
--port-random Use random port (49152-65535)
--protocol <proto> Protocol: udp or tcp (default: udp)
--mtu <size> Tunnel MTU (default: 1500)
DNS Options:
--dns <provider> DNS provider (default: cloudflare)
Providers: 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 (optional)
Security Options:
--cipher <cipher> Data channel cipher (default: AES-128-GCM)
Ciphers: AES-128-GCM, AES-192-GCM, AES-256-GCM, AES-128-CBC,
AES-192-CBC, AES-256-CBC, CHACHA20-POLY1305
--cert-type <type> Certificate type: ecdsa or rsa (default: ecdsa)
--cert-curve <curve> ECDSA curve (default: prime256v1)
Curves: prime256v1, secp384r1, secp521r1
--rsa-bits <size> RSA key size: 2048, 3072, 4096 (default: 2048)
--cc-cipher <cipher> Control channel cipher (auto-selected)
--tls-version-min <ver> Minimum TLS version: 1.2 or 1.3 (default: 1.2)
--tls-ciphersuites <list> TLS 1.3 cipher suites, colon-separated
--tls-groups <list> Key exchange groups, colon-separated
(default: X25519:prime256v1:secp384r1:secp521r1)
--hmac <alg> HMAC algorithm: SHA256, SHA384, SHA512 (default: SHA256)
--tls-sig <mode> TLS mode: crypt-v2, crypt, auth (default: crypt-v2)
--auth-mode <mode> Auth mode: pki, fingerprint (default: pki)
fingerprint requires OpenVPN 2.6+
--server-cert-days <n> Server cert validity in days (default: 3650)
Other Options:
--multi-client Allow same cert on multiple devices
Initial Client Options:
--client <name> Initial client name (default: client)
--client-password [p] Password-protect client (prompts if no value given)
--client-cert-days <n> Client cert validity in days (default: 3650)
--no-client Skip initial client creation
Examples:
$SCRIPT_NAME install
$SCRIPT_NAME install --port 443 --protocol tcp
$SCRIPT_NAME install --dns quad9 --cipher AES-256-GCM
$SCRIPT_NAME install -i
EOF
}
show_uninstall_help() {
cat <<-EOF
Remove OpenVPN server
Usage: $SCRIPT_NAME uninstall [options]
Options:
-f, --force Skip confirmation prompt
Examples:
$SCRIPT_NAME uninstall
$SCRIPT_NAME uninstall --force
EOF
}
show_client_help() {
cat <<-EOF
Manage client certificates
Usage: $SCRIPT_NAME client <subcommand> [options]
Subcommands:
add <name> Add a new client
list List all clients
revoke <name> Revoke a client certificate
renew <name> Renew a client certificate
Run '$SCRIPT_NAME client <subcommand> --help' for more info.
EOF
}
show_client_add_help() {
cat <<-EOF
Add a new VPN client
Usage: $SCRIPT_NAME client add <name> [options]
Options:
--password [pass] Password-protect client (prompts if no value given)
--cert-days <n> Certificate validity in days (default: 3650)
--output <path> Output path for .ovpn file (default: ~/<name>.ovpn)
Examples:
$SCRIPT_NAME client add alice
$SCRIPT_NAME client add bob --password
$SCRIPT_NAME client add charlie --cert-days 365 --output /tmp/charlie.ovpn
EOF
}
show_client_list_help() {
cat <<-EOF
List all client certificates
Usage: $SCRIPT_NAME client list [options]
Options:
--format <fmt> Output format: table or json (default: table)
Examples:
$SCRIPT_NAME client list
$SCRIPT_NAME client list --format json
EOF
}
show_client_revoke_help() {
cat <<-EOF
Revoke a client certificate
Usage: $SCRIPT_NAME client revoke <name> [options]
Options:
-f, --force Skip confirmation prompt
Examples:
$SCRIPT_NAME client revoke alice
$SCRIPT_NAME client revoke bob --force
EOF
}
show_client_renew_help() {
cat <<-EOF
Renew a client certificate
Usage: $SCRIPT_NAME client renew <name> [options]
Options:
--cert-days <n> New certificate validity in days (default: 3650)
Examples:
$SCRIPT_NAME client renew alice
$SCRIPT_NAME client renew bob --cert-days 365
EOF
}
show_server_help() {
cat <<-EOF
Server management
Usage: $SCRIPT_NAME server <subcommand> [options]
Subcommands:
status List currently connected clients
renew Renew server certificate
Run '$SCRIPT_NAME server <subcommand> --help' for more info.
EOF
}
show_server_status_help() {
cat <<-EOF
List currently connected clients
Note: Client data is updated every 60 seconds by OpenVPN.
Usage: $SCRIPT_NAME server status [options]
Options:
--format <fmt> Output format: table or json (default: table)
Examples:
$SCRIPT_NAME server status
$SCRIPT_NAME server status --format json
EOF
}
show_server_renew_help() {
cat <<-EOF
Renew server certificate
Usage: $SCRIPT_NAME server renew [options]
Options:
--cert-days <n> New certificate validity in days (default: 3650)
-f, --force Skip confirmation/warning
Examples:
$SCRIPT_NAME server renew
$SCRIPT_NAME server renew --cert-days 1825
EOF
}
# =============================================================================
# CLI Command Handlers
# =============================================================================
# Check if OpenVPN is installed
isOpenVPNInstalled() {
[[ -e /etc/openvpn/server/server.conf ]]
}
# Require OpenVPN to be installed
requireOpenVPN() {
if ! isOpenVPNInstalled; then
log_fatal "OpenVPN is not installed. Run '$SCRIPT_NAME install' first."
fi
}
# Require OpenVPN to NOT be installed
requireNoOpenVPN() {
if isOpenVPNInstalled; then
log_fatal "OpenVPN is already installed. Use '$SCRIPT_NAME client' to manage clients or '$SCRIPT_NAME uninstall' to remove."
fi
}
# Parse DNS provider string to DNS number
parse_dns_provider() {
case "$1" in
system | unbound | cloudflare | quad9 | quad9-uncensored | fdn | dnswatch | opendns | google | yandex | adguard | nextdns | custom)
DNS="$1"
;;
*) log_fatal "Invalid DNS provider: $1. See '$SCRIPT_NAME install --help' for valid providers." ;;
esac
}
# Parse cipher string
parse_cipher() {
case "$1" in
AES-128-GCM | AES-192-GCM | AES-256-GCM | AES-128-CBC | AES-192-CBC | AES-256-CBC | CHACHA20-POLY1305)
CIPHER="$1"
;;
*) log_fatal "Invalid cipher: $1. See '$SCRIPT_NAME install --help' for valid ciphers." ;;
esac
}
# Parse curve string
parse_curve() {
case "$1" in
prime256v1 | secp384r1 | secp521r1) echo "$1" ;;
*) log_fatal "Invalid curve: $1. Valid curves: prime256v1, secp384r1, secp521r1" ;;
esac
}
# =============================================================================
# Configuration Constants
# =============================================================================
# Protocol options
readonly PROTOCOLS=("udp" "tcp")
# DNS providers (use string names)
readonly DNS_PROVIDERS=("system" "unbound" "cloudflare" "quad9" "quad9-uncensored" "fdn" "dnswatch" "opendns" "google" "yandex" "adguard" "nextdns" "custom")
# Cipher options
readonly CIPHERS=("AES-128-GCM" "AES-192-GCM" "AES-256-GCM" "AES-128-CBC" "AES-192-CBC" "AES-256-CBC" "CHACHA20-POLY1305")
# Certificate types (use strings)
readonly CERT_TYPES=("ecdsa" "rsa")
# ECDSA curves
readonly CERT_CURVES=("prime256v1" "secp384r1" "secp521r1")
# RSA key sizes
readonly RSA_KEY_SIZES=("2048" "3072" "4096")
# TLS versions
readonly TLS_VERSIONS=("1.2" "1.3")
# TLS signature modes (use strings)
readonly TLS_SIG_MODES=("crypt-v2" "crypt" "auth")
# Authentication modes: pki (CA-based) or fingerprint (peer-fingerprint, OpenVPN 2.6+)
readonly AUTH_MODES=("pki" "fingerprint")
# HMAC algorithms
readonly HMAC_ALGS=("SHA256" "SHA384" "SHA512")
# TLS 1.3 cipher suite options
readonly TLS13_OPTIONS=("all" "aes-256-only" "aes-128-only" "chacha20-only")
# TLS groups options
readonly TLS_GROUPS_OPTIONS=("all" "x25519-only" "nist-only")
# =============================================================================
# Set Installation Defaults
# =============================================================================
# Centralized function to set all defaults - called before configuration
set_installation_defaults() {
# Network
ENDPOINT_TYPE="${ENDPOINT_TYPE:-4}"
CLIENT_IPV4="${CLIENT_IPV4:-y}"
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::}"
PORT="${PORT:-1194}"
PROTOCOL="${PROTOCOL:-udp}"
# DNS (use string name)
DNS="${DNS:-cloudflare}"
# Multi-client
MULTI_CLIENT="${MULTI_CLIENT:-n}"
# Encryption
CIPHER="${CIPHER:-AES-128-GCM}"
CERT_TYPE="${CERT_TYPE:-ecdsa}"
CERT_CURVE="${CERT_CURVE:-prime256v1}"
RSA_KEY_SIZE="${RSA_KEY_SIZE:-2048}"
TLS_VERSION_MIN="${TLS_VERSION_MIN:-1.2}"
TLS13_CIPHERSUITES="${TLS13_CIPHERSUITES:-TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256}"
TLS_GROUPS="${TLS_GROUPS:-X25519:prime256v1:secp384r1:secp521r1}"
HMAC_ALG="${HMAC_ALG:-SHA256}"
TLS_SIG="${TLS_SIG:-crypt-v2}"
AUTH_MODE="${AUTH_MODE:-pki}"
# Derive CC_CIPHER from CERT_TYPE if not set
if [[ -z $CC_CIPHER ]]; then
if [[ $CERT_TYPE == "ecdsa" ]]; then
CC_CIPHER="TLS-ECDHE-ECDSA-WITH-AES-128-GCM-SHA256"
else
CC_CIPHER="TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256"
fi
fi
# Client
CLIENT="${CLIENT:-client}"
PASS="${PASS:-1}"
CLIENT_CERT_DURATION_DAYS="${CLIENT_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}"
SERVER_CERT_DURATION_DAYS="${SERVER_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}"
# Note: Gateway values (VPN_GATEWAY_IPV4, VPN_GATEWAY_IPV6) and IPV6_SUPPORT
# are computed in prepare_network_config() which is called after validation
}
# Version comparison: returns 0 if version1 >= version2
version_ge() {
local ver1="$1" ver2="$2"
# Use sort -V for version comparison
[[ "$(printf '%s\n%s' "$ver1" "$ver2" | sort -V | head -n1)" == "$ver2" ]]
}
# Get installed OpenVPN version (e.g., "2.6.12")
get_openvpn_version() {
openvpn --version 2>/dev/null | head -1 | awk '{print $2}'
}
# Validation functions
validate_port() {
local port="$1"
if ! [[ "$port" =~ ^[0-9]+$ ]] || [[ "$port" -lt 1 ]] || [[ "$port" -gt 65535 ]]; then
log_fatal "Invalid port: $port. Must be a number between 1 and 65535."
fi
}
validate_subnet_ipv4() {
local subnet="$1"
# Check format: x.x.x.0 where x is 0-255
if ! [[ "$subnet" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.0$ ]]; then
log_fatal "Invalid IPv4 subnet: $subnet. Must be in format x.x.x.0 (e.g., 10.8.0.0)"
fi
local octet1="${BASH_REMATCH[1]}"
local octet2="${BASH_REMATCH[2]}"
local octet3="${BASH_REMATCH[3]}"
# Validate each octet is 0-255
if [[ "$octet1" -gt 255 ]] || [[ "$octet2" -gt 255 ]] || [[ "$octet3" -gt 255 ]]; then
log_fatal "Invalid IPv4 subnet: $subnet. Octets must be 0-255."
fi
# Check for RFC1918 private address ranges
if ! { [[ "$octet1" -eq 10 ]] ||
[[ "$octet1" -eq 172 && "$octet2" -ge 16 && "$octet2" -le 31 ]] ||
[[ "$octet1" -eq 192 && "$octet2" -eq 168 ]]; }; then
log_fatal "Invalid IPv4 subnet: $subnet. Must be a private network (10.x.x.0, 172.16-31.x.0, or 192.168.x.0)."
fi
}
validate_subnet_ipv6() {
local subnet="$1"
# Accept format: IPv6 address ending with :: (prefix only, no CIDR notation here)
# We expect formats like: fd42:42:42:42:: or fdxx:xxxx:xxxx:xxxx::
# The script will append /112 for the server directive
# IPv6 ULA validation (fd00::/8 range with at least /48 prefix)
# ULA format: fdxx:xxxx:xxxx:: or fdxx:xxxx:xxxx:xxxx:: where x is hex
if ! [[ "$subnet" =~ ^fd[0-9a-fA-F]{2}(:[0-9a-fA-F]{1,4}){2,5}::$ ]]; then
log_fatal "Invalid IPv6 subnet: $subnet. Must be a ULA address with at least a /48 prefix, ending with :: (e.g., fd42:42:42::)"
fi
}
validate_positive_int() {
local value="$1"
local name="$2"
if ! [[ "$value" =~ ^[0-9]+$ ]] || [[ "$value" -lt 1 ]]; then
log_fatal "Invalid $name: $value. Must be a positive integer."
fi
}
validate_mtu() {
local mtu="$1"
if ! [[ "$mtu" =~ ^[0-9]+$ ]] || [[ "$mtu" -lt 576 ]] || [[ "$mtu" -gt 65535 ]]; then
log_fatal "Invalid MTU: $mtu. Must be between 576 and 65535."
fi
}
# Maximum length for client names (OpenSSL CN limit)
readonly MAX_CLIENT_NAME_LENGTH=64
# Check if client name is valid (non-fatal, returns true/false)
is_valid_client_name() {
local name="$1"
[[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]] && [[ ${#name} -le $MAX_CLIENT_NAME_LENGTH ]]
}
# Validate client name and exit with error if invalid
validate_client_name() {
local name="$1"
if [[ -z "$name" ]]; then
log_fatal "Client name cannot be empty."
fi
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
log_fatal "Invalid client name: $name. Only alphanumeric characters, underscores, and hyphens are allowed."
fi
if [[ ${#name} -gt $MAX_CLIENT_NAME_LENGTH ]]; then
log_fatal "Client name too long: ${#name} characters. Maximum is $MAX_CLIENT_NAME_LENGTH characters (OpenSSL CN limit)."
fi
}
# Validate all configuration values (catches invalid env vars in non-interactive mode)
validate_configuration() {
# Validate PROTOCOL
case "$PROTOCOL" in
udp | tcp) ;;
*) log_fatal "Invalid protocol: $PROTOCOL. Must be 'udp' or 'tcp'." ;;
esac
# Validate DNS
case "$DNS" in
system | unbound | cloudflare | quad9 | quad9-uncensored | fdn | dnswatch | opendns | google | yandex | adguard | nextdns | custom) ;;
*) log_fatal "Invalid DNS provider: $DNS. Valid providers: system, unbound, cloudflare, quad9, quad9-uncensored, fdn, dnswatch, opendns, google, yandex, adguard, nextdns, custom" ;;
esac
# Validate CERT_TYPE
case "$CERT_TYPE" in
ecdsa | rsa) ;;
*) log_fatal "Invalid cert type: $CERT_TYPE. Must be 'ecdsa' or 'rsa'." ;;
esac
# Validate TLS_SIG
case "$TLS_SIG" in
crypt-v2 | crypt | auth) ;;
*) log_fatal "Invalid TLS signature mode: $TLS_SIG. Must be 'crypt-v2', 'crypt', or 'auth'." ;;
esac
# Validate AUTH_MODE
case "$AUTH_MODE" in
pki | fingerprint) ;;
*) log_fatal "Invalid auth mode: $AUTH_MODE. Must be 'pki' or 'fingerprint'." ;;
esac
# Fingerprint mode requires OpenVPN 2.6+
if [[ $AUTH_MODE == "fingerprint" ]]; then
local openvpn_ver
openvpn_ver=$(get_openvpn_version)
if [[ -n "$openvpn_ver" ]] && ! version_ge "$openvpn_ver" "2.6.0"; then
log_fatal "Fingerprint mode requires OpenVPN 2.6.0 or later. Installed version: $openvpn_ver"
fi
fi
# Validate PORT
if ! [[ "$PORT" =~ ^[0-9]+$ ]] || [[ "$PORT" -lt 1 ]] || [[ "$PORT" -gt 65535 ]]; then
log_fatal "Invalid port: $PORT. Must be a number between 1 and 65535."
fi
# Validate CLIENT_IPV4/CLIENT_IPV6
if [[ $CLIENT_IPV4 != "y" ]] && [[ $CLIENT_IPV6 != "y" ]]; then
log_fatal "At least one of CLIENT_IPV4 or CLIENT_IPV6 must be 'y'"
fi
# Validate ENDPOINT_TYPE
case "$ENDPOINT_TYPE" in
4 | 6) ;;
*) log_fatal "Invalid endpoint type: $ENDPOINT_TYPE. Must be '4' or '6'." ;;
esac
# Validate CIPHER
case "$CIPHER" in
AES-128-GCM | AES-192-GCM | AES-256-GCM | AES-128-CBC | AES-192-CBC | AES-256-CBC | CHACHA20-POLY1305) ;;
*) log_fatal "Invalid cipher: $CIPHER. Valid ciphers: AES-128-GCM, AES-192-GCM, AES-256-GCM, AES-128-CBC, AES-192-CBC, AES-256-CBC, CHACHA20-POLY1305" ;;
esac
# Validate CERT_CURVE (only if ECDSA)
if [[ $CERT_TYPE == "ecdsa" ]]; then
case "$CERT_CURVE" in
prime256v1 | secp384r1 | secp521r1) ;;
*) log_fatal "Invalid cert curve: $CERT_CURVE. Must be 'prime256v1', 'secp384r1', or 'secp521r1'." ;;
esac
fi
# Validate RSA_KEY_SIZE (only if RSA)
if [[ $CERT_TYPE == "rsa" ]]; then
case "$RSA_KEY_SIZE" in
2048 | 3072 | 4096) ;;
*) log_fatal "Invalid RSA key size: $RSA_KEY_SIZE. Must be 2048, 3072, or 4096." ;;
esac
fi
# Validate TLS_VERSION_MIN
case "$TLS_VERSION_MIN" in
1.2 | 1.3) ;;
*) log_fatal "Invalid TLS version: $TLS_VERSION_MIN. Must be '1.2' or '1.3'." ;;
esac
# Validate HMAC_ALG
case "$HMAC_ALG" in
SHA256 | SHA384 | SHA512) ;;
*) log_fatal "Invalid HMAC algorithm: $HMAC_ALG. Must be SHA256, SHA384, or SHA512." ;;
esac
# Validate MTU if set
if [[ -n $MTU ]]; then
if ! [[ "$MTU" =~ ^[0-9]+$ ]] || [[ "$MTU" -lt 576 ]] || [[ "$MTU" -gt 65535 ]]; then
log_fatal "Invalid MTU: $MTU. Must be a number between 576 and 65535."
fi
fi
# Validate custom DNS if selected
if [[ $DNS == "custom" ]] && [[ -z $DNS1 ]]; then
log_fatal "Custom DNS selected but DNS1 (primary DNS) is not set. Use --dns-primary to specify."
fi
# Validate VPN subnets using the dedicated validation functions
# These check format, octet ranges, and RFC1918/ULA compliance
if [[ -n $VPN_SUBNET_IPV4 ]]; then
validate_subnet_ipv4 "$VPN_SUBNET_IPV4"
fi
if [[ $CLIENT_IPV6 == "y" ]] && [[ -n $VPN_SUBNET_IPV6 ]]; then
validate_subnet_ipv6 "$VPN_SUBNET_IPV6"
fi
}
# =============================================================================
# Interactive Helper Functions
# =============================================================================
# Generic select-from-menu function for arrays
# Usage: select_from_array "prompt" array_name "default_value" result_var
# Note: Uses namerefs (-n) for arrays
select_from_array() {
local prompt="$1"
local -n _options_ref="$2"
local default="$3"
local -n _result_ref="$4"
# If already set (non-interactive mode), just return
if [[ -n $_result_ref ]]; then
return
fi
# Find default index (1-based for display)
local default_idx=1
for i in "${!_options_ref[@]}"; do
if [[ "${_options_ref[$i]}" == "$default" ]]; then
default_idx=$((i + 1))
break
fi
done
# Display menu
local count=${#_options_ref[@]}
for i in "${!_options_ref[@]}"; do
log_menu " $((i + 1))) ${_options_ref[$i]}"
done
# Read selection
local choice
until [[ $choice =~ ^[0-9]+$ ]] && ((choice >= 1 && choice <= count)); do
read -rp "$prompt [1-$count]: " -e -i "$default_idx" choice
done
_result_ref="${_options_ref[$((choice - 1))]}"
}
# Select with custom labels (for menu items that need different display text)
# Usage: select_with_labels "prompt" labels_array values_array "default_value" result_var
select_with_labels() {
local prompt="$1"
local -n _labels_ref="$2"
local -n _values_ref="$3"
local default="$4"
local -n _result_ref="$5"
# If already set (non-interactive mode), just return
if [[ -n $_result_ref ]]; then
return
fi
# Find default index
local default_idx=1
for i in "${!_values_ref[@]}"; do
if [[ "${_values_ref[$i]}" == "$default" ]]; then
default_idx=$((i + 1))
break
fi
done
# Display menu
local count=${#_labels_ref[@]}
for i in "${!_labels_ref[@]}"; do
log_menu " $((i + 1))) ${_labels_ref[$i]}"
done
# Read selection
local choice
until [[ $choice =~ ^[0-9]+$ ]] && ((choice >= 1 && choice <= count)); do
read -rp "$prompt [1-$count]: " -e -i "$default_idx" choice
done
_result_ref="${_values_ref[$((choice - 1))]}"
}
# Prompt for yes/no with default
# Usage: prompt_yes_no "prompt" "default" result_var
prompt_yes_no() {
local prompt="$1"
local default="$2"
local -n _result_ref="$3"
# If already set, just return
if [[ $_result_ref =~ ^[yn]$ ]]; then
return
fi
until [[ $_result_ref =~ ^[yn]$ ]]; do
read -rp "$prompt [y/n]: " -e -i "$default" _result_ref
done
}
# Prompt for a value with validation function
# Usage: prompt_validated "prompt" "validator_func" "default" result_var
# The validator should return 0 for valid, non-0 for invalid
prompt_validated() {
local prompt="$1"
local validator="$2"
local default="$3"
local -n _result_ref="$4"
# If already set and valid, return
if [[ -n $_result_ref ]] && $validator "$_result_ref" 2>/dev/null; then
return
fi
_result_ref=""
until [[ -n $_result_ref ]] && $validator "$_result_ref" 2>/dev/null; do
read -rp "$prompt: " -e -i "$default" _result_ref
done
}
# Non-fatal port validator (returns 0/1)
is_valid_port() {
local port="$1"
[[ "$port" =~ ^[0-9]+$ ]] && ((port >= 1 && port <= 65535))
}
# Non-fatal MTU validator (returns 0/1)
is_valid_mtu() {
local mtu="$1"
[[ "$mtu" =~ ^[0-9]+$ ]] && ((mtu >= 576 && mtu <= 65535))
}
# Handle install command
cmd_install() {
local interactive=false
local no_client=false
local client_password_flag=false
local client_password_value=""
while [[ $# -gt 0 ]]; do
case "$1" in
-i | --interactive)
interactive=true
shift
;;
--endpoint)
[[ -z "${2:-}" ]] && log_fatal "--endpoint requires an argument"
ENDPOINT="$2"
shift 2
;;
--ip)
[[ -z "${2:-}" ]] && log_fatal "--ip requires an argument"
IP="$2"
APPROVE_IP=y
shift 2
;;
--endpoint-type)
[[ -z "${2:-}" ]] && log_fatal "--endpoint-type requires an argument"
case "$2" in
4) ENDPOINT_TYPE="4" ;;
6) ENDPOINT_TYPE="6" ;;
*) log_fatal "Invalid endpoint type: $2. Use '4' or '6'." ;;
esac
shift 2
;;
--client-ipv4)
CLIENT_IPV4=y
shift
;;
--no-client-ipv4)
CLIENT_IPV4=n
shift
;;
--client-ipv6)
CLIENT_IPV6=y
shift
;;
--no-client-ipv6)
CLIENT_IPV6=n
shift
;;
--ipv6)
# Legacy flag: enable IPv6 for clients (backward compatibility)
CLIENT_IPV6=y
shift
;;
--subnet-ipv4)
[[ -z "${2:-}" ]] && log_fatal "--subnet-ipv4 requires an argument"
validate_subnet_ipv4 "$2"
VPN_SUBNET_IPV4="$2"
shift 2
;;
--subnet-ipv6)
[[ -z "${2:-}" ]] && log_fatal "--subnet-ipv6 requires an argument"
validate_subnet_ipv6 "$2"
VPN_SUBNET_IPV6="$2"
shift 2
;;
--subnet)
# Legacy flag: --subnet now maps to --subnet-ipv4
[[ -z "${2:-}" ]] && log_fatal "--subnet requires an argument"
validate_subnet_ipv4 "$2"
VPN_SUBNET_IPV4="$2"
shift 2
;;
--port)
[[ -z "${2:-}" ]] && log_fatal "--port requires an argument"
validate_port "$2"
PORT="$2"
shift 2
;;
--port-random)
PORT="random"
shift
;;
--protocol)
[[ -z "${2:-}" ]] && log_fatal "--protocol requires an argument"
case "$2" in
udp | tcp)
PROTOCOL="$2"
;;
*) log_fatal "Invalid protocol: $2. Use 'udp' or 'tcp'." ;;
esac
shift 2
;;
--mtu)
[[ -z "${2:-}" ]] && log_fatal "--mtu requires an argument"
validate_mtu "$2"
MTU="$2"
shift 2
;;
--dns)
[[ -z "${2:-}" ]] && log_fatal "--dns requires an argument"
parse_dns_provider "$2"
shift 2
;;
--dns-primary)
[[ -z "${2:-}" ]] && log_fatal "--dns-primary requires an argument"
DNS1="$2"
shift 2
;;
--dns-secondary)
[[ -z "${2:-}" ]] && log_fatal "--dns-secondary requires an argument"
DNS2="$2"
shift 2
;;
--multi-client)
MULTI_CLIENT=y
shift
;;
--cipher)
[[ -z "${2:-}" ]] && log_fatal "--cipher requires an argument"
parse_cipher "$2"
CUSTOMIZE_ENC=y
shift 2
;;
--cert-type)
[[ -z "${2:-}" ]] && log_fatal "--cert-type requires an argument"
case "$2" in
ecdsa | rsa) CERT_TYPE="$2" ;;
*) log_fatal "Invalid cert-type: $2. Use 'ecdsa' or 'rsa'." ;;
esac
shift 2
;;
--cert-curve)
[[ -z "${2:-}" ]] && log_fatal "--cert-curve requires an argument"
CERT_CURVE=$(parse_curve "$2")
CUSTOMIZE_ENC=y
shift 2
;;
--rsa-bits)
[[ -z "${2:-}" ]] && log_fatal "--rsa-bits requires an argument"
case "$2" in
2048 | 3072 | 4096) RSA_KEY_SIZE="$2" ;;
*) log_fatal "Invalid RSA key size: $2. Use 2048, 3072, or 4096." ;;
esac
CUSTOMIZE_ENC=y
shift 2
;;
--cc-cipher)
[[ -z "${2:-}" ]] && log_fatal "--cc-cipher requires an argument"
CC_CIPHER="$2"
CUSTOMIZE_ENC=y
shift 2
;;
--tls-ciphersuites)
[[ -z "${2:-}" ]] && log_fatal "--tls-ciphersuites requires an argument"
TLS13_CIPHERSUITES="$2"
CUSTOMIZE_ENC=y
shift 2
;;
--tls-version-min)
[[ -z "${2:-}" ]] && log_fatal "--tls-version-min requires an argument"
case "$2" in
1.2 | 1.3) TLS_VERSION_MIN="$2" ;;
*) log_fatal "Invalid TLS version: $2. Use '1.2' or '1.3'." ;;
esac
CUSTOMIZE_ENC=y
shift 2
;;
--tls-groups)
[[ -z "${2:-}" ]] && log_fatal "--tls-groups requires an argument"
TLS_GROUPS="$2"
CUSTOMIZE_ENC=y
shift 2
;;
--hmac)
[[ -z "${2:-}" ]] && log_fatal "--hmac requires an argument"
case "$2" in
SHA256 | SHA384 | SHA512) HMAC_ALG="$2" ;;
*) log_fatal "Invalid HMAC algorithm: $2. Use SHA256, SHA384, or SHA512." ;;
esac
CUSTOMIZE_ENC=y
shift 2
;;
--tls-sig)
[[ -z "${2:-}" ]] && log_fatal "--tls-sig requires an argument"
case "$2" in
crypt-v2 | crypt | auth) TLS_SIG="$2" ;;
*) log_fatal "Invalid TLS mode: $2. Use 'crypt-v2', 'crypt', or 'auth'." ;;
esac
shift 2
;;
--auth-mode)
[[ -z "${2:-}" ]] && log_fatal "--auth-mode requires an argument"
case "$2" in
pki | fingerprint) AUTH_MODE="$2" ;;
*) log_fatal "Invalid auth mode: $2. Use 'pki' or 'fingerprint'." ;;
esac
shift 2
;;
--server-cert-days)
[[ -z "${2:-}" ]] && log_fatal "--server-cert-days requires an argument"
validate_positive_int "$2" "server-cert-days"
SERVER_CERT_DURATION_DAYS="$2"
shift 2
;;
--client)
[[ -z "${2:-}" ]] && log_fatal "--client requires an argument"
validate_client_name "$2"
CLIENT="$2"
shift 2
;;
--client-password)
client_password_flag=true
# Check if next arg is a value or another flag
if [[ -n "${2:-}" ]] && [[ ! "$2" =~ ^- ]]; then
client_password_value="$2"
shift
fi
shift
;;
--client-cert-days)
[[ -z "${2:-}" ]] && log_fatal "--client-cert-days requires an argument"
validate_positive_int "$2" "client-cert-days"
CLIENT_CERT_DURATION_DAYS="$2"
shift 2
;;
--no-client)
no_client=true
shift
;;
-h | --help)
show_install_help
exit 0
;;
*)
log_fatal "Unknown option: $1. See '$SCRIPT_NAME install --help' for usage."
;;
esac
done
# Validate custom DNS settings
if [[ -n "${DNS1:-}" || -n "${DNS2:-}" ]] && [[ "${DNS:-}" != "custom" ]]; then
log_fatal "--dns-primary and --dns-secondary require --dns custom"
fi
# Check if already installed
requireNoOpenVPN
if [[ $interactive == true ]]; then
# Run interactive installer
installQuestions
else
# Non-interactive mode - set flags and defaults
NON_INTERACTIVE_INSTALL=y
APPROVE_INSTALL=y
APPROVE_IP=${APPROVE_IP:-y}
CONTINUE=y
# Handle random port
if [[ $PORT == "random" ]]; then
PORT=$(shuf -i 49152-65535 -n1)
log_info "Random Port: $PORT"
fi
# Client setup
if [[ $no_client == true ]]; then
NEW_CLIENT=n
else
NEW_CLIENT=y
if [[ $client_password_flag == true ]]; then
PASS=2
if [[ -n "$client_password_value" ]]; then
PASSPHRASE="$client_password_value"
fi
fi
fi
# 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
# Prepare derived network configuration (gateways, etc.)
prepare_network_config
installOpenVPN
}
# Handle uninstall command
cmd_uninstall() {
local force=false
while [[ $# -gt 0 ]]; do
case "$1" in
-f | --force)
force=true
shift
;;
-h | --help)
show_uninstall_help
exit 0
;;
*)
log_fatal "Unknown option: $1. See '$SCRIPT_NAME uninstall --help' for usage."
;;
esac
done
requireOpenVPN
if [[ $force == true ]]; then
REMOVE=y
fi
removeOpenVPN
}
# Handle client command
cmd_client() {
local subcmd="${1:-}"
shift || true
case "$subcmd" in
"" | "-h" | "--help")
show_client_help
exit 0
;;
add)
cmd_client_add "$@"
;;
list)
cmd_client_list "$@"
;;
revoke)
cmd_client_revoke "$@"
;;
renew)
cmd_client_renew "$@"
;;
*)
log_fatal "Unknown client subcommand: $subcmd. See '$SCRIPT_NAME client --help' for usage."
;;
esac
}
# Handle client add command
cmd_client_add() {
local client_name=""
local password_flag=false
local password_value=""
# First non-flag argument is the client name
while [[ $# -gt 0 ]]; do
case "$1" in
--password)
password_flag=true
# Check if next arg is a value or another flag
if [[ -n "${2:-}" ]] && [[ ! "$2" =~ ^- ]]; then
password_value="$2"
shift
fi
shift
;;
--cert-days)
[[ -z "${2:-}" ]] && log_fatal "--cert-days requires an argument"
validate_positive_int "$2" "cert-days"
CLIENT_CERT_DURATION_DAYS="$2"
shift 2
;;
--output)
[[ -z "${2:-}" ]] && log_fatal "--output requires an argument"
CLIENT_FILEPATH="$2"
shift 2
;;
-h | --help)
show_client_add_help
exit 0
;;
-*)
log_fatal "Unknown option: $1. See '$SCRIPT_NAME client add --help' for usage."
;;
*)
if [[ -z "$client_name" ]]; then
client_name="$1"
else
log_fatal "Unexpected argument: $1"
fi
shift
;;
esac
done
[[ -z "$client_name" ]] && log_fatal "Client name is required. See '$SCRIPT_NAME client add --help' for usage."
validate_client_name "$client_name"
requireOpenVPN
# Set up variables for newClient function
CLIENT="$client_name"
CLIENT_CERT_DURATION_DAYS=${CLIENT_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}
if [[ $password_flag == true ]]; then
PASS=2
if [[ -n "$password_value" ]]; then
PASSPHRASE="$password_value"
fi
else
PASS=1
fi
newClient
exit 0
}
# Handle client list command
cmd_client_list() {
local format="table"
while [[ $# -gt 0 ]]; do
case "$1" in
--format)
[[ -z "${2:-}" ]] && log_fatal "--format requires an argument"
case "$2" in
table | json) format="$2" ;;
*) log_fatal "Invalid format: $2. Use 'table' or 'json'." ;;
esac
shift 2
;;
-h | --help)
show_client_list_help
exit 0
;;
*)
log_fatal "Unknown option: $1. See '$SCRIPT_NAME client list --help' for usage."
;;
esac
done
requireOpenVPN
OUTPUT_FORMAT="$format" listClients
}
# Handle client revoke command
cmd_client_revoke() {
local client_name=""
local force=false
while [[ $# -gt 0 ]]; do
case "$1" in
-f | --force)
force=true
shift
;;
-h | --help)
show_client_revoke_help
exit 0
;;
-*)
log_fatal "Unknown option: $1. See '$SCRIPT_NAME client revoke --help' for usage."
;;
*)
if [[ -z "$client_name" ]]; then
client_name="$1"
else
log_fatal "Unexpected argument: $1"
fi
shift
;;
esac
done
[[ -z "$client_name" ]] && log_fatal "Client name is required. See '$SCRIPT_NAME client revoke --help' for usage."
requireOpenVPN
CLIENT="$client_name"
if [[ $force == true ]]; then
REVOKE_CONFIRM=y
fi
revokeClient
}
# Handle client renew command
cmd_client_renew() {
local client_name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--cert-days)
[[ -z "${2:-}" ]] && log_fatal "--cert-days requires an argument"
validate_positive_int "$2" "cert-days"
CLIENT_CERT_DURATION_DAYS="$2"
shift 2
;;
-h | --help)
show_client_renew_help
exit 0
;;
-*)
log_fatal "Unknown option: $1. See '$SCRIPT_NAME client renew --help' for usage."
;;
*)
if [[ -z "$client_name" ]]; then
client_name="$1"
else
log_fatal "Unexpected argument: $1"
fi
shift
;;
esac
done
[[ -z "$client_name" ]] && log_fatal "Client name is required. See '$SCRIPT_NAME client renew --help' for usage."
requireOpenVPN
CLIENT="$client_name"
CLIENT_CERT_DURATION_DAYS=${CLIENT_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}
renewClient
}
# Handle server command
cmd_server() {
local subcmd="${1:-}"
shift || true
case "$subcmd" in
"" | "-h" | "--help")
show_server_help
exit 0
;;
status)
cmd_server_status "$@"
;;
renew)
cmd_server_renew "$@"
;;
*)
log_fatal "Unknown server subcommand: $subcmd. See '$SCRIPT_NAME server --help' for usage."
;;
esac
}
# Handle server status command
cmd_server_status() {
local format="table"
while [[ $# -gt 0 ]]; do
case "$1" in
--format)
[[ -z "${2:-}" ]] && log_fatal "--format requires an argument"
case "$2" in
table | json) format="$2" ;;
*) log_fatal "Invalid format: $2. Use 'table' or 'json'." ;;
esac
shift 2
;;
-h | --help)
show_server_status_help
exit 0
;;
*)
log_fatal "Unknown option: $1. See '$SCRIPT_NAME server status --help' for usage."
;;
esac
done
requireOpenVPN
OUTPUT_FORMAT="$format" listConnectedClients
}
# Handle server renew command
cmd_server_renew() {
local force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--cert-days)
[[ -z "${2:-}" ]] && log_fatal "--cert-days requires an argument"
validate_positive_int "$2" "cert-days"
SERVER_CERT_DURATION_DAYS="$2"
shift 2
;;
-f | --force)
force=true
shift
;;
-h | --help)
show_server_renew_help
exit 0
;;
*)
log_fatal "Unknown option: $1. See '$SCRIPT_NAME server renew --help' for usage."
;;
esac
done
requireOpenVPN
SERVER_CERT_DURATION_DAYS=${SERVER_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}
if [[ $force == true ]]; then
CONTINUE=y
fi
renewServer
}
# Handle interactive command (legacy menu)
cmd_interactive() {
while [[ $# -gt 0 ]]; do
case "$1" in
-h | --help)
echo "Launch interactive menu for OpenVPN management"
echo ""
echo "Usage: $SCRIPT_NAME interactive"
exit 0
;;
*)
log_fatal "Unknown option: $1"
;;
esac
done
if isOpenVPNInstalled; then
manageMenu
else
installQuestions
installOpenVPN
fi
}
# Main argument parser
parse_args() {
# Parse global options first
while [[ $# -gt 0 ]]; do
case "$1" in
--verbose)
VERBOSE=1
shift
;;
--log)
[[ -z "${2:-}" ]] && log_fatal "--log requires an argument"
LOG_FILE="$2"
shift 2
;;
--no-log)
LOG_FILE=""
shift
;;
--no-color)
# Colors already set at script start, but we can unset them
COLOR_RESET=''
COLOR_RED=''
COLOR_GREEN=''
COLOR_YELLOW=''
COLOR_BLUE=''
COLOR_CYAN=''
COLOR_DIM=''
COLOR_BOLD=''
shift
;;
-h | --help)
show_help
exit 0
;;
-*)
# Could be a command-specific option, let command handle it
break
;;
*)
# First non-option is the command
break
;;
esac
done
# Get the command
local cmd="${1:-}"
shift || true
# Check if user just wants help (don't require root for help)
# Also detect --format json early to suppress log output before initialCheck
local wants_help=false
local prev_arg=""
for arg in "$@"; do
if [[ "$arg" == "-h" || "$arg" == "--help" ]]; then
wants_help=true
fi
if [[ "$prev_arg" == "--format" && "$arg" == "json" ]]; then
OUTPUT_FORMAT="json"
fi
prev_arg="$arg"
done
# Dispatch to command handler
case "$cmd" in
"")
show_help
exit 0
;;
install)
[[ $wants_help == false ]] && initialCheck
cmd_install "$@"
;;
uninstall)
[[ $wants_help == false ]] && initialCheck
cmd_uninstall "$@"
;;
client)
[[ $wants_help == false ]] && initialCheck
cmd_client "$@"
;;
server)
[[ $wants_help == false ]] && initialCheck
cmd_server "$@"
;;
interactive)
[[ $wants_help == false ]] && initialCheck
cmd_interactive "$@"
;;
*)
log_fatal "Unknown command: $cmd. See '$SCRIPT_NAME --help' for usage."
;;
esac
}
# =============================================================================
# System Check Functions
# =============================================================================
function isRoot() {
if [ "$EUID" -ne 0 ]; then
return 1
fi
}
function tunAvailable() {
if [ ! -e /dev/net/tun ]; then
return 1
fi
}
function checkOS() {
if [[ -e /etc/debian_version ]]; then
OS="debian"
source /etc/os-release
if [[ $ID == "debian" || $ID == "raspbian" ]]; then
if [[ $VERSION_ID -lt 11 ]]; then
log_warn "Your version of Debian is not supported."
log_info "However, if you're using Debian >= 11 or unstable/testing, you can continue at your own risk."
until [[ $CONTINUE =~ (y|n) ]]; do
read -rp "Continue? [y/n]: " -e CONTINUE
done
if [[ $CONTINUE == "n" ]]; then
exit 1
fi
fi
elif [[ $ID == "ubuntu" ]]; then
OS="ubuntu"
MAJOR_UBUNTU_VERSION=$(echo "$VERSION_ID" | cut -d '.' -f1)
if [[ $MAJOR_UBUNTU_VERSION -lt 18 ]]; then
log_warn "Your version of Ubuntu is not supported."
log_info "However, if you're using Ubuntu >= 18.04 or beta, you can continue at your own risk."
until [[ $CONTINUE =~ (y|n) ]]; do
read -rp "Continue? [y/n]: " -e CONTINUE
done
if [[ $CONTINUE == "n" ]]; then
exit 1
fi
fi
fi
elif [[ -e /etc/os-release ]]; then
source /etc/os-release
if [[ $ID == "fedora" || $ID_LIKE == "fedora" ]]; then
OS="fedora"
fi
if [[ $ID == "opensuse-tumbleweed" ]]; then
OS="opensuse"
fi
if [[ $ID == "opensuse-leap" ]]; then
OS="opensuse"
if [[ ${VERSION_ID%.*} -lt 16 ]]; then
log_info "The script only supports openSUSE Leap 16+."
log_fatal "Your version of openSUSE Leap is not supported."
fi
fi
if [[ $ID == "centos" || $ID == "rocky" || $ID == "almalinux" ]]; then
OS="centos"
fi
if [[ $ID == "ol" ]]; then
OS="oracle"
fi
if [[ $OS =~ (centos|oracle) ]] && [[ ${VERSION_ID%.*} -lt 8 ]]; then
log_info "The script only supports CentOS Stream / Rocky Linux / AlmaLinux / Oracle Linux version 8+."
log_fatal "Your version is not supported."
fi
if [[ $ID == "amzn" ]]; then
if [[ "$PRETTY_NAME" =~ ^Amazon\ Linux\ 2023\.([0-9]+) ]] && [[ "${BASH_REMATCH[1]}" -ge 6 ]]; then
OS="amzn2023"
else
log_info "The script only supports Amazon Linux 2023.6+"
log_info "Amazon Linux 2 is EOL and no longer supported."
log_fatal "Your version of Amazon Linux is not supported."
fi
fi
if [[ $ID == "arch" ]]; then
OS="arch"
fi
elif [[ -e /etc/arch-release ]]; then
OS=arch
else
log_fatal "It looks like you aren't running this installer on a Debian, Ubuntu, Fedora, openSUSE, CentOS, Amazon Linux 2023, Oracle Linux, Arch Linux, Rocky Linux or AlmaLinux system."
fi
}
function checkArchPendingKernelUpgrade() {
if [[ $OS != "arch" ]]; then
return 0
fi
# Check if running kernel's modules are available
# (detects if kernel was upgraded but system not rebooted)
# Skip this check in containers - they share host kernel but have their own /lib/modules
if [[ -f /.dockerenv ]] || grep -qE '(docker|lxc|containerd)' /proc/1/cgroup 2>/dev/null; then
log_info "Running in container, skipping kernel modules check"
else
local running_kernel
running_kernel=$(uname -r)
if [[ ! -d "/lib/modules/${running_kernel}" ]]; then
log_error "Kernel modules for running kernel ($running_kernel) not found!"
log_info "This usually means the kernel was upgraded but the system wasn't rebooted."
log_fatal "Please reboot your system and run this script again."
fi
fi
log_info "Checking for pending kernel upgrades on Arch Linux..."
# Sync package database to check for updates
if ! pacman -Sy &>/dev/null; then
log_warn "Failed to sync package database, skipping kernel upgrade check"
return 0
fi
# Check for pending linux kernel upgrades
local pending_kernels
pending_kernels=$(pacman -Qu 2>/dev/null | grep -E '^linux' || true)
if [[ -n "$pending_kernels" ]]; then
log_warn "Linux kernel upgrade(s) pending:"
echo "$pending_kernels" | while read -r line; do
log_info " $line"
done
echo ""
log_info "This script uses 'pacman -Syu' which will upgrade your kernel."
log_info "After a kernel upgrade, the TUN module won't be available until you reboot."
echo ""
log_info "Please upgrade your system and reboot first:"
log_info " sudo pacman -Syu"
log_info " sudo reboot"
echo ""
log_fatal "Aborting. Run this script again after upgrading and rebooting."
fi
log_success "No pending kernel upgrades"
}
function initialCheck() {
log_debug "Checking root privileges..."
if ! isRoot; then
log_fatal "Sorry, you need to run this script as root."
fi
log_debug "Root check passed"
log_debug "Checking TUN device availability..."
if ! tunAvailable; then
log_fatal "TUN is not available."
fi
log_debug "TUN device available at /dev/net/tun"
log_debug "Detecting operating system..."
checkOS
log_debug "Detected OS: $OS (${PRETTY_NAME:-unknown})"
checkArchPendingKernelUpgrade
}
# Check if OpenVPN version is at least the specified version
# Usage: openvpnVersionAtLeast "2.5"
# Returns 0 if version is >= specified, 1 otherwise
function openvpnVersionAtLeast() {
local required_version="$1"
local installed_version
if ! command -v openvpn &>/dev/null; then
return 1
fi
installed_version=$(openvpn --version 2>/dev/null | head -1 | awk '{print $2}')
if [[ -z "$installed_version" ]]; then
return 1
fi
# Compare versions using sort -V
if [[ "$(printf '%s\n' "$required_version" "$installed_version" | sort -V | head -n1)" == "$required_version" ]]; then
return 0
fi
return 1
}
# Check if kernel version is at least the specified version
# Usage: kernelVersionAtLeast "6.16"
# Returns 0 if version is >= specified, 1 otherwise
function kernelVersionAtLeast() {
local required_version="$1"
local kernel_version
kernel_version=$(uname -r | cut -d'-' -f1)
if [[ -z "$kernel_version" ]]; then
return 1
fi
if [[ "$(printf '%s\n' "$required_version" "$kernel_version" | sort -V | head -n1)" == "$required_version" ]]; then
return 0
fi
return 1
}
# Check if Data Channel Offload (DCO) is available
# DCO requires: OpenVPN 2.6+, kernel support (Linux 6.16+ or ovpn-dco module)
# Returns 0 if DCO is available, 1 otherwise
function isDCOAvailable() {
# DCO requires OpenVPN 2.6+
if ! openvpnVersionAtLeast "2.6"; then
return 1
fi
# DCO is built into Linux 6.16+, or available via ovpn-dco module
if kernelVersionAtLeast "6.16"; then
return 0
elif lsmod 2>/dev/null | grep -q "^ovpn_dco" || modinfo ovpn-dco &>/dev/null; then
return 0
fi
return 1
}
function installOpenVPNRepo() {
log_info "Setting up official OpenVPN repository..."
if [[ $OS =~ (debian|ubuntu) ]]; then
run_cmd_fatal "Update package lists" apt-get update
run_cmd_fatal "Installing prerequisites" apt-get install -y ca-certificates curl
# Create keyrings directory
run_cmd "Creating keyrings directory" mkdir -p /etc/apt/keyrings
# Download and install GPG key
if ! run_cmd "Downloading OpenVPN GPG key" curl -fsSL https://swupdate.openvpn.net/repos/repo-public.gpg -o /etc/apt/keyrings/openvpn-repo-public.asc; then
log_fatal "Failed to download OpenVPN repository GPG key"
fi
# Add repository - using stable release
if [[ -z "${VERSION_CODENAME}" ]]; then
log_fatal "VERSION_CODENAME is not set. Unable to configure OpenVPN repository."
fi
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/openvpn-repo-public.asc] https://build.openvpn.net/debian/openvpn/stable ${VERSION_CODENAME} main" >/etc/apt/sources.list.d/openvpn-aptrepo.list
log_info "Updating package lists with new repository..."
run_cmd_fatal "Update package lists" apt-get update
log_info "OpenVPN official repository configured"
elif [[ $OS =~ (centos|oracle) ]]; then
# For RHEL-based systems, use Fedora Copr (OpenVPN 2.6 stable)
# EPEL is required for pkcs11-helper dependency
log_info "Configuring OpenVPN Copr repository for RHEL-based system..."
# Oracle Linux uses oracle-epel-release-el* instead of epel-release
if [[ $OS == "oracle" ]]; then
EPEL_PACKAGE="oracle-epel-release-el${VERSION_ID%.*}"
else
EPEL_PACKAGE="epel-release"
fi
if ! command -v dnf &>/dev/null; then
run_cmd_fatal "Installing EPEL repository" yum install -y "$EPEL_PACKAGE"
run_cmd_fatal "Installing yum-plugin-copr" yum install -y yum-plugin-copr
run_cmd_fatal "Enabling OpenVPN Copr repo" yum copr enable -y @OpenVPN/openvpn-release-2.6
else
run_cmd_fatal "Installing EPEL repository" dnf install -y "$EPEL_PACKAGE"
run_cmd_fatal "Installing dnf-plugins-core" dnf install -y dnf-plugins-core
run_cmd_fatal "Enabling OpenVPN Copr repo" dnf copr enable -y @OpenVPN/openvpn-release-2.6
fi
log_info "OpenVPN Copr repository configured"
elif [[ $OS == "fedora" ]]; then
# Fedora already ships with recent OpenVPN 2.6.x, no Copr needed
log_info "Fedora already has recent OpenVPN packages, using distribution version"
else
log_info "No official OpenVPN repository available for this OS, using distribution packages"
fi
}
function installUnbound() {
log_info "Installing Unbound DNS resolver..."
# Install Unbound if not present
if [[ ! -e /etc/unbound/unbound.conf ]]; then
if [[ $OS =~ (debian|ubuntu) ]]; then
run_cmd_fatal "Installing Unbound" apt-get install -y unbound
elif [[ $OS =~ (centos|oracle) ]]; then
run_cmd_fatal "Installing Unbound" yum install -y unbound
elif [[ $OS =~ (fedora|amzn2023) ]]; then
run_cmd_fatal "Installing Unbound" dnf install -y unbound
elif [[ $OS == "opensuse" ]]; then
run_cmd_fatal "Installing Unbound" zypper install -y unbound
elif [[ $OS == "arch" ]]; then
run_cmd_fatal "Installing Unbound" pacman -Syu --noconfirm unbound
fi
fi
# Configure Unbound for OpenVPN (runs whether freshly installed or pre-existing)
# Create conf.d directory (works on all distros)
run_cmd "Creating Unbound config directory" mkdir -p /etc/unbound/unbound.conf.d
# Ensure main config includes conf.d directory
# Modern Debian/Ubuntu use include-toplevel, others need include directive
if ! grep -qE "include(-toplevel)?:\s*.*/etc/unbound/unbound.conf.d" /etc/unbound/unbound.conf 2>/dev/null; then
# Add include directive for conf.d if not present
echo 'include: "/etc/unbound/unbound.conf.d/*.conf"' >>/etc/unbound/unbound.conf
fi
# Generate OpenVPN-specific Unbound configuration
# Using consistent best-practice settings across all distros
{
echo 'server:'
echo ' # OpenVPN DNS resolver configuration'
# IPv4 VPN interface (only if clients get IPv4)
if [[ $CLIENT_IPV4 == 'y' ]]; then
echo " interface: $VPN_GATEWAY_IPV4"
echo " access-control: $VPN_SUBNET_IPV4/24 allow"
fi
# IPv6 VPN interface (only if clients get IPv6)
if [[ $CLIENT_IPV6 == 'y' ]]; then
echo " interface: $VPN_GATEWAY_IPV6"
echo " access-control: ${VPN_SUBNET_IPV6}/112 allow"
fi
echo ''
echo ' # Security hardening'
echo ' hide-identity: yes'
echo ' hide-version: yes'
echo ' harden-glue: yes'
echo ' harden-dnssec-stripped: yes'
echo ''
echo ' # Performance optimizations'
echo ' prefetch: yes'
echo ' use-caps-for-id: yes'
echo ' qname-minimisation: yes'
echo ''
echo ' # Allow binding before tun interface exists'
echo ' ip-freebind: yes'
echo ''
echo ' # DNS rebinding protection'
echo ' private-address: 10.0.0.0/8'
echo ' private-address: 172.16.0.0/12'
echo ' private-address: 192.168.0.0/16'
echo ' private-address: 169.254.0.0/16'
echo ' private-address: 127.0.0.0/8'
echo ' private-address: fd00::/8'
echo ' private-address: fe80::/10'
echo ' private-address: ::ffff:0:0/96'
# Add VPN subnet to private addresses if IPv6 enabled
if [[ $CLIENT_IPV6 == 'y' ]]; then
echo " private-address: ${VPN_SUBNET_IPV6}/112"
fi
# Disable remote-control (requires SSL certs on openSUSE)
if [[ $OS == "opensuse" ]]; then
echo ''
echo 'remote-control:'
echo ' control-enable: no'
fi
} >/etc/unbound/unbound.conf.d/openvpn.conf
run_cmd "Enabling Unbound service" systemctl enable unbound
run_cmd "Starting Unbound service" systemctl restart unbound
# Validate Unbound is running
for i in {1..10}; do
if pgrep -x unbound >/dev/null; then
return 0
fi
sleep 1
done
log_fatal "Unbound failed to start. Check 'journalctl -u unbound' for details."
}
function resolvePublicIPv4() {
local public_ip=""
# Try to resolve public IPv4 using: https://api.seeip.org
if [[ -z $public_ip ]]; then
public_ip=$(curl -f -m 5 -sS --retry 2 --retry-connrefused -4 https://api.seeip.org 2>/dev/null)
fi
# Try to resolve using: https://ifconfig.me
if [[ -z $public_ip ]]; then
public_ip=$(curl -f -m 5 -sS --retry 2 --retry-connrefused -4 https://ifconfig.me 2>/dev/null)
fi
# Try to resolve using: https://api.ipify.org
if [[ -z $public_ip ]]; then
public_ip=$(curl -f -m 5 -sS --retry 2 --retry-connrefused -4 https://api.ipify.org 2>/dev/null)
fi
# Try to resolve using: ns1.google.com
if [[ -z $public_ip ]]; then
public_ip=$(dig -4 TXT +short o-o.myaddr.l.google.com @ns1.google.com | tr -d '"')
fi
echo "$public_ip"
}
function resolvePublicIPv6() {
local public_ip=""
# Try to resolve public IPv6 using: https://api6.seeip.org
if [[ -z $public_ip ]]; then
public_ip=$(curl -f -m 5 -sS --retry 2 --retry-connrefused -6 https://api6.seeip.org 2>/dev/null)
fi
# Try to resolve using: https://ifconfig.me (IPv6)
if [[ -z $public_ip ]]; then
public_ip=$(curl -f -m 5 -sS --retry 2 --retry-connrefused -6 https://ifconfig.me 2>/dev/null)
fi
# Try to resolve using: https://api64.ipify.org (dual-stack, prefer IPv6)
if [[ -z $public_ip ]]; then
public_ip=$(curl -f -m 5 -sS --retry 2 --retry-connrefused -6 https://api64.ipify.org 2>/dev/null)
fi
# Try to resolve using: ns1.google.com
if [[ -z $public_ip ]]; then
public_ip=$(dig -6 TXT +short o-o.myaddr.l.google.com @ns1.google.com | tr -d '"')
fi
echo "$public_ip"
}
# Legacy wrapper for backward compatibility
function resolvePublicIP() {
if [[ $ENDPOINT_TYPE == "6" ]]; then
resolvePublicIPv6
else
resolvePublicIPv4
fi
}
# Detect server's IPv4 and IPv6 addresses
function detect_server_ips() {
IP_IPV4=$(ip -4 addr | sed -ne 's|^.* inet \([^/]*\)/.* scope global.*$|\1|p' | head -1)
IP_IPV6=$(ip -6 addr | sed -ne 's|^.* inet6 \([^/]*\)/.* scope global.*$|\1|p' | head -1)
# Set IP based on ENDPOINT_TYPE
if [[ $ENDPOINT_TYPE == "6" ]]; then
IP="$IP_IPV6"
else
IP="$IP_IPV4"
fi
}
# Calculate derived network configuration values
function prepare_network_config() {
# Calculate IPv4 gateway (always needed for leak prevention)
VPN_GATEWAY_IPV4="${VPN_SUBNET_IPV4%.*}.1"
# Calculate IPv6 gateway if IPv6 is enabled
if [[ $CLIENT_IPV6 == "y" ]]; then
VPN_GATEWAY_IPV6="${VPN_SUBNET_IPV6}1"
fi
# Set legacy variable for backward compatibility
IPV6_SUPPORT="$CLIENT_IPV6"
}
function installQuestions() {
log_header "OpenVPN Installer"
log_prompt "The git repository is available at: https://github.com/angristan/openvpn-install"
log_prompt "I need to ask you a few questions before starting the setup."
log_prompt "You can leave the default options and just press enter if you are okay with them."
# ==========================================================================
# Step 1: Detect server IP addresses
# ==========================================================================
log_menu ""
log_prompt "Detecting server IP addresses..."
# Detect IPv4 address
IP_IPV4=$(ip -4 addr | sed -ne 's|^.* inet \([^/]*\)/.* scope global.*$|\1|p' | head -1)
# Detect IPv6 address
IP_IPV6=$(ip -6 addr | sed -ne 's|^.* inet6 \([^/]*\)/.* scope global.*$|\1|p' | head -1)
if [[ -n $IP_IPV4 ]]; then
log_prompt " IPv4 address detected: $IP_IPV4"
else
log_prompt " No IPv4 address detected"
fi
if [[ -n $IP_IPV6 ]]; then
log_prompt " IPv6 address detected: $IP_IPV6"
else
log_prompt " No IPv6 address detected"
fi
# ==========================================================================
# Step 2: Endpoint type selection
# ==========================================================================
log_menu ""
log_prompt "What IP version should clients use to connect to this server?"
# Determine default based on available addresses
if [[ -n $IP_IPV4 ]]; then
ENDPOINT_TYPE_DEFAULT=1
elif [[ -n $IP_IPV6 ]]; then
ENDPOINT_TYPE_DEFAULT=2
else
log_fatal "No IPv4 or IPv6 address detected on this server."
fi
log_menu " 1) IPv4"
log_menu " 2) IPv6"
until [[ $ENDPOINT_TYPE_CHOICE =~ ^[1-2]$ ]]; do
read -rp "Endpoint type [1-2]: " -e -i $ENDPOINT_TYPE_DEFAULT ENDPOINT_TYPE_CHOICE
done
case $ENDPOINT_TYPE_CHOICE in
1)
ENDPOINT_TYPE="4"
IP="$IP_IPV4"
;;
2)
ENDPOINT_TYPE="6"
IP="$IP_IPV6"
;;
esac
# ==========================================================================
# Step 3: Endpoint address (handle NAT for IPv4, direct for IPv6)
# ==========================================================================
APPROVE_IP=${APPROVE_IP:-n}
if [[ $APPROVE_IP =~ n ]]; then
log_menu ""
if [[ $ENDPOINT_TYPE == "4" ]]; then
log_prompt "Server listening IPv4 address:"
read -rp "IPv4 address: " -e -i "$IP" IP
else
log_prompt "Server listening IPv6 address:"
read -rp "IPv6 address: " -e -i "$IP" IP
fi
fi
# If IPv4 and private IP, server is behind NAT
if [[ $ENDPOINT_TYPE == "4" ]] && echo "$IP" | grep -qE '^(10\.|172\.1[6789]\.|172\.2[0-9]\.|172\.3[01]\.|192\.168)'; then
log_menu ""
log_prompt "It seems this server is behind NAT. What is its public IPv4 address or hostname?"
log_prompt "We need it for the clients to connect to the server."
if [[ -z $ENDPOINT ]]; then
DEFAULT_ENDPOINT=$(resolvePublicIPv4)
fi
until [[ $ENDPOINT != "" ]]; do
read -rp "Public IPv4 address or hostname: " -e -i "$DEFAULT_ENDPOINT" ENDPOINT
done
elif [[ $ENDPOINT_TYPE == "6" ]]; then
# For IPv6, check if it's a link-local address (starts with fe80)
if echo "$IP" | grep -qiE '^fe80'; then
log_menu ""
log_prompt "The detected IPv6 address is link-local. What is the public IPv6 address or hostname?"
log_prompt "We need it for the clients to connect to the server."
if [[ -z $ENDPOINT ]]; then
DEFAULT_ENDPOINT=$(resolvePublicIPv6)
fi
until [[ $ENDPOINT != "" ]]; do
read -rp "Public IPv6 address or hostname: " -e -i "$DEFAULT_ENDPOINT" ENDPOINT
done
fi
fi
# ==========================================================================
# Step 4: Client IP versions
# ==========================================================================
log_menu ""
log_prompt "What IP versions should VPN clients use?"
log_prompt "This determines both their VPN addresses and internet access through the tunnel."
# Check IPv6 connectivity for suggestion
if type ping6 >/dev/null 2>&1; then
PING6="ping6 -c1 -W2 ipv6.google.com > /dev/null 2>&1"
else
PING6="ping -6 -c1 -W2 ipv6.google.com > /dev/null 2>&1"
fi
HAS_IPV6_CONNECTIVITY="n"
if eval "$PING6"; then
HAS_IPV6_CONNECTIVITY="y"
fi
# Default suggestion based on connectivity
if [[ $HAS_IPV6_CONNECTIVITY == "y" ]]; then
CLIENT_IP_DEFAULT=3 # Dual-stack if IPv6 available
else
CLIENT_IP_DEFAULT=1 # IPv4 only otherwise
fi
log_menu " 1) IPv4 only"
log_menu " 2) IPv6 only"
log_menu " 3) Dual-stack (IPv4 + IPv6)"
until [[ $CLIENT_IP_CHOICE =~ ^[1-3]$ ]]; do
read -rp "Client IP versions [1-3]: " -e -i $CLIENT_IP_DEFAULT CLIENT_IP_CHOICE
done
case $CLIENT_IP_CHOICE in
1)
CLIENT_IPV4="y"
CLIENT_IPV6="n"
;;
2)
CLIENT_IPV4="n"
CLIENT_IPV6="y"
;;
3)
CLIENT_IPV4="y"
CLIENT_IPV6="y"
;;
esac
# ==========================================================================
# Step 5: IPv4 subnet (prompt only if IPv4 enabled, but always set for leak prevention)
# ==========================================================================
if [[ $CLIENT_IPV4 == "y" ]]; then
log_menu ""
log_prompt "IPv4 VPN subnet:"
log_menu " 1) Default: 10.8.0.0/24"
log_menu " 2) Custom"
until [[ $SUBNET_IPV4_CHOICE =~ ^[1-2]$ ]]; do
read -rp "IPv4 subnet choice [1-2]: " -e -i 1 SUBNET_IPV4_CHOICE
done
case $SUBNET_IPV4_CHOICE in
1)
VPN_SUBNET_IPV4="10.8.0.0"
;;
2)
# Skip prompt if VPN_SUBNET_IPV4 is already set (e.g., via environment variable)
if [[ -z $VPN_SUBNET_IPV4 ]]; then
until [[ $VPN_SUBNET_IPV4 =~ ^(10\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|172\.(1[6-9]|2[0-9]|3[0-1])\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|192\.168\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]))\.0$ ]]; do
read -rp "Custom IPv4 subnet (e.g., 10.9.0.0): " VPN_SUBNET_IPV4
done
fi
;;
esac
else
# IPv6-only mode: still need IPv4 subnet for leak prevention (redirect-gateway def1)
VPN_SUBNET_IPV4="10.8.0.0"
fi
# ==========================================================================
# Step 6: IPv6 subnet (if IPv6 enabled for clients)
# ==========================================================================
if [[ $CLIENT_IPV6 == "y" ]]; then
log_menu ""
log_prompt "IPv6 VPN subnet:"
log_menu " 1) Default: fd42:42:42:42::/112"
log_menu " 2) Custom"
until [[ $SUBNET_IPV6_CHOICE =~ ^[1-2]$ ]]; do
read -rp "IPv6 subnet choice [1-2]: " -e -i 1 SUBNET_IPV6_CHOICE
done
case $SUBNET_IPV6_CHOICE in
1)
VPN_SUBNET_IPV6="fd42:42:42:42::"
;;
2)
# Skip prompt if VPN_SUBNET_IPV6 is already set (e.g., via environment variable)
if [[ -z $VPN_SUBNET_IPV6 ]]; then
until [[ $VPN_SUBNET_IPV6 =~ ^fd[0-9a-fA-F]{0,2}(:[0-9a-fA-F]{0,4}){0,6}::$ ]]; do
read -rp "Custom IPv6 subnet (e.g., fd12:3456:789a::): " VPN_SUBNET_IPV6
done
fi
;;
esac
fi
log_menu ""
log_prompt "What port do you want OpenVPN to listen to?"
log_menu " 1) Default: 1194"
log_menu " 2) Custom"
log_menu " 3) Random [49152-65535]"
until [[ $PORT_CHOICE =~ ^[1-3]$ ]]; do
read -rp "Port choice [1-3]: " -e -i 1 PORT_CHOICE
done
case $PORT_CHOICE in
1)
PORT="1194"
;;
2)
until [[ $PORT =~ ^[0-9]+$ ]] && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ]; do
read -rp "Custom port [1-65535]: " -e -i 1194 PORT
done
;;
3)
# Generate random number within private ports range
PORT=$(shuf -i 49152-65535 -n1)
log_info "Random Port: $PORT"
;;
esac
log_menu ""
log_prompt "What protocol do you want OpenVPN to use?"
log_prompt "UDP is faster. Unless it is not available, you shouldn't use TCP."
log_menu " 1) UDP"
log_menu " 2) TCP"
until [[ $PROTOCOL_CHOICE =~ ^[1-2]$ ]]; do
read -rp "Protocol [1-2]: " -e -i 1 PROTOCOL_CHOICE
done
case $PROTOCOL_CHOICE in
1)
PROTOCOL="udp"
;;
2)
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
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
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."
until [[ $MULTI_CLIENT =~ (y|n) ]]; do
read -rp "Allow multiple devices per client? [y/n]: " -e -i n MULTI_CLIENT
done
log_menu ""
log_prompt "Do you want to customize the tunnel MTU?"
log_menu " MTU controls the maximum packet size. Lower values can help"
log_menu " with connectivity issues on some networks (e.g., PPPoE, mobile)."
log_menu " 1) Default (1500) - works for most networks"
log_menu " 2) Custom"
until [[ $MTU_CHOICE =~ ^[1-2]$ ]]; do
read -rp "MTU choice [1-2]: " -e -i 1 MTU_CHOICE
done
if [[ $MTU_CHOICE == "2" ]]; then
until [[ $MTU =~ ^[0-9]+$ ]] && [[ $MTU -ge 576 ]] && [[ $MTU -le 65535 ]]; do
read -rp "MTU [576-65535]: " -e -i 1500 MTU
done
fi
log_menu ""
log_prompt "Choose the authentication mode:"
log_menu " 1) PKI (Certificate Authority) - Traditional CA-based authentication (recommended for larger setups)"
log_menu " 2) Peer Fingerprint - Simplified WireGuard-like authentication using certificate fingerprints"
log_menu " Note: Fingerprint mode requires OpenVPN 2.6+ and is ideal for small/home setups"
local auth_mode_choice
until [[ $auth_mode_choice =~ ^[1-2]$ ]]; do
read -rp "Authentication mode [1-2]: " -e -i 1 auth_mode_choice
done
case $auth_mode_choice in
1)
AUTH_MODE="pki"
;;
2)
AUTH_MODE="fingerprint"
# Verify OpenVPN 2.6+ is available for fingerprint mode
local openvpn_ver
openvpn_ver=$(get_openvpn_version)
if [[ -n "$openvpn_ver" ]] && ! version_ge "$openvpn_ver" "2.6.0"; then
log_warn "OpenVPN $openvpn_ver detected. Fingerprint mode requires 2.6.0+."
log_warn "OpenVPN 2.6+ will be installed during setup."
fi
;;
esac
log_menu ""
log_prompt "Do you want to customize encryption settings?"
log_prompt "Unless you know what you're doing, you should stick with the default parameters provided by the script."
log_prompt "Note that whatever you choose, all the choices presented in the script are safe (unlike OpenVPN's defaults)."
log_prompt "See https://github.com/angristan/openvpn-install#security-and-encryption to learn more."
log_menu ""
until [[ $CUSTOMIZE_ENC =~ (y|n) ]]; do
read -rp "Customize encryption settings? [y/n]: " -e -i n CUSTOMIZE_ENC
done
if [[ $CUSTOMIZE_ENC == "n" ]]; then
# Use default, sane and fast parameters
CIPHER="AES-128-GCM"
CERT_TYPE="ecdsa"
CERT_CURVE="prime256v1"
CC_CIPHER="TLS-ECDHE-ECDSA-WITH-AES-128-GCM-SHA256"
TLS13_CIPHERSUITES="TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256"
TLS_VERSION_MIN="1.2"
TLS_GROUPS="X25519:prime256v1:secp384r1:secp521r1"
HMAC_ALG="SHA256"
TLS_SIG="crypt-v2"
else
log_menu ""
log_prompt "Choose which cipher you want to use for the data channel:"
log_menu " 1) AES-128-GCM (recommended)"
log_menu " 2) AES-192-GCM"
log_menu " 3) AES-256-GCM"
log_menu " 4) AES-128-CBC"
log_menu " 5) AES-192-CBC"
log_menu " 6) AES-256-CBC"
log_menu " 7) CHACHA20-POLY1305 (requires OpenVPN 2.5+, good for devices without AES-NI)"
until [[ $CIPHER_CHOICE =~ ^[1-7]$ ]]; do
read -rp "Cipher [1-7]: " -e -i 1 CIPHER_CHOICE
done
case $CIPHER_CHOICE in
1)
CIPHER="AES-128-GCM"
;;
2)
CIPHER="AES-192-GCM"
;;
3)
CIPHER="AES-256-GCM"
;;
4)
CIPHER="AES-128-CBC"
;;
5)
CIPHER="AES-192-CBC"
;;
6)
CIPHER="AES-256-CBC"
;;
7)
CIPHER="CHACHA20-POLY1305"
;;
esac
log_menu ""
log_prompt "Choose what kind of certificate you want to use:"
log_menu " 1) ECDSA (recommended)"
log_menu " 2) RSA"
local cert_type_choice
until [[ $cert_type_choice =~ ^[1-2]$ ]]; do
read -rp "Certificate key type [1-2]: " -e -i 1 cert_type_choice
done
case $cert_type_choice in
1)
CERT_TYPE="ecdsa"
log_menu ""
log_prompt "Choose which curve you want to use for the certificate's key:"
select_from_array "Curve" CERT_CURVES "prime256v1" CERT_CURVE
;;
2)
CERT_TYPE="rsa"
log_menu ""
log_prompt "Choose which size you want to use for the certificate's RSA key:"
select_from_array "RSA key size" RSA_KEY_SIZES "2048" RSA_KEY_SIZE
;;
esac
log_menu ""
log_prompt "Choose which cipher you want to use for the control channel:"
local cc_labels cc_values
if [[ $CERT_TYPE == "ecdsa" ]]; then
cc_labels=("ECDHE-ECDSA-AES-128-GCM-SHA256 (recommended)" "ECDHE-ECDSA-AES-256-GCM-SHA384" "ECDHE-ECDSA-CHACHA20-POLY1305 (OpenVPN 2.5+)")
cc_values=("TLS-ECDHE-ECDSA-WITH-AES-128-GCM-SHA256" "TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384" "TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256")
else
cc_labels=("ECDHE-RSA-AES-128-GCM-SHA256 (recommended)" "ECDHE-RSA-AES-256-GCM-SHA384" "ECDHE-RSA-CHACHA20-POLY1305 (OpenVPN 2.5+)")
cc_values=("TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256" "TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384" "TLS-ECDHE-RSA-WITH-CHACHA20-POLY1305-SHA256")
fi
select_with_labels "Control channel cipher" cc_labels cc_values "${cc_values[0]}" CC_CIPHER
log_menu ""
log_prompt "Choose the minimum TLS version:"
log_menu " 1) TLS 1.2 (recommended, compatible with all clients)"
log_menu " 2) TLS 1.3 (more secure, requires OpenVPN 2.5+ clients)"
until [[ $TLS_VERSION_MIN_CHOICE =~ ^[1-2]$ ]]; do
read -rp "Minimum TLS version [1-2]: " -e -i 1 TLS_VERSION_MIN_CHOICE
done
case $TLS_VERSION_MIN_CHOICE in
1)
TLS_VERSION_MIN="1.2"
;;
2)
TLS_VERSION_MIN="1.3"
;;
esac
log_menu ""
log_prompt "Choose TLS 1.3 cipher suites (used when TLS 1.3 is negotiated):"
log_menu " 1) All secure ciphers (recommended)"
log_menu " 2) AES-256-GCM only"
log_menu " 3) AES-128-GCM only"
log_menu " 4) ChaCha20-Poly1305 only"
until [[ $TLS13_CIPHER_CHOICE =~ ^[1-4]$ ]]; do
read -rp "TLS 1.3 cipher suite [1-4]: " -e -i 1 TLS13_CIPHER_CHOICE
done
case $TLS13_CIPHER_CHOICE in
1)
TLS13_CIPHERSUITES="TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256"
;;
2)
TLS13_CIPHERSUITES="TLS_AES_256_GCM_SHA384"
;;
3)
TLS13_CIPHERSUITES="TLS_AES_128_GCM_SHA256"
;;
4)
TLS13_CIPHERSUITES="TLS_CHACHA20_POLY1305_SHA256"
;;
esac
log_menu ""
log_prompt "Choose TLS key exchange groups (for ECDH key exchange):"
log_menu " 1) All modern curves (recommended)"
log_menu " 2) X25519 only (most secure, may have compatibility issues)"
log_menu " 3) NIST curves only (prime256v1, secp384r1, secp521r1)"
until [[ $TLS_GROUPS_CHOICE =~ ^[1-3]$ ]]; do
read -rp "TLS groups [1-3]: " -e -i 1 TLS_GROUPS_CHOICE
done
case $TLS_GROUPS_CHOICE in
1)
TLS_GROUPS="X25519:prime256v1:secp384r1:secp521r1"
;;
2)
TLS_GROUPS="X25519"
;;
3)
TLS_GROUPS="prime256v1:secp384r1:secp521r1"
;;
esac
log_menu ""
# The "auth" options behaves differently with AEAD ciphers (GCM, ChaCha20-Poly1305)
if [[ $CIPHER =~ CBC$ ]]; then
log_prompt "The digest algorithm authenticates data channel packets and tls-auth packets from the control channel."
elif [[ $CIPHER =~ GCM$ ]] || [[ $CIPHER == "CHACHA20-POLY1305" ]]; then
log_prompt "The digest algorithm authenticates tls-auth packets from the control channel."
fi
log_prompt "Which digest algorithm do you want to use for HMAC?"
log_menu " 1) SHA-256 (recommended)"
log_menu " 2) SHA-384"
log_menu " 3) SHA-512"
until [[ $HMAC_ALG_CHOICE =~ ^[1-3]$ ]]; do
read -rp "Digest algorithm [1-3]: " -e -i 1 HMAC_ALG_CHOICE
done
case $HMAC_ALG_CHOICE in
1)
HMAC_ALG="SHA256"
;;
2)
HMAC_ALG="SHA384"
;;
3)
HMAC_ALG="SHA512"
;;
esac
log_menu ""
log_prompt "You can add an additional layer of security to the control channel."
local tls_sig_labels=("tls-crypt-v2 (recommended): Encrypts control channel, unique key per client" "tls-crypt: Encrypts control channel, shared key for all clients" "tls-auth: Authenticates control channel, no encryption")
select_with_labels "Control channel security" tls_sig_labels TLS_SIG_MODES "crypt-v2" TLS_SIG
fi
log_menu ""
log_prompt "Okay, that was all I needed. We are ready to setup your OpenVPN server now."
log_prompt "You will be able to generate a client at the end of the installation."
APPROVE_INSTALL=${APPROVE_INSTALL:-n}
if [[ $APPROVE_INSTALL =~ n ]]; then
read -n1 -r -p "Press any key to continue..."
fi
}
function installOpenVPN() {
if [[ $NON_INTERACTIVE_INSTALL == "y" ]]; then
# Resolve public IP if ENDPOINT not set
if [[ -z $ENDPOINT ]]; then
ENDPOINT=$(resolvePublicIP)
fi
# Log non-interactive mode and parameters
log_info "=== OpenVPN Non-Interactive Install ==="
log_info "Running in non-interactive mode with the following settings:"
log_info " ENDPOINT=$ENDPOINT"
log_info " ENDPOINT_TYPE=$ENDPOINT_TYPE"
log_info " CLIENT_IPV4=$CLIENT_IPV4"
log_info " CLIENT_IPV6=$CLIENT_IPV6"
log_info " VPN_SUBNET_IPV4=$VPN_SUBNET_IPV4"
log_info " VPN_SUBNET_IPV6=$VPN_SUBNET_IPV6"
log_info " PORT=$PORT"
log_info " PROTOCOL=$PROTOCOL"
log_info " DNS=$DNS"
[[ -n $MTU ]] && log_info " MTU=$MTU"
log_info " MULTI_CLIENT=$MULTI_CLIENT"
log_info " AUTH_MODE=$AUTH_MODE"
log_info " CLIENT=$CLIENT"
log_info " CLIENT_CERT_DURATION_DAYS=$CLIENT_CERT_DURATION_DAYS"
log_info " SERVER_CERT_DURATION_DAYS=$SERVER_CERT_DURATION_DAYS"
fi
# Get the "public" interface from the default route
NIC=$(ip -4 route ls | grep default | grep -Po '(?<=dev )(\S+)' | head -1)
if [[ -z $NIC ]] && [[ $CLIENT_IPV6 == 'y' ]]; then
NIC=$(ip -6 route show default | sed -ne 's/^default .* dev \([^ ]*\) .*$/\1/p')
fi
# $NIC can not be empty for script rm-openvpn-rules.sh
if [[ -z $NIC ]]; then
log_warn "Could not detect public interface."
log_info "This needs for setup MASQUERADE."
until [[ $CONTINUE =~ (y|n) ]]; do
read -rp "Continue? [y/n]: " -e CONTINUE
done
if [[ $CONTINUE == "n" ]]; then
exit 1
fi
fi
# If OpenVPN isn't installed yet, install it. This script is more-or-less
# idempotent on multiple runs, but will only install OpenVPN from upstream
# the first time.
if [[ ! -e /etc/openvpn/server/server.conf ]]; then
log_header "Installing OpenVPN"
# Setup official OpenVPN repository for latest versions
installOpenVPNRepo
log_info "Installing OpenVPN and dependencies..."
# socat is used for communicating with the OpenVPN management interface (client disconnect on revoke)
if [[ $OS =~ (debian|ubuntu) ]]; then
run_cmd_fatal "Installing OpenVPN" apt-get install -y openvpn iptables openssl curl ca-certificates tar dnsutils socat
elif [[ $OS == 'centos' ]]; then
run_cmd_fatal "Installing OpenVPN" yum install -y openvpn iptables openssl ca-certificates curl tar bind-utils socat 'policycoreutils-python*'
elif [[ $OS == 'oracle' ]]; then
run_cmd_fatal "Installing OpenVPN" yum install -y openvpn iptables openssl ca-certificates curl tar bind-utils socat policycoreutils-python-utils
elif [[ $OS == 'amzn2023' ]]; then
run_cmd_fatal "Installing OpenVPN" dnf install -y openvpn iptables openssl ca-certificates curl tar bind-utils socat
elif [[ $OS == 'fedora' ]]; then
run_cmd_fatal "Installing OpenVPN" dnf install -y openvpn iptables openssl ca-certificates curl tar bind-utils socat policycoreutils-python-utils
elif [[ $OS == 'opensuse' ]]; then
run_cmd_fatal "Installing OpenVPN" zypper install -y openvpn iptables openssl ca-certificates curl tar bind-utils socat
elif [[ $OS == 'arch' ]]; then
run_cmd_fatal "Installing OpenVPN" pacman --needed --noconfirm -Syu openvpn iptables openssl ca-certificates curl tar bind socat
fi
# Verify ChaCha20-Poly1305 compatibility if selected
if [[ $CIPHER == "CHACHA20-POLY1305" ]] || [[ $CC_CIPHER =~ CHACHA20 ]]; then
local installed_version
installed_version=$(openvpn --version 2>/dev/null | head -1 | awk '{print $2}')
if ! openvpnVersionAtLeast "2.5"; then
log_fatal "ChaCha20-Poly1305 requires OpenVPN 2.5 or later. Installed version: $installed_version"
fi
log_info "OpenVPN version supports ChaCha20-Poly1305"
fi
# Check Data Channel Offload (DCO) availability
if isDCOAvailable; then
# Check if configuration is DCO-compatible (udp or udp6)
if [[ $PROTOCOL =~ ^udp ]] && [[ $CIPHER =~ (GCM|CHACHA20-POLY1305) ]]; then
log_info "Data Channel Offload (DCO) is available and will be used for improved performance"
else
log_info "Data Channel Offload (DCO) is available but not enabled (requires UDP, AEAD cipher)"
fi
else
log_info "Data Channel Offload (DCO) is not available (requires OpenVPN 2.6+ and kernel support)"
fi
# Create the server directory (OpenVPN 2.4+ directory structure)
run_cmd_fatal "Creating server directory" mkdir -p /etc/openvpn/server
fi
# Determine which user/group OpenVPN should run as
# - Fedora/RHEL/Amazon create 'openvpn' user with 'openvpn' group
# - Arch creates 'openvpn' user with 'network' group
# - Debian/Ubuntu/openSUSE don't create a dedicated user, use 'nobody'
#
# Also check if the systemd service file already handles user/group switching.
# If so, we shouldn't add user/group to config (would cause double privilege drop).
SYSTEMD_HANDLES_USER=false
for service_file in /usr/lib/systemd/system/openvpn-server@.service /lib/systemd/system/openvpn-server@.service; do
if [[ -f "$service_file" ]] && grep -q "^User=" "$service_file"; then
SYSTEMD_HANDLES_USER=true
break
fi
done
if id openvpn &>/dev/null; then
OPENVPN_USER=openvpn
# Get the openvpn user's primary group (e.g., 'openvpn' on Fedora, 'network' on Arch)
OPENVPN_GROUP=$(id -gn openvpn 2>/dev/null || echo openvpn)
else
OPENVPN_USER=nobody
if grep -qs "^nogroup:" /etc/group; then
OPENVPN_GROUP=nogroup
else
OPENVPN_GROUP=nobody
fi
fi
# Install the latest version of easy-rsa from source, if not already installed.
if [[ ! -d /etc/openvpn/server/easy-rsa/ ]]; then
run_cmd_fatal "Downloading Easy-RSA v${EASYRSA_VERSION}" curl -fL --retry 5 -o ~/easy-rsa.tgz "https://github.com/OpenVPN/easy-rsa/releases/download/v${EASYRSA_VERSION}/EasyRSA-${EASYRSA_VERSION}.tgz"
log_info "Verifying Easy-RSA checksum..."
CHECKSUM_OUTPUT=$(echo "${EASYRSA_SHA256} $HOME/easy-rsa.tgz" | sha256sum -c 2>&1) || {
_log_to_file "[CHECKSUM] $CHECKSUM_OUTPUT"
run_cmd "Cleaning up failed download" rm -f ~/easy-rsa.tgz
log_fatal "SHA256 checksum verification failed for easy-rsa download!"
}
_log_to_file "[CHECKSUM] $CHECKSUM_OUTPUT"
run_cmd_fatal "Creating Easy-RSA directory" mkdir -p /etc/openvpn/server/easy-rsa
run_cmd_fatal "Extracting Easy-RSA" tar xzf ~/easy-rsa.tgz --strip-components=1 --no-same-owner --directory /etc/openvpn/server/easy-rsa
run_cmd "Cleaning up archive" rm -f ~/easy-rsa.tgz
cd /etc/openvpn/server/easy-rsa/ || return
case $CERT_TYPE in
ecdsa)
echo "set_var EASYRSA_ALGO ec" >vars
echo "set_var EASYRSA_CURVE $CERT_CURVE" >>vars
;;
rsa)
echo "set_var EASYRSA_KEY_SIZE $RSA_KEY_SIZE" >vars
;;
esac
# Generate a random, alphanumeric identifier of 16 characters for CN and one for server name
# Note: 2>/dev/null suppresses "Broken pipe" errors from fold when head exits early
SERVER_CN="cn_$(head /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 2>/dev/null | head -n 1)"
echo "$SERVER_CN" >SERVER_CN_GENERATED
SERVER_NAME="server_$(head /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 2>/dev/null | head -n 1)"
echo "$SERVER_NAME" >SERVER_NAME_GENERATED
# Create the PKI, set up the CA, the DH params and the server certificate
log_info "Initializing PKI..."
run_cmd_fatal "Initializing PKI" ./easyrsa init-pki
if [[ $AUTH_MODE == "pki" ]]; then
# Traditional PKI mode with CA
export EASYRSA_CA_EXPIRE=$DEFAULT_CERT_VALIDITY_DURATION_DAYS
log_info "Building CA..."
run_cmd_fatal "Building CA" ./easyrsa --batch --req-cn="$SERVER_CN" build-ca nopass
export EASYRSA_CERT_EXPIRE=${SERVER_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}
log_info "Building server certificate..."
run_cmd_fatal "Building server certificate" ./easyrsa --batch build-server-full "$SERVER_NAME" nopass
export EASYRSA_CRL_DAYS=$DEFAULT_CRL_VALIDITY_DURATION_DAYS
run_cmd_fatal "Generating CRL" ./easyrsa gen-crl
else
# Fingerprint mode with self-signed certificates (OpenVPN 2.6+)
log_info "Building self-signed server certificate for fingerprint mode..."
export EASYRSA_CERT_EXPIRE=${SERVER_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}
run_cmd_fatal "Building self-signed server certificate" ./easyrsa --batch self-sign-server "$SERVER_NAME" nopass
# Extract and store server fingerprint
SERVER_FINGERPRINT=$(openssl x509 -in "pki/issued/$SERVER_NAME.crt" -fingerprint -sha256 -noout | cut -d'=' -f2)
if [[ -z $SERVER_FINGERPRINT ]]; then
log_error "Failed to extract server certificate fingerprint"
exit 1
fi
mkdir -p /etc/openvpn/server
echo "$SERVER_FINGERPRINT" >/etc/openvpn/server/server-fingerprint
log_info "Server fingerprint: $SERVER_FINGERPRINT"
fi
log_info "Generating TLS key..."
case $TLS_SIG in
crypt-v2)
# Generate tls-crypt-v2 server key
run_cmd_fatal "Generating tls-crypt-v2 server key" openvpn --genkey tls-crypt-v2-server /etc/openvpn/server/tls-crypt-v2.key
;;
crypt)
# Generate tls-crypt key
run_cmd_fatal "Generating tls-crypt key" openvpn --genkey secret /etc/openvpn/server/tls-crypt.key
;;
auth)
# Generate tls-auth key
run_cmd_fatal "Generating tls-auth key" openvpn --genkey secret /etc/openvpn/server/tls-auth.key
;;
esac
# Store auth mode for later use
echo "$AUTH_MODE" >AUTH_MODE_GENERATED
else
# If easy-rsa is already installed, grab the generated SERVER_NAME
# for client configs
cd /etc/openvpn/server/easy-rsa/ || return
SERVER_NAME=$(cat SERVER_NAME_GENERATED)
# Read stored auth mode
if [[ -f AUTH_MODE_GENERATED ]]; then
AUTH_MODE=$(cat AUTH_MODE_GENERATED)
else
# Default to pki for existing installations
AUTH_MODE="pki"
fi
fi
# Move all the generated files
log_info "Copying certificates..."
if [[ $AUTH_MODE == "pki" ]]; then
run_cmd_fatal "Copying certificates to /etc/openvpn/server" cp pki/ca.crt pki/private/ca.key "pki/issued/$SERVER_NAME.crt" "pki/private/$SERVER_NAME.key" /etc/openvpn/server/easy-rsa/pki/crl.pem /etc/openvpn/server
# Make cert revocation list readable for non-root
run_cmd "Setting CRL permissions" chmod 644 /etc/openvpn/server/crl.pem
else
# Fingerprint mode: only copy server cert and key (no CA or CRL)
run_cmd_fatal "Copying certificates to /etc/openvpn/server" cp "pki/issued/$SERVER_NAME.crt" "pki/private/$SERVER_NAME.key" /etc/openvpn/server
fi
# Generate server.conf
log_info "Generating server configuration..."
echo "port $PORT" >/etc/openvpn/server/server.conf
# Protocol selection: use proto6 variants if endpoint is IPv6
if [[ $ENDPOINT_TYPE == "6" ]]; then
echo "proto ${PROTOCOL}6" >>/etc/openvpn/server/server.conf
else
echo "proto $PROTOCOL" >>/etc/openvpn/server/server.conf
fi
if [[ $MULTI_CLIENT == "y" ]]; then
echo "duplicate-cn" >>/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)
if [[ $SYSTEMD_HANDLES_USER == "false" ]]; then
echo "user $OPENVPN_USER
group $OPENVPN_GROUP" >>/etc/openvpn/server/server.conf
fi
echo "persist-key
persist-tun
keepalive 10 120
topology subnet" >>/etc/openvpn/server/server.conf
# IPv4 server directive - always assign IPv4 to clients for proper routing
# Even for IPv6-only mode, we need IPv4 addresses so redirect-gateway def1 can block IPv4 leaks
echo "server $VPN_SUBNET_IPV4 255.255.255.0" >>/etc/openvpn/server/server.conf
# IPv6 server directive (only if clients get IPv6)
if [[ $CLIENT_IPV6 == "y" ]]; then
{
echo "server-ipv6 ${VPN_SUBNET_IPV6}/112"
echo "tun-ipv6"
echo "push tun-ipv6"
} >>/etc/openvpn/server/server.conf
fi
# ifconfig-pool-persist is incompatible with duplicate-cn
if [[ $MULTI_CLIENT != "y" ]]; then
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
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
# 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
fi
if [[ -n $MTU ]]; then
echo "tun-mtu $MTU" >>/etc/openvpn/server/server.conf
fi
# Use ECDH key exchange (dh none) with tls-groups for curve negotiation
echo "dh none" >>/etc/openvpn/server/server.conf
echo "tls-groups $TLS_GROUPS" >>/etc/openvpn/server/server.conf
case $TLS_SIG in
crypt-v2)
echo "tls-crypt-v2 tls-crypt-v2.key" >>/etc/openvpn/server/server.conf
;;
crypt)
echo "tls-crypt tls-crypt.key" >>/etc/openvpn/server/server.conf
;;
auth)
echo "tls-auth tls-auth.key 0" >>/etc/openvpn/server/server.conf
;;
esac
# Common server config options
# PKI mode adds crl-verify, ca, and remote-cert-tls
# Fingerprint mode: <peer-fingerprint> block is added when first client is created
{
[[ $AUTH_MODE == "pki" ]] && echo "crl-verify crl.pem
ca ca.crt"
echo "cert $SERVER_NAME.crt
key $SERVER_NAME.key
auth $HMAC_ALG
cipher $CIPHER
ignore-unknown-option data-ciphers
data-ciphers $CIPHER
ncp-ciphers $CIPHER
tls-server
tls-version-min $TLS_VERSION_MIN"
[[ $AUTH_MODE == "pki" ]] && echo "remote-cert-tls client"
echo "tls-cipher $CC_CIPHER
tls-ciphersuites $TLS13_CIPHERSUITES
client-config-dir ccd
status /var/log/openvpn/status.log
management /var/run/openvpn-server/server.sock unix
verb 3"
} >>/etc/openvpn/server/server.conf
# Create client-config-dir dir
run_cmd_fatal "Creating client config directory" mkdir -p /etc/openvpn/server/ccd
# Create log dir
run_cmd_fatal "Creating log directory" mkdir -p /var/log/openvpn
# On distros that use a dedicated OpenVPN user (not "nobody"), e.g., Fedora, RHEL, Arch,
# set ownership so OpenVPN can read config/certs and write to log directory
if [[ $OPENVPN_USER != "nobody" ]]; then
log_info "Setting ownership for OpenVPN user..."
chown -R "$OPENVPN_USER:$OPENVPN_GROUP" /etc/openvpn/server
chown "$OPENVPN_USER:$OPENVPN_GROUP" /var/log/openvpn
fi
# 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
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
fi
# Enable IPv6 forwarding if clients get IPv6
if [[ $CLIENT_IPV6 == 'y' ]]; then
echo 'net.ipv6.conf.all.forwarding=1' >>/etc/sysctl.d/99-openvpn.conf
fi
# Apply sysctl rules
run_cmd "Applying sysctl rules" sysctl --system
# If SELinux is enabled and a custom port was selected, we need this
if hash sestatus 2>/dev/null; then
if sestatus | grep "Current mode" | grep -qs "enforcing"; then
if [[ $PORT != '1194' ]]; then
# Strip "6" suffix from protocol (semanage expects "udp" or "tcp", not "udp6"/"tcp6")
SELINUX_PROTOCOL="${PROTOCOL%6}"
run_cmd "Configuring SELinux port" semanage port -a -t openvpn_port_t -p "$SELINUX_PROTOCOL" "$PORT"
fi
fi
fi
# Finally, restart and enable OpenVPN
# OpenVPN 2.4+ uses openvpn-server@.service with config in /etc/openvpn/server/
log_info "Configuring OpenVPN service..."
# Find the service file (location and name vary by distro)
# Modern distros: openvpn-server@.service in /usr/lib/systemd/system/ or /lib/systemd/system/
# openSUSE: openvpn@.service (old-style) that we need to adapt
if [[ -f /usr/lib/systemd/system/openvpn-server@.service ]]; then
SERVICE_SOURCE="/usr/lib/systemd/system/openvpn-server@.service"
elif [[ -f /lib/systemd/system/openvpn-server@.service ]]; then
SERVICE_SOURCE="/lib/systemd/system/openvpn-server@.service"
elif [[ -f /usr/lib/systemd/system/openvpn@.service ]]; then
# openSUSE uses old-style service, we'll create our own openvpn-server@.service
SERVICE_SOURCE="/usr/lib/systemd/system/openvpn@.service"
elif [[ -f /lib/systemd/system/openvpn@.service ]]; then
SERVICE_SOURCE="/lib/systemd/system/openvpn@.service"
else
log_fatal "Could not find openvpn-server@.service or openvpn@.service file"
fi
# Don't modify package-provided service, copy to /etc/systemd/system/
run_cmd_fatal "Copying OpenVPN service file" cp "$SERVICE_SOURCE" /etc/systemd/system/openvpn-server@.service
# Workaround to fix OpenVPN service on OpenVZ
run_cmd "Patching service file (LimitNPROC)" sed -i 's|LimitNPROC|#LimitNPROC|' /etc/systemd/system/openvpn-server@.service
# Ensure the service uses /etc/openvpn/server/ as working directory
# This is needed for openSUSE which uses old-style paths by default
if grep -q "cd /etc/openvpn/" /etc/systemd/system/openvpn-server@.service; then
run_cmd "Patching service file (paths)" sed -i 's|/etc/openvpn/|/etc/openvpn/server/|g' /etc/systemd/system/openvpn-server@.service
fi
# Ensure RuntimeDirectory is set for the management socket
# Some distros (e.g., openSUSE) don't include this in their service file
if ! grep -q "RuntimeDirectory=" /etc/systemd/system/openvpn-server@.service; then
run_cmd "Patching service file (RuntimeDirectory)" sed -i '/\[Service\]/a RuntimeDirectory=openvpn-server' /etc/systemd/system/openvpn-server@.service
fi
# AppArmor: Ubuntu 25.04+ ships an enforcing profile for OpenVPN
# (/etc/apparmor.d/openvpn) that doesn't allow the management unix socket
# in /run/openvpn-server/. Add a local override to permit this.
if [[ -f /etc/apparmor.d/openvpn ]]; then
log_info "Configuring AppArmor for OpenVPN..."
mkdir -p /etc/apparmor.d/local
if [[ ! -f /etc/apparmor.d/local/openvpn ]] || ! grep -q "openvpn-server" /etc/apparmor.d/local/openvpn; then
{
echo "# Allow OpenVPN management socket and status files in openvpn-server directory"
echo "/{,var/}run/openvpn-server/** rw,"
} >>/etc/apparmor.d/local/openvpn
fi
run_cmd "Reloading AppArmor profile" apparmor_parser -r /etc/apparmor.d/openvpn
fi
run_cmd "Reloading systemd" systemctl daemon-reload
run_cmd "Enabling OpenVPN service" systemctl enable openvpn-server@server
# In fingerprint mode, delay service start until first client is created
# (OpenVPN requires at least one fingerprint or a CA to start)
if [[ $AUTH_MODE == "pki" ]]; then
run_cmd "Starting OpenVPN service" systemctl restart openvpn-server@server
fi
if [[ $DNS == "unbound" ]]; then
installUnbound
fi
# Configure firewall rules
# 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
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"
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"
fi
run_cmd "Reloading firewalld" firewall-cmd --reload
elif systemctl is-active --quiet nftables; then
# Use nftables native rules for systems with nftables active
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
echo " iifname \"tun*\" ip saddr $VPN_SUBNET_IPV4/24 accept"
fi
if [[ $CLIENT_IPV6 == 'y' ]]; then
echo " iifname \"tun*\" ip6 saddr ${VPN_SUBNET_IPV6}/112 accept"
fi
echo " iifname \"$NIC\" $PROTOCOL dport $PORT accept"
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"
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"
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
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
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'
fi
# Reload nftables to apply rules
run_cmd "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
fi
# Script to remove rules
echo "#!/bin/sh" >/etc/iptables/rm-openvpn-rules.sh
# IPv4 removal rules
if [[ $CLIENT_IPV4 == 'y' ]]; then
echo "iptables -t nat -D POSTROUTING -s $VPN_SUBNET_IPV4/24 -o $NIC -j MASQUERADE
iptables -D INPUT -i tun+ -s $VPN_SUBNET_IPV4/24 -j ACCEPT
iptables -D FORWARD -i tun+ -s $VPN_SUBNET_IPV4/24 -j ACCEPT
iptables -D FORWARD -o tun+ -d $VPN_SUBNET_IPV4/24 -j ACCEPT
iptables -D INPUT -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT" >>/etc/iptables/rm-openvpn-rules.sh
fi
# IPv6 removal rules
if [[ $CLIENT_IPV6 == 'y' ]]; then
echo "ip6tables -t nat -D POSTROUTING -s ${VPN_SUBNET_IPV6}/112 -o $NIC -j MASQUERADE
ip6tables -D INPUT -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j ACCEPT
ip6tables -D FORWARD -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j ACCEPT
ip6tables -D FORWARD -o tun+ -d ${VPN_SUBNET_IPV6}/112 -j ACCEPT
ip6tables -D INPUT -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT" >>/etc/iptables/rm-openvpn-rules.sh
fi
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
# Handle the rules via a systemd script
echo "[Unit]
Description=iptables rules for OpenVPN
After=firewalld.service
Before=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/etc/iptables/add-openvpn-rules.sh
ExecStop=/etc/iptables/rm-openvpn-rules.sh
RemainAfterExit=yes
[Install]
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
fi
# If the server is behind a NAT, use the correct IP address for the clients to connect to
if [[ $ENDPOINT != "" ]]; then
IP=$ENDPOINT
fi
# client-template.txt is created so we have a template to add further users later
log_info "Creating client template..."
echo "client" >/etc/openvpn/server/client-template.txt
if [[ $PROTOCOL == 'udp' ]]; then
echo "proto udp" >>/etc/openvpn/server/client-template.txt
echo "explicit-exit-notify" >>/etc/openvpn/server/client-template.txt
elif [[ $PROTOCOL == 'udp6' ]]; then
echo "proto udp6" >>/etc/openvpn/server/client-template.txt
echo "explicit-exit-notify" >>/etc/openvpn/server/client-template.txt
elif [[ $PROTOCOL == 'tcp' ]]; then
echo "proto tcp-client" >>/etc/openvpn/server/client-template.txt
elif [[ $PROTOCOL == 'tcp6' ]]; then
echo "proto tcp6-client" >>/etc/openvpn/server/client-template.txt
fi
# Common client template options
# PKI mode adds remote-cert-tls and verify-x509-name
# Fingerprint mode adds peer-fingerprint when generating client config
{
echo "remote $IP $PORT
dev tun
resolv-retry infinite
nobind
persist-key
persist-tun"
[[ $AUTH_MODE == "pki" ]] && echo "remote-cert-tls server
verify-x509-name $SERVER_NAME name"
echo "auth $HMAC_ALG
auth-nocache
cipher $CIPHER
ignore-unknown-option data-ciphers
data-ciphers $CIPHER
ncp-ciphers $CIPHER
tls-client
tls-version-min $TLS_VERSION_MIN
tls-cipher $CC_CIPHER
tls-ciphersuites $TLS13_CIPHERSUITES
ignore-unknown-option block-outside-dns
setenv opt block-outside-dns # Prevent Windows 10 DNS leak
verb 3"
} >>/etc/openvpn/server/client-template.txt
if [[ -n $MTU ]]; then
echo "tun-mtu $MTU" >>/etc/openvpn/server/client-template.txt
fi
# Generate the custom client.ovpn
if [[ $NEW_CLIENT == "n" ]]; then
if [[ $AUTH_MODE == "fingerprint" ]]; then
log_info "No clients added. OpenVPN will not start until you add at least one client."
else
log_info "No clients added. To add clients, simply run the script again."
fi
else
log_info "Generating first client certificate..."
newClient
# In fingerprint mode, start service now that we have at least one fingerprint
if [[ $AUTH_MODE == "fingerprint" ]]; then
run_cmd "Starting OpenVPN service" systemctl restart openvpn-server@server
fi
log_success "If you want to add more clients, you simply need to run this script another time!"
fi
}
# Helper function to get the home directory for storing client configs
function getHomeDir() {
local client="$1"
if [ -d "/home/${client}" ]; then
echo "/home/${client}"
elif [ "${SUDO_USER}" ]; then
if [ "${SUDO_USER}" == "root" ]; then
echo "/root"
else
echo "/home/${SUDO_USER}"
fi
else
echo "/root"
fi
}
# Helper function to get the owner of a client config file (if client matches a system user)
function getClientOwner() {
local client="$1"
# Check if client name corresponds to an existing system user with a home directory
if id "$client" &>/dev/null && [ -d "/home/${client}" ]; then
echo "${client}"
elif [ "${SUDO_USER}" ] && [ "${SUDO_USER}" != "root" ]; then
echo "${SUDO_USER}"
fi
}
# Helper function to set proper ownership and permissions on client config file
function setClientConfigPermissions() {
local filepath="$1"
local owner="$2"
if [[ -n "$owner" ]]; then
local owner_group
owner_group=$(id -gn "$owner")
chmod go-rw "$filepath"
chown "$owner:$owner_group" "$filepath"
fi
}
# Helper function to write client config file with proper path and permissions
# Usage: writeClientConfig <client_name>
# Uses CLIENT_FILEPATH env var if set, otherwise defaults to home directory
# Side effects: sets GENERATED_CONFIG_PATH global variable with the final path
function writeClientConfig() {
local client="$1"
local clientFilePath
# Determine output file path
if [[ -n "$CLIENT_FILEPATH" ]]; then
clientFilePath="$CLIENT_FILEPATH"
# Ensure parent directory exists for custom paths
local parentDir
parentDir=$(dirname "$clientFilePath")
if [[ ! -d "$parentDir" ]]; then
run_cmd_fatal "Creating directory $parentDir" mkdir -p "$parentDir"
fi
else
local homeDir
homeDir=$(getHomeDir "$client")
clientFilePath="$homeDir/$client.ovpn"
fi
# Generate the .ovpn config file
generateClientConfig "$client" "$clientFilePath"
# Set proper ownership and permissions if client matches a system user
local clientOwner
clientOwner=$(getClientOwner "$client")
setClientConfigPermissions "$clientFilePath" "$clientOwner"
# Export path for caller to use
GENERATED_CONFIG_PATH="$clientFilePath"
}
# Helper function to regenerate the CRL after certificate changes
function regenerateCRL() {
export EASYRSA_CRL_DAYS=$DEFAULT_CRL_VALIDITY_DURATION_DAYS
run_cmd_fatal "Regenerating CRL" ./easyrsa gen-crl
run_cmd "Removing old CRL" rm -f /etc/openvpn/server/crl.pem
run_cmd_fatal "Copying new CRL" cp /etc/openvpn/server/easy-rsa/pki/crl.pem /etc/openvpn/server/crl.pem
run_cmd "Setting CRL permissions" chmod 644 /etc/openvpn/server/crl.pem
}
# Helper function to generate .ovpn client config file
# Usage: generateClientConfig <client_name> <filepath>
function generateClientConfig() {
local client="$1"
local filepath="$2"
# Read auth mode
local auth_mode="pki"
if [[ -f /etc/openvpn/server/easy-rsa/AUTH_MODE_GENERATED ]]; then
auth_mode=$(cat /etc/openvpn/server/easy-rsa/AUTH_MODE_GENERATED)
fi
# Determine if we use tls-crypt-v2, tls-crypt, or tls-auth
local tls_sig=""
if grep -qs "^tls-crypt-v2" /etc/openvpn/server/server.conf; then
tls_sig="1"
elif grep -qs "^tls-crypt" /etc/openvpn/server/server.conf; then
tls_sig="2"
elif grep -qs "^tls-auth" /etc/openvpn/server/server.conf; then
tls_sig="3"
fi
# Generate the custom client.ovpn
run_cmd "Creating client config" cp /etc/openvpn/server/client-template.txt "$filepath"
{
if [[ $auth_mode == "pki" ]]; then
# PKI mode: include CA certificate
echo "<ca>"
cat "/etc/openvpn/server/easy-rsa/pki/ca.crt"
echo "</ca>"
else
# Fingerprint mode: use server fingerprint instead of CA
local server_fingerprint
if [[ ! -f /etc/openvpn/server/server-fingerprint ]]; then
log_error "Server fingerprint file not found"
exit 1
fi
server_fingerprint=$(cat /etc/openvpn/server/server-fingerprint)
if [[ -z $server_fingerprint ]]; then
log_error "Server fingerprint is empty"
exit 1
fi
echo "peer-fingerprint $server_fingerprint"
fi
echo "<cert>"
awk '/BEGIN/,/END CERTIFICATE/' "/etc/openvpn/server/easy-rsa/pki/issued/$client.crt"
echo "</cert>"
echo "<key>"
cat "/etc/openvpn/server/easy-rsa/pki/private/$client.key"
echo "</key>"
case $tls_sig in
1)
# Generate per-client tls-crypt-v2 key in /etc/openvpn/server/
# Using /tmp would fail on Ubuntu 25.04+ due to AppArmor restrictions
tls_crypt_v2_tmpfile=$(mktemp /etc/openvpn/server/tls-crypt-v2-client.XXXXXX)
if [[ -z "$tls_crypt_v2_tmpfile" ]] || [[ ! -f "$tls_crypt_v2_tmpfile" ]]; then
log_error "Failed to create temporary file for tls-crypt-v2 client key"
exit 1
fi
if ! openvpn --tls-crypt-v2 /etc/openvpn/server/tls-crypt-v2.key \
--genkey tls-crypt-v2-client "$tls_crypt_v2_tmpfile"; then
rm -f "$tls_crypt_v2_tmpfile"
log_error "Failed to generate tls-crypt-v2 client key"
exit 1
fi
echo "<tls-crypt-v2>"
cat "$tls_crypt_v2_tmpfile"
echo "</tls-crypt-v2>"
rm -f "$tls_crypt_v2_tmpfile"
;;
2)
echo "<tls-crypt>"
cat /etc/openvpn/server/tls-crypt.key
echo "</tls-crypt>"
;;
3)
echo "key-direction 1"
echo "<tls-auth>"
cat /etc/openvpn/server/tls-auth.key
echo "</tls-auth>"
;;
esac
} >>"$filepath"
}
# Helper function to get the current auth mode
# Returns: "pki" or "fingerprint"
function getAuthMode() {
if [[ -f /etc/openvpn/server/easy-rsa/AUTH_MODE_GENERATED ]]; then
cat /etc/openvpn/server/easy-rsa/AUTH_MODE_GENERATED
else
echo "pki"
fi
}
# Helper function to get valid client names from server.conf fingerprint block
# In fingerprint mode, clients are tracked via comments in the <peer-fingerprint> block
# Format in server.conf:
# <peer-fingerprint>
# # client_name
# SHA256:fingerprint
# </peer-fingerprint>
# Returns: newline-separated list of client names
function getClientsFromFingerprints() {
local server_conf="/etc/openvpn/server/server.conf"
if [[ ! -f "$server_conf" ]]; then
return
fi
# Extract client names from comments in peer-fingerprint block
# Comments are in format "# client_name" on lines before fingerprints
sed -n '/<peer-fingerprint>/,/<\/peer-fingerprint>/p' "$server_conf" | grep "^# " | sed 's/^# //'
}
# Helper function to check if a client exists in fingerprint mode
# Arguments: client_name
# Returns: 0 if exists, 1 if not
function clientExistsInFingerprints() {
local client_name="$1"
getClientsFromFingerprints | grep -qx "$client_name"
}
# Helper function to get certificate expiry info
# Arguments: cert_file_path
# Outputs: expiry_date|days_remaining (pipe-separated)
function getCertExpiry() {
local cert_file="$1"
local expiry_date="unknown"
local days_remaining="null"
if [[ -f "$cert_file" ]]; then
local enddate
enddate=$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2)
if [[ -n "$enddate" ]]; then
local expiry_epoch
expiry_epoch=$(date -d "$enddate" +%s 2>/dev/null || date -j -f "%b %d %H:%M:%S %Y %Z" "$enddate" +%s 2>/dev/null)
if [[ -n "$expiry_epoch" ]]; then
expiry_date=$(date -d "@$expiry_epoch" +%Y-%m-%d 2>/dev/null || date -r "$expiry_epoch" +%Y-%m-%d 2>/dev/null)
local now_epoch
now_epoch=$(date +%s)
days_remaining=$(((expiry_epoch - now_epoch) / 86400))
fi
fi
fi
echo "$expiry_date|$days_remaining"
}
# Helper function to remove certificate files for regeneration
# Arguments: name (client or server name)
# Must be called from easy-rsa directory
function removeCertFiles() {
local name="$1"
rm -f "pki/issued/$name.crt" "pki/private/$name.key" "pki/reqs/$name.req"
}
# Helper function to extract SHA256 fingerprint from certificate
# Arguments: cert_file_path
# Outputs: fingerprint string or empty on failure
function extractFingerprint() {
local cert_file="$1"
openssl x509 -in "$cert_file" -fingerprint -sha256 -noout 2>/dev/null | cut -d'=' -f2
}
# Helper function to list valid clients and select one
# Arguments: show_expiry (optional, "true" to show expiry info)
# Sets global variables:
# CLIENT - the selected client name
# CLIENTNUMBER - the selected client number (1-based index)
# NUMBEROFCLIENTS - total count of valid clients
function selectClient() {
local show_expiry="${1:-false}"
local client_number
local auth_mode
local clients_list
auth_mode=$(getAuthMode)
# Get list of valid clients based on auth mode
if [[ $auth_mode == "fingerprint" ]]; then
# Fingerprint mode: get clients from server.conf peer-fingerprint block
clients_list=$(getClientsFromFingerprints)
NUMBEROFCLIENTS=$(echo "$clients_list" | grep -c . || echo 0)
else
# PKI mode: get valid clients from index.txt
clients_list=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt 2>/dev/null | grep "^V" | cut -d '=' -f 2)
NUMBEROFCLIENTS=$(echo "$clients_list" | grep -c . || echo 0)
fi
if [[ $NUMBEROFCLIENTS == '0' ]]; then
log_fatal "You have no existing clients!"
fi
# If CLIENT is set, validate it exists as a valid client
if [[ -n $CLIENT ]]; then
if echo "$clients_list" | grep -qx "$CLIENT"; then
return
else
log_fatal "Client '$CLIENT' not found or not valid"
fi
fi
# Display client list
if [[ $show_expiry == "true" ]]; then
local i=1
while read -r client; do
local client_cert="/etc/openvpn/server/easy-rsa/pki/issued/$client.crt"
local days
days=$(getDaysUntilExpiry "$client_cert")
local expiry
expiry=$(formatExpiry "$days")
echo " $i) $client $expiry"
((i++))
done <<<"$clients_list"
else
echo "$clients_list" | nl -s ') '
fi
# Prompt for selection
until [[ ${CLIENTNUMBER:-$client_number} -ge 1 && ${CLIENTNUMBER:-$client_number} -le $NUMBEROFCLIENTS ]]; do
if [[ $NUMBEROFCLIENTS == '1' ]]; then
read -rp "Select one client [1]: " client_number
else
read -rp "Select one client [1-$NUMBEROFCLIENTS]: " client_number
fi
done
CLIENTNUMBER="${CLIENTNUMBER:-$client_number}"
CLIENT=$(echo "$clients_list" | sed -n "${CLIENTNUMBER}p")
}
# Escape a string for JSON output
function json_escape() {
local str="$1"
# Escape backslashes first, then quotes, then control characters
str="${str//\\/\\\\}"
str="${str//\"/\\\"}"
str="${str//$'\n'/\\n}"
str="${str//$'\r'/\\r}"
str="${str//$'\t'/\\t}"
printf '%s' "$str"
}
function listClients() {
local index_file="/etc/openvpn/server/easy-rsa/pki/index.txt"
local cert_dir="/etc/openvpn/server/easy-rsa/pki/issued"
local number_of_clients
local format="${OUTPUT_FORMAT:-table}"
local auth_mode
auth_mode=$(getAuthMode)
# Collect client data based on auth mode
local clients_data=()
if [[ $auth_mode == "fingerprint" ]]; then
# Fingerprint mode: get clients from certificates in pki/issued/
# Valid clients have their fingerprint in server.conf, revoked ones don't
local valid_clients
valid_clients=$(getClientsFromFingerprints)
# Get all client certificates (exclude server certs)
local all_clients=()
for cert_file in "$cert_dir"/*.crt; do
[[ ! -f "$cert_file" ]] && continue
local client_name
client_name=$(basename "$cert_file" .crt)
# Skip server certificates and backup files
[[ "$client_name" == server_* ]] && continue
[[ "$client_name" == *.bak ]] && continue
all_clients+=("$client_name")
done
number_of_clients=${#all_clients[@]}
if [[ $number_of_clients == '0' ]]; then
if [[ $format == "json" ]]; then
echo '{"clients":[]}'
else
log_warn "You have no existing client certificates!"
fi
return
fi
for client_name in "${all_clients[@]}"; do
[[ -z "$client_name" ]] && continue
local status_text
# Check if client is in the valid fingerprints list
if echo "$valid_clients" | grep -qx "$client_name"; then
status_text="valid"
else
status_text="revoked"
fi
local expiry_info
expiry_info=$(getCertExpiry "$cert_dir/$client_name.crt")
clients_data+=("$client_name|$status_text|$expiry_info")
done
else
# PKI mode: get clients from index.txt
# Exclude server certificates (CN starting with server_)
number_of_clients=$(tail -n +2 "$index_file" 2>/dev/null | grep "^[VR]" | grep -cv "/CN=server_" || echo 0)
if [[ $number_of_clients == '0' ]]; then
if [[ $format == "json" ]]; then
echo '{"clients":[]}'
else
log_warn "You have no existing client certificates!"
fi
return
fi
while read -r line; do
local status="${line:0:1}"
local client_name
client_name=$(echo "$line" | sed 's/.*\/CN=//')
local status_text
if [[ "$status" == "V" ]]; then
status_text="valid"
elif [[ "$status" == "R" ]]; then
status_text="revoked"
else
status_text="unknown"
fi
local expiry_info
expiry_info=$(getCertExpiry "$cert_dir/$client_name.crt")
clients_data+=("$client_name|$status_text|$expiry_info")
done < <(tail -n +2 "$index_file" | grep "^[VR]" | grep -v "/CN=server_" | sort -t$'\t' -k2)
fi
if [[ $format == "json" ]]; then
# Output JSON
echo '{"clients":['
local first=true
for client_entry in "${clients_data[@]}"; do
IFS='|' read -r name status expiry days <<<"$client_entry"
[[ $first == true ]] && first=false || printf ','
# Handle null for days_remaining (no quotes for JSON null)
local days_json
if [[ "$days" == "null" || -z "$days" ]]; then
days_json="null"
else
days_json="$days"
fi
printf '{"name":"%s","status":"%s","expiry":"%s","days_remaining":%s}\n' \
"$(json_escape "$name")" "$(json_escape "$status")" "$(json_escape "$expiry")" "$days_json"
done
echo ']}'
else
# Output table
log_header "Client Certificates"
log_info "Found $number_of_clients client certificate(s)"
log_menu ""
printf " %-25s %-10s %-12s %s\n" "Name" "Status" "Expiry" "Remaining"
printf " %-25s %-10s %-12s %s\n" "----" "------" "------" "---------"
for client_entry in "${clients_data[@]}"; do
IFS='|' read -r name status expiry days <<<"$client_entry"
local relative
if [[ $days == "null" ]]; then
relative="unknown"
elif [[ $days -lt 0 ]]; then
relative="$((-days)) days ago"
elif [[ $days -eq 0 ]]; then
relative="today"
elif [[ $days -eq 1 ]]; then
relative="1 day"
else
relative="$days days"
fi
# Capitalize status for table display
local status_display="${status^}"
printf " %-25s %-10s %-12s %s\n" "$name" "$status_display" "$expiry" "$relative"
done
log_menu ""
fi
}
function formatBytes() {
local bytes=$1
# Validate input is numeric
if ! [[ "$bytes" =~ ^[0-9]+$ ]]; then
echo "N/A"
return
fi
if [[ $bytes -ge 1073741824 ]]; then
awk "BEGIN {printf \"%.1fG\", $bytes/1073741824}"
elif [[ $bytes -ge 1048576 ]]; then
awk "BEGIN {printf \"%.1fM\", $bytes/1048576}"
elif [[ $bytes -ge 1024 ]]; then
awk "BEGIN {printf \"%.1fK\", $bytes/1024}"
else
echo "${bytes}B"
fi
}
function listConnectedClients() {
local status_file="/var/log/openvpn/status.log"
local format="${OUTPUT_FORMAT:-table}"
if [[ ! -f "$status_file" ]]; then
if [[ $format == "json" ]]; then
echo '{"error":"Status file not found","clients":[]}'
else
log_warn "Status file not found: $status_file"
log_info "Make sure OpenVPN is running."
fi
return
fi
local client_count
client_count=$(grep -c "^CLIENT_LIST" "$status_file" 2>/dev/null) || client_count=0
if [[ "$client_count" -eq 0 ]]; then
if [[ $format == "json" ]]; then
echo '{"clients":[]}'
else
log_header "Connected Clients"
log_info "No clients currently connected."
log_info "Note: Data refreshes every 60 seconds."
fi
return
fi
# Collect client data
local clients_data=()
while IFS=',' read -r _ name real_addr vpn_ip _ bytes_recv bytes_sent connected_since _; do
clients_data+=("$name|$real_addr|$vpn_ip|$bytes_recv|$bytes_sent|$connected_since")
done < <(grep "^CLIENT_LIST" "$status_file")
if [[ $format == "json" ]]; then
echo '{"clients":['
local first=true
for client_entry in "${clients_data[@]}"; do
IFS='|' read -r name real_addr vpn_ip bytes_recv bytes_sent connected_since <<<"$client_entry"
[[ $first == true ]] && first=false || printf ','
printf '{"name":"%s","real_address":"%s","vpn_ip":"%s","bytes_received":%s,"bytes_sent":%s,"connected_since":"%s"}\n' \
"$(json_escape "$name")" "$(json_escape "$real_addr")" "$(json_escape "$vpn_ip")" \
"${bytes_recv:-0}" "${bytes_sent:-0}" "$(json_escape "$connected_since")"
done
echo ']}'
else
log_header "Connected Clients"
log_info "Found $client_count connected client(s)"
log_menu ""
printf " %-20s %-22s %-16s %-20s %s\n" "Name" "Real Address" "VPN IP" "Connected Since" "Transfer"
printf " %-20s %-22s %-16s %-20s %s\n" "----" "------------" "------" "---------------" "--------"
for client_entry in "${clients_data[@]}"; do
IFS='|' read -r name real_addr vpn_ip bytes_recv bytes_sent connected_since <<<"$client_entry"
local recv_human sent_human
recv_human=$(formatBytes "$bytes_recv")
sent_human=$(formatBytes "$bytes_sent")
local transfer="↓${recv_human} ↑${sent_human}"
printf " %-20s %-22s %-16s %-20s %s\n" "$name" "$real_addr" "$vpn_ip" "$connected_since" "$transfer"
done
log_menu ""
log_info "Note: Data refreshes every 60 seconds."
fi
}
function newClient() {
log_header "New Client Setup"
# Only prompt for client name if not already set or invalid
if ! is_valid_client_name "$CLIENT"; then
log_prompt "Tell me a name for the client."
log_prompt "The name must consist of alphanumeric characters, underscores, or dashes (max $MAX_CLIENT_NAME_LENGTH characters)."
until is_valid_client_name "$CLIENT"; do
read -rp "Client name: " -e CLIENT
done
fi
# Only prompt for cert duration if not already set
if [[ -z $CLIENT_CERT_DURATION_DAYS ]] || ! [[ $CLIENT_CERT_DURATION_DAYS =~ ^[0-9]+$ ]] || [[ $CLIENT_CERT_DURATION_DAYS -lt 1 ]]; then
log_menu ""
log_prompt "How many days should the client certificate be valid for?"
until [[ $CLIENT_CERT_DURATION_DAYS =~ ^[0-9]+$ ]] && [[ $CLIENT_CERT_DURATION_DAYS -ge 1 ]]; do
read -rp "Certificate validity (days): " -e -i $DEFAULT_CERT_VALIDITY_DURATION_DAYS CLIENT_CERT_DURATION_DAYS
done
fi
# Only prompt for password if not already set
if ! [[ $PASS =~ ^[1-2]$ ]]; then
log_menu ""
log_prompt "Do you want to protect the configuration file with a password?"
log_prompt "(e.g. encrypt the private key with a password)"
log_menu " 1) Add a passwordless client"
log_menu " 2) Use a password for the client"
until [[ $PASS =~ ^[1-2]$ ]]; do
read -rp "Select an option [1-2]: " -e -i 1 PASS
done
fi
cd /etc/openvpn/server/easy-rsa/ || return
# Read auth mode
if [[ -f AUTH_MODE_GENERATED ]]; then
AUTH_MODE=$(cat AUTH_MODE_GENERATED)
else
AUTH_MODE="pki"
fi
# Check if client already exists
local CLIENTEXISTS=0
if [[ $AUTH_MODE == "fingerprint" ]]; then
# Fingerprint mode: check server.conf peer-fingerprint block
if clientExistsInFingerprints "$CLIENT"; then
CLIENTEXISTS=1
fi
else
# PKI mode: check index.txt
if [[ -f pki/index.txt ]]; then
CLIENTEXISTS=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep -E "^V" | grep -c -E "/CN=$CLIENT\$")
fi
fi
if [[ $CLIENTEXISTS != '0' ]]; then
log_error "The specified client CN was already found, please choose another name."
exit 1
fi
# In fingerprint mode, clean up any revoked cert files so we can reuse the name
if [[ $AUTH_MODE == "fingerprint" ]] && [[ -f "pki/issued/$CLIENT.crt" ]]; then
log_info "Removing old revoked certificate files for $CLIENT..."
removeCertFiles "$CLIENT"
fi
log_info "Generating client certificate..."
export EASYRSA_CERT_EXPIRE=$CLIENT_CERT_DURATION_DAYS
# Determine easyrsa command based on auth mode
local easyrsa_cmd cert_desc
if [[ $AUTH_MODE == "pki" ]]; then
easyrsa_cmd="build-client-full"
cert_desc="client certificate"
else
easyrsa_cmd="self-sign-client"
cert_desc="self-signed client certificate"
fi
case $PASS in
1)
run_cmd_fatal "Building $cert_desc" ./easyrsa --batch "$easyrsa_cmd" "$CLIENT" nopass
;;
2)
if [[ -z "$PASSPHRASE" ]]; then
log_warn "You will be asked for the client password below"
if ! ./easyrsa --batch "$easyrsa_cmd" "$CLIENT"; then
log_fatal "Building $cert_desc failed"
fi
else
log_info "Using provided passphrase for client certificate"
export EASYRSA_PASSPHRASE="$PASSPHRASE"
run_cmd_fatal "Building $cert_desc" ./easyrsa --batch --passin=env:EASYRSA_PASSPHRASE --passout=env:EASYRSA_PASSPHRASE "$easyrsa_cmd" "$CLIENT"
unset EASYRSA_PASSPHRASE
fi
;;
esac
# Fingerprint mode: register client fingerprint with server
if [[ $AUTH_MODE == "fingerprint" ]]; then
CLIENT_FINGERPRINT=$(openssl x509 -in "pki/issued/$CLIENT.crt" -fingerprint -sha256 -noout | cut -d'=' -f2)
if [[ -z $CLIENT_FINGERPRINT ]]; then
log_error "Failed to extract client certificate fingerprint"
exit 1
fi
log_info "Client fingerprint: $CLIENT_FINGERPRINT"
# Add fingerprint to server.conf's <peer-fingerprint> block
# Create the block if this is the first client
if ! grep -q '<peer-fingerprint>' /etc/openvpn/server/server.conf; then
echo "# Client fingerprints are listed below
<peer-fingerprint>
# $CLIENT
$CLIENT_FINGERPRINT
</peer-fingerprint>" >>/etc/openvpn/server/server.conf
else
# Insert comment and fingerprint before closing tag
sed -i "/<\/peer-fingerprint>/i # $CLIENT\n$CLIENT_FINGERPRINT" /etc/openvpn/server/server.conf
fi
# Reload OpenVPN to pick up new fingerprint
log_info "Reloading OpenVPN to apply new fingerprint..."
if systemctl is-active --quiet openvpn-server@server; then
systemctl reload openvpn-server@server 2>/dev/null || systemctl restart openvpn-server@server
fi
fi
log_success "Client $CLIENT added and is valid for $CLIENT_CERT_DURATION_DAYS days."
# Write the .ovpn config file with proper path and permissions
writeClientConfig "$CLIENT"
log_menu ""
log_success "The configuration file has been written to $GENERATED_CONFIG_PATH."
log_info "Download the .ovpn file and import it in your OpenVPN client."
}
function revokeClient() {
log_header "Revoke Client"
log_prompt "Select the existing client certificate you want to revoke"
selectClient
cd /etc/openvpn/server/easy-rsa/ || return
# Read auth mode
local auth_mode="pki"
if [[ -f AUTH_MODE_GENERATED ]]; then
auth_mode=$(cat AUTH_MODE_GENERATED)
fi
log_info "Revoking certificate for $CLIENT..."
if [[ $auth_mode == "pki" ]]; then
# PKI mode: use Easy-RSA revocation and CRL
run_cmd_fatal "Revoking certificate" ./easyrsa --batch revoke-issued "$CLIENT"
regenerateCRL
run_cmd "Backing up index" cp /etc/openvpn/server/easy-rsa/pki/index.txt{,.bk}
else
# Fingerprint mode: remove fingerprint from server.conf
# Keep cert files so revoked clients appear in client list
log_info "Removing client fingerprint from server configuration..."
# Remove comment line and fingerprint line below it from server.conf
sed -i "/^# $CLIENT\$/{N;d;}" /etc/openvpn/server/server.conf
# Reload OpenVPN to apply fingerprint removal
log_info "Reloading OpenVPN to apply fingerprint removal..."
if systemctl is-active --quiet openvpn-server@server; then
systemctl reload openvpn-server@server 2>/dev/null || systemctl restart openvpn-server@server
fi
fi
run_cmd "Removing client config from /home" find /home/ -maxdepth 2 -name "$CLIENT.ovpn" -delete
run_cmd "Removing client config from /root" rm -f "/root/$CLIENT.ovpn"
run_cmd "Removing IP assignment" sed -i "/^$CLIENT,.*/d" /etc/openvpn/server/ipp.txt
# Disconnect the client if currently connected
disconnectClient "$CLIENT"
log_success "Certificate for client $CLIENT revoked."
}
# Disconnect a client via the management interface
function disconnectClient() {
local client_name="$1"
local mgmt_socket="/var/run/openvpn-server/server.sock"
if [[ ! -S "$mgmt_socket" ]]; then
log_warn "Management socket not found. Client may still be connected until they reconnect."
return 0
fi
log_info "Disconnecting client $client_name..."
if echo "kill $client_name" | socat - UNIX-CONNECT:"$mgmt_socket" >/dev/null 2>&1; then
log_success "Client $client_name disconnected."
else
log_warn "Could not disconnect client (they may not be connected)."
fi
}
function renewClient() {
local client_cert_duration_days
local auth_mode
log_header "Renew Client Certificate"
log_prompt "Select the existing client certificate you want to renew"
selectClient "true"
# Allow user to specify renewal duration (use CLIENT_CERT_DURATION_DAYS env var for headless mode)
if [[ -z $CLIENT_CERT_DURATION_DAYS ]] || ! [[ $CLIENT_CERT_DURATION_DAYS =~ ^[0-9]+$ ]] || [[ $CLIENT_CERT_DURATION_DAYS -lt 1 ]]; then
log_menu ""
log_prompt "How many days should the renewed certificate be valid for?"
until [[ $client_cert_duration_days =~ ^[0-9]+$ ]] && [[ $client_cert_duration_days -ge 1 ]]; do
read -rp "Certificate validity (days): " -e -i $DEFAULT_CERT_VALIDITY_DURATION_DAYS client_cert_duration_days
done
else
client_cert_duration_days=$CLIENT_CERT_DURATION_DAYS
fi
cd /etc/openvpn/server/easy-rsa/ || retur
gitextract_d63ixilj/
├── .editorconfig
├── .github/
│ ├── FUNDING.yml
│ ├── issue_template.md
│ ├── linters/
│ │ └── .markdown-lint.yml
│ ├── pull_request_template.md
│ └── workflows/
│ ├── do-test.yml
│ ├── docker-test.yml
│ ├── lint.yml
│ └── update-easyrsa-hash.yml
├── .trivyignore
├── AGENTS.md
├── FAQ.md
├── LICENSE
├── Makefile
├── README.md
├── biome.json
├── docker-compose.yml
├── openvpn-install.sh
├── renovate.json
└── test/
├── Dockerfile.client
├── Dockerfile.server
├── client-entrypoint.sh
├── server-entrypoint.sh
└── validate-output.sh
Condensed preview — 24 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (300K chars).
[
{
"path": ".editorconfig",
"chars": 121,
"preview": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\n\n[*.sh]\nindent_style = tab\nindent_size = 4"
},
{
"path": ".github/FUNDING.yml",
"chars": 55,
"preview": "ko_fi: stanislas\ncustom: https://coindrop.to/stanislas\n"
},
{
"path": ".github/issue_template.md",
"chars": 780,
"preview": "<!---\n❗️ Please read ❗️\n➡️ If you need help with OpenVPN itself, please use the community forums (https://forums.openvpn"
},
{
"path": ".github/linters/.markdown-lint.yml",
"chars": 106,
"preview": "{\n \"MD013\": null,\n \"MD045\": null,\n \"MD040\": null,\n \"MD036\": null,\n \"MD041\": null,\n \"MD060\": null,\n}\n"
},
{
"path": ".github/pull_request_template.md",
"chars": 478,
"preview": "<!---\n❗️ Please read ❗️\n➡️ Please make sure you've followed the guidelines: https://github.com/angristan/openvpn-install"
},
{
"path": ".github/workflows/do-test.yml",
"chars": 4529,
"preview": "# DigitalOcean E2E tests (manual trigger only)\n# Primary CI testing is now done via Docker in docker-test.yml\n# This wor"
},
{
"path": ".github/workflows/docker-test.yml",
"chars": 11287,
"preview": "---\non:\n push:\n branches: [master]\n pull_request:\n workflow_dispatch:\n\nname: Docker Test\n\nconcurrency:\n group: ${"
},
{
"path": ".github/workflows/lint.yml",
"chars": 663,
"preview": "on:\n push:\n branches: [master]\n pull_request:\n workflow_dispatch:\n\nname: Lint\n\nconcurrency:\n group: ${{ github.wo"
},
{
"path": ".github/workflows/update-easyrsa-hash.yml",
"chars": 2692,
"preview": "name: Update Easy-RSA SHA256\n\n# Note: This workflow commits and pushes changes to openvpn-install.sh.\n# Uses PAT to trig"
},
{
"path": ".trivyignore",
"chars": 233,
"preview": "# Test containers require root for OpenVPN NET_ADMIN capability\nAVD-DS-0002\n\n# Test containers don't need healthcheck\nAV"
},
{
"path": "AGENTS.md",
"chars": 423,
"preview": "- Use gh CLI to interact with GitHub\n- Test locally using the Docker setup when needed\n- When doing changes, check if RE"
},
{
"path": "FAQ.md",
"chars": 8164,
"preview": "# FAQ\n\n**Q:** The script has been updated since I installed OpenVPN. How do I update?\n\n**A:** You can't. Managing update"
},
{
"path": "LICENSE",
"chars": 1107,
"preview": "MIT License\n\nCopyright (c) 2013 Nyr\nCopyright (c) 2016 Stanislas Lange (angristan)\n\nPermission is hereby granted, free o"
},
{
"path": "Makefile",
"chars": 3082,
"preview": ".PHONY: test test-build test-up test-down test-logs test-clean\n\n# Run the full test suite\ntest: test-build test-up\n\t@ech"
},
{
"path": "README.md",
"chars": 30011,
"preview": "# openvpn-install\n\n[](https://saythanks.io/to/angr"
},
{
"path": "biome.json",
"chars": 136,
"preview": "{\n \"$schema\": \"https://biomejs.dev/schemas/1.9.4/schema.json\",\n \"formatter\": {\n \"indentStyle\": \"space\",\n \"indent"
},
{
"path": "docker-compose.yml",
"chars": 1316,
"preview": "---\nservices:\n openvpn-server:\n build:\n context: .\n dockerfile: test/Dockerfile.server\n args:\n "
},
{
"path": "openvpn-install.sh",
"chars": 148356,
"preview": "#!/bin/bash\n# shellcheck disable=SC1091,SC2034\n# SC1091: Not following /etc/os-release (sourced dynamically)\n# SC2034: V"
},
{
"path": "renovate.json",
"chars": 540,
"preview": "{\n \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n \"extends\": [\"config:recommended\"],\n \"ignorePaths\""
},
{
"path": "test/Dockerfile.client",
"chars": 705,
"preview": "# checkov:skip=CKV_DOCKER_2:Test container doesn't need healthcheck\n# checkov:skip=CKV_DOCKER_3:OpenVPN client requires "
},
{
"path": "test/Dockerfile.server",
"chars": 4041,
"preview": "# checkov:skip=CKV_DOCKER_2:Test container doesn't need healthcheck\n# checkov:skip=CKV_DOCKER_3:OpenVPN server requires "
},
{
"path": "test/client-entrypoint.sh",
"chars": 13516,
"preview": "#!/bin/bash\nset -e\n\necho \"=== OpenVPN Client Container ===\"\n\n# Create TUN device if it doesn't exist\nif [ ! -c /dev/net/"
},
{
"path": "test/server-entrypoint.sh",
"chars": 41632,
"preview": "#!/bin/bash\nset -e\n\necho \"=== OpenVPN Server Container ===\"\n\n# Create TUN device if it doesn't exist\nif [ ! -c /dev/net/"
},
{
"path": "test/validate-output.sh",
"chars": 2384,
"preview": "#!/bin/bash\n# Validates that script output only contains properly formatted log messages\n# All output from openvpn-insta"
}
]
About this extraction
This page contains the full source code of the angristan/openvpn-install GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 24 files (269.9 KB), approximately 80.2k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.