Full Code of angristan/openvpn-install for AI

master 63448d542def cached
24 files
269.9 KB
80.2k tokens
1 requests
Download .txt
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

[![Say Thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](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

[![Star History Chart](https://api.star-history.com/svg?repos=angristan/openvpn-install&type=Date)](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
Download .txt
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[![Say Thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](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.

Copied to clipboard!