[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\n\n[*.sh]\nindent_style = tab\nindent_size = 4\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "ko_fi: stanislas\ncustom: https://coindrop.to/stanislas\n"
  },
  {
    "path": ".github/issue_template.md",
    "content": "<!---\n❗️ Please read ❗️\n➡️ 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)\n➡️ For the script, prefer opening a discussion thread for help: https://github.com/angristan/openvpn-install/discussions\n💡 It helps keep the issue tracker clean and focused on bugs and feature requests.\n\n🙏 Please include as much information as possible, and make sure you're running the latest version of the script.\n✍️ Please state the Linux distribution you're using and its version, as well as the OpenVPN version.\n✋ 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.\n--->\n"
  },
  {
    "path": ".github/linters/.markdown-lint.yml",
    "content": "{\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",
    "content": "<!---\n❗️ Please read ❗️\n➡️ Please make sure you've followed the guidelines: https://github.com/angristan/openvpn-install#contributing\n✅ Please make sure your changes are tested and working\n🗣️ Please avoid large PRs, and discuss changes in a GitHub issue first\n✋ 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.\n--->\n"
  },
  {
    "path": ".github/workflows/do-test.yml",
    "content": "# DigitalOcean E2E tests (manual trigger only)\n# Primary CI testing is now done via Docker in docker-test.yml\n# This workflow is kept for real-world VM testing when needed\non:\n  workflow_dispatch:\n\nname: Test\n\npermissions:\n  contents: read\n\njobs:\n  install:\n    runs-on: ubuntu-latest\n    if: github.repository == 'angristan/openvpn-install' && github.actor == 'angristan'\n    strategy:\n      matrix:\n        os-image:\n          - debian-12-x64\n          - debian-13-x64\n          - ubuntu-22-04-x64\n          - ubuntu-24-04-x64\n          - fedora-42-x64\n          # - centos-stream-9-x64 # yum oomkill\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          persist-credentials: false\n\n      - name: Setup doctl\n        uses: digitalocean/action-doctl@135ac0aa0eed4437d547c6f12c364d3006b42824 # v2.5.1\n        with:\n          token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}\n\n      - name: Create server\n        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\n\n      - name: Get server ID\n        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\"\n        id: server_id\n\n      - name: Move server to dedicated project\n        run: doctl projects resources assign \"$DIGITALOCEAN_PROJECT_ID\" --resource=do:droplet:\"$SERVER_ID\"\n        env:\n          DIGITALOCEAN_PROJECT_ID: ${{ secrets.DIGITALOCEAN_PROJECT_ID }}\n          SERVER_ID: ${{ steps.server_id.outputs.value }}\n\n      - name: Wait for server to boot\n        run: sleep 90\n\n      - name: Get server IP\n        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\"\n        id: server_ip\n\n      - name: Get server OS\n        run: echo \"value=$(echo \"${{ matrix.os-image }}\" | cut -d '-' -f1)\" >> \"$GITHUB_OUTPUT\"\n        id: server_os\n\n      - name: Setup remote server (Debian/Ubuntu)\n        if: steps.server_os.outputs.value == 'debian' || steps.server_os.outputs.value == 'ubuntu'\n        uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0\n        with:\n          host: ${{ steps.server_ip.outputs.value }}\n          username: root\n          key: ${{ secrets.SSH_KEY }}\n          script: set -x && apt-get update && apt-get -o DPkg::Lock::Timeout=120 install -y git\n\n      - name: Setup remote server (Fedora)\n        if: steps.server_os.outputs.value == 'fedora'\n        uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0\n        with:\n          host: ${{ steps.server_ip.outputs.value }}\n          username: root\n          key: ${{ secrets.SSH_KEY }}\n          script: set -x && dnf install -y git\n\n      - name: Setup remote server (CentOS)\n        if: steps.server_os.outputs.value == 'centos'\n        uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0\n        with:\n          host: ${{ steps.server_ip.outputs.value }}\n          username: root\n          key: ${{ secrets.SSH_KEY }}\n          script: set -x && yum install -y git\n\n      - name: Download repo and checkout current commit\n        uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0\n        with:\n          host: ${{ steps.server_ip.outputs.value }}\n          username: root\n          key: ${{ secrets.SSH_KEY }}\n          script: set -x && git clone https://github.com/angristan/openvpn-install.git && cd openvpn-install && git checkout ${{ github.sha }}\n\n      - name: Run openvpn-install.sh in headless mode\n        uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0\n        with:\n          host: ${{ steps.server_ip.outputs.value }}\n          username: root\n          key: ${{ secrets.SSH_KEY }}\n          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'\n\n      - name: Delete server\n        run: doctl compute droplet delete -f \"openvpn-action-${GITHUB_RUN_ID}-${GITHUB_RUN_NUMBER}-${{ matrix.os-image }}\"\n        if: always()\n"
  },
  {
    "path": ".github/workflows/docker-test.yml",
    "content": "---\non:\n  push:\n    branches: [master]\n  pull_request:\n  workflow_dispatch:\n\nname: Docker Test\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: ${{ github.event_name == 'pull_request' }}\n\npermissions:\n  contents: read\n\njobs:\n  docker-test:\n    runs-on: ubuntu-latest\n    timeout-minutes: 20\n    strategy:\n      fail-fast: false\n      matrix:\n        os:\n          - name: ubuntu-18.04\n            image: ubuntu:18.04\n          - name: ubuntu-20.04\n            image: ubuntu:20.04\n          - name: ubuntu-22.04\n            image: ubuntu:22.04\n          - name: ubuntu-24.04\n            image: ubuntu:24.04\n          - name: ubuntu-25.10\n            image: ubuntu:25.10\n          - name: debian-11\n            image: debian:11\n          - name: debian-12\n            image: debian:12\n          - name: centos-stream-9\n            image: quay.io/centos/centos:stream9\n          - name: centos-stream-10\n            image: quay.io/centos/centos:stream10\n          - name: fedora-42\n            image: fedora:42\n          - name: fedora-43\n            image: fedora:43\n          - name: rocky-8\n            image: rockylinux/rockylinux:8\n          - name: rocky-9\n            image: rockylinux/rockylinux:9\n          - name: rocky-10\n            image: rockylinux/rockylinux:10\n          - name: almalinux-8\n            image: almalinux:8\n          - name: almalinux-9\n            image: almalinux:9\n          - name: almalinux-10\n            image: almalinux:10\n          - name: archlinux\n            image: archlinux:latest\n          - name: opensuse-leap-16.0\n            image: opensuse/leap:16.0\n          - name: opensuse-tumbleweed\n            image: opensuse/tumbleweed\n          - name: oraclelinux-8\n            image: oraclelinux:8\n          - name: oraclelinux-9\n            image: oraclelinux:9\n          - name: oraclelinux-10\n            image: oraclelinux:10\n          - name: amazonlinux-2023\n            image: amazonlinux:2023\n        # Default TLS settings (tls-crypt-v2)\n        tls:\n          - name: tls-crypt-v2\n            sig: crypt-v2\n            key_file: tls-crypt-v2.key\n        # Additional TLS types tested on Ubuntu 24.04 only\n        include:\n          - os:\n              name: ubuntu-24.04-tls-crypt\n              image: ubuntu:24.04\n            tls:\n              name: tls-crypt\n              sig: crypt\n              key_file: tls-crypt.key\n          - os:\n              name: ubuntu-24.04-tls-auth\n              image: ubuntu:24.04\n            tls:\n              name: tls-auth\n              sig: auth\n              key_file: tls-auth.key\n          # Test firewalld support on Fedora\n          - os:\n              name: fedora-42-firewalld\n              image: fedora:42\n              enable_firewalld: true\n            tls:\n              name: tls-crypt-v2\n              sig: crypt-v2\n              key_file: tls-crypt-v2.key\n          # Test nftables support on Debian\n          - os:\n              name: debian-12-nftables\n              image: debian:12\n              enable_nftables: true\n            tls:\n              name: tls-crypt-v2\n              sig: crypt-v2\n              key_file: tls-crypt-v2.key\n          # Test IPv6 dual-stack support on Ubuntu\n          - os:\n              name: ubuntu-24.04-dual-stack\n              image: ubuntu:24.04\n              client_ipv6: true\n            tls:\n              name: tls-crypt-v2\n              sig: crypt-v2\n              key_file: tls-crypt-v2.key\n          # Test peer-fingerprint authentication mode (OpenVPN 2.6+)\n          - os:\n              name: ubuntu-24.04-fingerprint\n              image: ubuntu:24.04\n              auth_mode: fingerprint\n            tls:\n              name: tls-crypt-v2\n              sig: crypt-v2\n              key_file: tls-crypt-v2.key\n\n    name: ${{ matrix.os.name }}\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          persist-credentials: false\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0\n\n      - name: Build server image\n        run: |\n          docker build \\\n            --build-arg BASE_IMAGE=${{ matrix.os.image }} \\\n            --build-arg ENABLE_FIREWALLD=${{ matrix.os.enable_firewalld && 'y' || 'n' }} \\\n            --build-arg ENABLE_NFTABLES=${{ matrix.os.enable_nftables && 'y' || 'n' }} \\\n            -t openvpn-server \\\n            -f test/Dockerfile.server .\n\n      - name: Build client image\n        run: docker build -t openvpn-client -f test/Dockerfile.client .\n\n      - name: Create Docker network\n        run: docker network create --subnet=172.28.0.0/24 vpn-test\n\n      - name: Create shared volume\n        run: docker volume create shared-config\n\n      - name: Start OpenVPN server\n        run: |\n          docker run -d \\\n            --name openvpn-server \\\n            --hostname openvpn-server \\\n            --privileged \\\n            --cgroupns=host \\\n            --device=/dev/net/tun:/dev/net/tun \\\n            --sysctl net.ipv4.ip_forward=1 \\\n            --sysctl net.ipv6.conf.all.forwarding=1 \\\n            --network vpn-test \\\n            --ip 172.28.0.10 \\\n            -v shared-config:/shared \\\n            -v /sys/fs/cgroup:/sys/fs/cgroup:rw \\\n            --tmpfs /run \\\n            --tmpfs /run/lock \\\n            --stop-signal SIGRTMIN+3 \\\n            -e TLS_SIG=${{ matrix.tls.sig }} \\\n            -e TLS_KEY_FILE=${{ matrix.tls.key_file }} \\\n            -e CLIENT_IPV6=${{ matrix.os.client_ipv6 && 'y' || 'n' }} \\\n            -e AUTH_MODE=${{ matrix.os.auth_mode || 'pki' }} \\\n            openvpn-server\n\n      - name: Wait for server installation and startup\n        run: |\n          echo \"Waiting for OpenVPN server to install and client config to be ready...\"\n          for i in {1..90}; do\n            # Get service status (properly handle non-zero exit codes)\n            # systemctl is-active returns exit code 3 for \"inactive\"/\"failed\", so capture output without checking exit code\n            SERVICE_STATUS=\"$(docker exec openvpn-server systemctl is-active openvpn-test.service 2>/dev/null)\" || true\n            [ -z \"$SERVICE_STATUS\" ] && SERVICE_STATUS=\"unknown\"\n\n            # Fail fast if service failed\n            if [ \"$SERVICE_STATUS\" = \"failed\" ]; then\n              echo \"ERROR: openvpn-test.service failed during installation\"\n              docker exec openvpn-server systemctl status openvpn-test.service 2>&1 || true\n              docker exec openvpn-server journalctl -u openvpn-test.service --no-pager -n 100 2>&1 || true\n              exit 1\n            fi\n\n            # Check if OpenVPN server is running and client config exists\n            # The service will be \"activating\" while waiting for client tests - that's expected\n            OPENVPN_RUNNING=false\n            CONFIG_EXISTS=false\n\n            if docker exec openvpn-server pgrep -f \"openvpn.*server.conf\" > /dev/null 2>&1; then\n              OPENVPN_RUNNING=true\n            fi\n\n            if docker exec openvpn-server test -f /shared/client.ovpn 2>/dev/null; then\n              CONFIG_EXISTS=true\n            fi\n\n            if [ \"$OPENVPN_RUNNING\" = true ] && [ \"$CONFIG_EXISTS\" = true ]; then\n              echo \"OpenVPN server is running and client config is ready!\"\n              break\n            fi\n\n            echo \"Waiting... ($i/90) - Service: $SERVICE_STATUS, OpenVPN running: $OPENVPN_RUNNING, Config exists: $CONFIG_EXISTS\"\n            sleep 5\n          done\n\n          # Final verification with retry (handles race condition during cert renewal restart)\n          OPENVPN_STARTED=false\n          for retry in {1..5}; do\n            if docker exec openvpn-server pgrep -f \"openvpn.*server.conf\" > /dev/null 2>&1; then\n              OPENVPN_STARTED=true\n              break\n            fi\n            echo \"Waiting for OpenVPN process... (retry $retry/5)\"\n            sleep 2\n          done\n\n          if [ \"$OPENVPN_STARTED\" = false ]; then\n            echo \"ERROR: OpenVPN server failed to start\"\n            docker exec openvpn-server systemctl status openvpn-server@server 2>&1 || true\n            docker exec openvpn-server journalctl -u openvpn-test.service --no-pager -n 100 2>&1 || true\n            exit 1\n          fi\n\n          if ! docker exec openvpn-server test -f /shared/client.ovpn 2>/dev/null; then\n            echo \"ERROR: Client config not generated\"\n            docker exec openvpn-server journalctl -u openvpn-test.service --no-pager -n 100 2>&1 || true\n            exit 1\n          fi\n\n          echo \"Server ready for client connection!\"\n\n      - name: Verify client config was generated\n        run: |\n          docker run --rm -v shared-config:/shared alpine \\\n            ls -la /shared/\n          docker run --rm -v shared-config:/shared alpine \\\n            cat /shared/client.ovpn\n\n      - name: Start OpenVPN client and run tests\n        run: |\n          docker run \\\n            --name openvpn-client \\\n            --hostname openvpn-client \\\n            --cap-add=NET_ADMIN \\\n            --device=/dev/net/tun:/dev/net/tun \\\n            --network vpn-test \\\n            --ip 172.28.0.20 \\\n            -v shared-config:/shared \\\n            openvpn-client &\n\n          # Wait for tests to complete (look for success message)\n          # Extended timeout for revocation e2e tests\n          for i in {1..180}; do\n            if docker logs openvpn-client 2>&1 | grep -q \"ALL TESTS PASSED\"\n            then\n              echo \"Tests passed!\"\n              exit 0\n            fi\n            if docker logs openvpn-client 2>&1 | grep -q \"FAIL:\"; then\n              echo \"Tests failed!\"\n              docker logs openvpn-client\n              exit 1\n            fi\n            echo \"Waiting for tests... ($i/180)\"\n            sleep 2\n          done\n\n          echo \"Timeout waiting for tests\"\n          docker logs openvpn-client\n          exit 1\n\n      - name: Show server logs\n        if: always()\n        run: docker logs openvpn-server 2>&1 || true\n\n      - name: Show systemd journal logs\n        if: always()\n        run: |\n          echo \"=== openvpn-test.service status ===\"\n          docker exec openvpn-server systemctl status openvpn-test.service 2>&1 || true\n          echo \"\"\n          echo \"=== openvpn-test.service journal ===\"\n          docker exec openvpn-server journalctl -u openvpn-test.service --no-pager -n 100 2>&1 || true\n          echo \"\"\n          echo \"=== openvpn-server@server.service journal ===\"\n          docker exec openvpn-server journalctl -u openvpn-server@server.service --no-pager -n 50 2>&1 || true\n\n      - name: Show install script log\n        if: always()\n        run: |\n          docker cp openvpn-server:/root/openvpn-install.log /tmp/openvpn-install.log 2>/dev/null && \\\n            cat /tmp/openvpn-install.log || echo \"No install log found\"\n\n      - name: Show client logs\n        if: always()\n        run: docker logs openvpn-client 2>&1 || true\n\n      - name: Cleanup\n        if: always()\n        run: |\n          docker stop openvpn-server openvpn-client 2>/dev/null || true\n          docker rm openvpn-server openvpn-client 2>/dev/null || true\n          docker network rm vpn-test 2>/dev/null || true\n          docker volume rm shared-config 2>/dev/null || true\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "on:\n  push:\n    branches: [master]\n  pull_request:\n  workflow_dispatch:\n\nname: Lint\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: ${{ github.event_name == 'pull_request' }}\n\npermissions:\n  contents: read\n\njobs:\n  super-linter:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout Code\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n          persist-credentials: false\n      - name: Lint Code Base\n        uses: super-linter/super-linter@d5b0a2ab116623730dd094f15ddc1b6b25bf7b99 # v8.3.2\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/update-easyrsa-hash.yml",
    "content": "name: Update Easy-RSA SHA256\n\n# Note: This workflow commits and pushes changes to openvpn-install.sh.\n# Uses PAT to trigger CI on the resulting commit. Infinite recursion is prevented\n# by the 'renovate/' branch prefix check - CI commits don't re-trigger this workflow.\n# Requires: Create a PAT with 'contents: write' scope and add as repository secret 'PAT'\n\non:\n  pull_request:\n    types: [opened, synchronize]\n    paths:\n      - \"openvpn-install.sh\"\n\npermissions:\n  contents: read\n\njobs:\n  update-hash:\n    if: startsWith(github.head_ref, 'renovate/')\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          ref: ${{ github.head_ref }}\n          token: ${{ secrets.PAT }}\n          persist-credentials: false\n\n      - name: Extract version and update SHA256\n        run: |\n          VERSION=$(grep -oP 'EASYRSA_VERSION=\"\\K[^\"]+' openvpn-install.sh)\n          if [ -z \"$VERSION\" ]; then\n            echo \"Error: Failed to extract EASYRSA_VERSION\"\n            exit 1\n          fi\n          echo \"Easy-RSA version: $VERSION\"\n\n          CURRENT_SHA=$(grep -oP 'EASYRSA_SHA256=\"\\K[^\"]+' openvpn-install.sh)\n          if [ -z \"$CURRENT_SHA\" ]; then\n            echo \"Error: Failed to extract EASYRSA_SHA256\"\n            exit 1\n          fi\n          echo \"Current SHA256: $CURRENT_SHA\"\n\n          TARBALL_URL=\"https://github.com/OpenVPN/easy-rsa/releases/download/v${VERSION}/EasyRSA-${VERSION}.tgz\"\n          if ! curl -fsSL \"$TARBALL_URL\" -o /tmp/easyrsa.tgz; then\n            echo \"Error: Failed to download Easy-RSA tarball from $TARBALL_URL\"\n            exit 1\n          fi\n          NEW_SHA=$(sha256sum /tmp/easyrsa.tgz | cut -d' ' -f1)\n          echo \"New SHA256: $NEW_SHA\"\n\n          if [ \"$CURRENT_SHA\" != \"$NEW_SHA\" ]; then\n            sed -i \"s|EASYRSA_SHA256=\\\"$CURRENT_SHA\\\"|EASYRSA_SHA256=\\\"$NEW_SHA\\\"|\" openvpn-install.sh\n            echo \"SHA256 updated\"\n            echo \"HASH_CHANGED=true\" >> \"$GITHUB_ENV\"\n          else\n            echo \"SHA256 already correct\"\n          fi\n\n      - name: Commit changes\n        if: env.HASH_CHANGED == 'true'\n        env:\n          PAT: ${{ secrets.PAT }}\n        run: |\n          if ! git diff --quiet openvpn-install.sh; then\n            git config user.name \"github-actions[bot]\"\n            git config user.email \"github-actions[bot]@users.noreply.github.com\"\n            git remote set-url origin \"https://x-access-token:${PAT}@github.com/${{ github.repository }}\"\n            git add openvpn-install.sh\n            git commit -m \"chore: update Easy-RSA SHA256 hash\"\n            git push\n          else\n            echo \"No changes to commit\"\n          fi\n"
  },
  {
    "path": ".trivyignore",
    "content": "# Test containers require root for OpenVPN NET_ADMIN capability\nAVD-DS-0002\n\n# Test containers don't need healthcheck\nAVD-DS-0026\n\n# False positive: yum clean all is present in the conditional but Trivy doesn't detect it\nAVD-DS-0015\n"
  },
  {
    "path": "AGENTS.md",
    "content": "- Use gh CLI to interact with GitHub\n- Test locally using the Docker setup when needed\n- When doing changes, check if README/FAQ and tests needs to be updated\n- Remember the script and documentation needs to be accessible to a moderately technical audience\n- Keep PR description concise (no test plan)\n- Don't use gh cli to post comments on the developer's behalf\n- Don't amend commits and force push unless told otherwise\n"
  },
  {
    "path": "FAQ.md",
    "content": "# FAQ\n\n**Q:** The script has been updated since I installed OpenVPN. How do I update?\n\n**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.\n\nYou can, of course, it's even recommended, update the `openvpn` package with your package manager.\n\n---\n\n**Q:** How do I renew certificates before they expire?\n\n**A:** Use the CLI commands to renew certificates:\n\n```bash\n# Renew a client certificate\n./openvpn-install.sh client renew alice\n\n# Renew with custom validity period (365 days)\n./openvpn-install.sh client renew alice --cert-days 365\n\n# Renew the server certificate\n./openvpn-install.sh server renew\n```\n\nFor 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).\n\n---\n\n**Q:** How do I check for DNS leaks?\n\n**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.\n\n---\n\n**Q:** How do I fix DNS leaks?\n\n**A:** On Windows 10 DNS leaks are blocked by default with the `block-outside-dns` option.\nOn Linux you need to add these lines to your `.ovpn` file based on your Distribution.\n\nDebian 9, 10 and Ubuntu 16.04, 18.04\n\n```\nscript-security 2\nup /etc/openvpn/update-resolv-conf\ndown /etc/openvpn/update-resolv-conf\n```\n\nCentOS 6, 7\n\n```\nscript-security 2\nup /usr/share/doc/openvpn-2.4.8/contrib/pull-resolv-conf/client.up\ndown /usr/share/doc/openvpn-2.4.8/contrib/pull-resolv-conf/client.down\n```\n\nCentOS 8, Fedora 30, 31\n\n```\nscript-security 2\nup /usr/share/doc/openvpn/contrib/pull-resolv-conf/client.up\ndown /usr/share/doc/openvpn/contrib/pull-resolv-conf/client.down\n```\n\nArch Linux\n\n```\nscript-security 2\nup /usr/share/openvpn/contrib/pull-resolv-conf/client.up\ndown /usr/share/openvpn/contrib/pull-resolv-conf/client.down\n```\n\n---\n\n**Q:** IPv6 is not working on my Hetzner VM\n\n**A:** This an issue on their side. See <https://angristan.xyz/fix-ipv6-hetzner-cloud/>\n\n---\n\n**Q:** DNS is not working on my Linux client\n\n**A:** See \"How do I fix DNS leaks?\" question\n\n---\n\n**Q:** What sysctl and firewall changes are made by the script?\n\n**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`.\n\nSysctl options are at `/etc/sysctl.d/99-openvpn.conf`\n\n---\n\n**Q:** How can I access other clients connected to the same OpenVPN server?\n\n**A:** Add `client-to-client` to your `server.conf`\n\n---\n\n**Q:** My router can't connect\n\n**A:**\n\n- `Options error: No closing quotation (\") in config.ovpn:46` :\n\n  type `yes` when asked to customize encryption settings and choose `tls-auth`\n\n---\n\n**Q:** How can I access computers on the OpenVPN server's LAN?\n\n**A:** Two steps are required:\n\n1. **Push a route to clients** - Add the LAN subnet to `/etc/openvpn/server/server.conf`:\n\n   ```\n   push \"route 192.168.1.0 255.255.255.0\"\n   ```\n\n   Replace `192.168.1.0/24` with your actual LAN subnet.\n\n2. **Enable routing back to VPN clients** - Choose one of these options:\n   - **Option A: Add a static route on your router** (recommended when you can configure your router)\n\n     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.\n\n   - **Option B: Masquerade VPN traffic to LAN**\n\n     If you can't modify your router, add a masquerade rule so VPN traffic appears to come from the server:\n\n     ```bash\n     # iptables\n     iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -d 192.168.1.0/24 -j MASQUERADE\n\n     # or nftables\n     nft add rule ip nat postrouting ip saddr 10.8.0.0/24 ip daddr 192.168.1.0/24 masquerade\n     ```\n\n     Make this persistent by adding it to your firewall scripts.\n\nRestart OpenVPN after making changes: `systemctl restart openvpn-server@server`\n\n---\n\n**Q:** How can I add multiple users in one go?\n\n**A:** Here is a sample Bash script to achieve this:\n\n```bash\n#!/bin/bash\nuserlist=(user1 user2 user3)\n\nfor user in \"${userlist[@]}\"; do\n  ./openvpn-install.sh client add \"$user\"\ndone\n```\n\nFrom a list in a text file:\n\n```bash\n#!/bin/bash\nwhile read -r user; do\n  ./openvpn-install.sh client add \"$user\"\ndone < users.txt\n```\n\nTo add password-protected clients:\n\n```bash\n#!/bin/bash\n./openvpn-install.sh client add alice --password \"secretpass123\"\n```\n\n---\n\n**Q:** How do I change the default `.ovpn` file created for future clients?\n\n**A:** You can edit the template out of which `.ovpn` files are created by editing `/etc/openvpn/server/client-template.txt`\n\n---\n\n**Q:** For my clients - I want to set my internal network to pass through the VPN and the rest to go through my internet?\n\n**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\n\n```sh\nroute-nopull\nroute 10.0.0.0 255.0.0.0\n```\n\nSo for example - here it would route all traffic of `10.0.0.0/8` to the VPN. And the rest through the internet.\n\n---\n\n**Q:** How do I configure split-tunnel mode on the server (route only specific networks through VPN for all clients)?\n\n**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`:\n\n1. Remove or comment out the redirect-gateway line:\n\n   ```\n   #push \"redirect-gateway def1 bypass-dhcp\"\n   ```\n\n2. Add routes for the networks you want to tunnel:\n\n   ```\n   push \"route 10.0.0.0 255.0.0.0\"\n   push \"route 192.168.1.0 255.255.255.0\"\n   ```\n\n3. Optionally remove DNS push directives if you don't want VPN DNS:\n\n   ```\n   #push \"dhcp-option DNS 1.1.1.1\"\n   ```\n\n4. For IPv6, remove or comment out:\n\n   ```\n   #push \"route-ipv6 2000::/3\"\n   #push \"redirect-gateway ipv6\"\n   ```\n\n   Or add specific IPv6 routes:\n\n   ```\n   push \"route-ipv6 2001:db8::/32\"\n   ```\n\n5. Restart OpenVPN: `systemctl restart openvpn-server@server`\n\n---\n\n**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?\n\n**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:\n\nWindows (commands needs to run cmd.exe as Administrator):\n\n```\nnetsh interface ipv6 add prefixpolicy fd00::/8 3 1\n```\n\nLinux:\n\nedit `/etc/gai.conf` and uncomment the following line and also change its value to `1`:\n\n```\nlabel fc00::/7      1\n```\n\nThis 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>\"`\n\n---\n\n**Q:** How can I run OpenVPN on port 443 alongside a web server?\n\n**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.\n\n1. During installation, select **TCP** and port **443**\n2. Configure your web server to listen on a different port (e.g., 8443)\n3. Add to `/etc/openvpn/server/server.conf`:\n\n   ```\n   port-share 127.0.0.1 8443\n   ```\n\n4. Restart OpenVPN: `systemctl restart openvpn-server@server`\n\nThis 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.\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2013 Nyr\nCopyright (c) 2016 Stanislas Lange (angristan)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": ".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@echo \"Waiting for tests to complete...\"\n\t@for i in $$(seq 1 180); do \\\n\t\tif docker logs openvpn-client 2>&1 | grep -q \"ALL TESTS PASSED\"; then \\\n\t\t\techo \"✓ Tests passed!\"; \\\n\t\t\t$(MAKE) test-down; \\\n\t\t\texit 0; \\\n\t\tfi; \\\n\t\tif docker logs openvpn-client 2>&1 | grep -q \"FAIL:\"; then \\\n\t\t\techo \"✗ Tests failed!\"; \\\n\t\t\tdocker logs openvpn-client; \\\n\t\t\t$(MAKE) test-down; \\\n\t\t\texit 1; \\\n\t\tfi; \\\n\t\techo \"Waiting... ($$i/180)\"; \\\n\t\tsleep 2; \\\n\tdone; \\\n\techo \"Timeout waiting for tests\"; \\\n\t$(MAKE) test-down; \\\n\texit 1\n\n# Build test containers\ntest-build:\n\tBASE_IMAGE=$(BASE_IMAGE) docker compose build\n\n# Start test containers\ntest-up:\n\tdocker compose up -d\n\n# Stop and remove test containers\ntest-down:\n\tdocker compose down -v --remove-orphans\n\n# View logs\ntest-logs:\n\tdocker compose logs -f\n\n# View server logs only\ntest-logs-server:\n\tdocker logs -f openvpn-server\n\n# View client logs only\ntest-logs-client:\n\tdocker logs -f openvpn-client\n\n# Full cleanup\ntest-clean: test-down\n\tdocker rmi openvpn-install-openvpn-server openvpn-install-openvpn-client 2>/dev/null || true\n\tdocker volume prune -f\n\n# Interactive shell into server container\ntest-shell-server:\n\tdocker exec -it openvpn-server /bin/bash\n\n# Interactive shell into client container\ntest-shell-client:\n\tdocker exec -it openvpn-client /bin/bash\n\n# Test specific distributions\ntest-ubuntu-18.04:\n\t$(MAKE) test BASE_IMAGE=ubuntu:18.04\n\ntest-ubuntu-20.04:\n\t$(MAKE) test BASE_IMAGE=ubuntu:20.04\n\ntest-ubuntu-22.04:\n\t$(MAKE) test BASE_IMAGE=ubuntu:22.04\n\ntest-ubuntu-24.04:\n\t$(MAKE) test BASE_IMAGE=ubuntu:24.04\n\ntest-debian-11:\n\t$(MAKE) test BASE_IMAGE=debian:11\n\ntest-debian-12:\n\t$(MAKE) test BASE_IMAGE=debian:12\n\ntest-fedora-40:\n\t$(MAKE) test BASE_IMAGE=fedora:40\n\ntest-fedora-41:\n\t$(MAKE) test BASE_IMAGE=fedora:41\n\ntest-rocky-8:\n\t$(MAKE) test BASE_IMAGE=rockylinux:8\n\ntest-rocky-9:\n\t$(MAKE) test BASE_IMAGE=rockylinux:9\n\ntest-almalinux-8:\n\t$(MAKE) test BASE_IMAGE=almalinux:8\n\ntest-almalinux-9:\n\t$(MAKE) test BASE_IMAGE=almalinux:9\n\ntest-oracle-8:\n\t$(MAKE) test BASE_IMAGE=oraclelinux:8\n\ntest-oracle-9:\n\t$(MAKE) test BASE_IMAGE=oraclelinux:9\n\ntest-amazon-2023:\n\t$(MAKE) test BASE_IMAGE=amazonlinux:2023\n\ntest-arch:\n\t$(MAKE) test BASE_IMAGE=archlinux:latest\n\ntest-centos-stream-9:\n\t$(MAKE) test BASE_IMAGE=quay.io/centos/centos:stream9\n\ntest-opensuse-leap:\n\t$(MAKE) test BASE_IMAGE=opensuse/leap:16.0\n\ntest-opensuse-tumbleweed:\n\t$(MAKE) test BASE_IMAGE=opensuse/tumbleweed\n\n# Test all distributions (runs sequentially)\ntest-all:\n\t$(MAKE) test-ubuntu-18.04\n\t$(MAKE) test-ubuntu-20.04\n\t$(MAKE) test-ubuntu-22.04\n\t$(MAKE) test-ubuntu-24.04\n\t$(MAKE) test-debian-11\n\t$(MAKE) test-debian-12\n\t$(MAKE) test-fedora-40\n\t$(MAKE) test-fedora-41\n\t$(MAKE) test-rocky-8\n\t$(MAKE) test-rocky-9\n\t$(MAKE) test-almalinux-8\n\t$(MAKE) test-almalinux-9\n\t$(MAKE) test-oracle-8\n\t$(MAKE) test-oracle-9\n\t$(MAKE) test-amazon-2023\n\t$(MAKE) test-arch\n\t$(MAKE) test-centos-stream-9\n\t$(MAKE) test-opensuse-leap\n\t$(MAKE) test-opensuse-tumbleweed\n"
  },
  {
    "path": "README.md",
    "content": "# openvpn-install\n\n[![Say Thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](https://saythanks.io/to/angristan)\n\nOpenVPN installer for Debian, Ubuntu, Fedora, openSUSE, CentOS, Amazon Linux, Arch Linux, Oracle Linux, Rocky Linux and AlmaLinux.\n\nThis script will let you setup and manage your own secure VPN server in just a few seconds.\n\n## What is this?\n\nThis 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.\n\nOnce set up, you will be able to generate client configuration files for every device you want to connect.\n\nEach client will be able to route its internet traffic through the server, fully encrypted.\n\n```mermaid\ngraph LR\n  A[Phone] -->|Encrypted| VPN\n  B[Laptop] -->|Encrypted| VPN\n  C[Computer] -->|Encrypted| VPN\n\n  VPN[OpenVPN Server]\n\n  VPN --> I[Internet]\n```\n\n## Why OpenVPN?\n\nOpenVPN 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).\n\nThat said, OpenVPN still makes sense when you need:\n\n- **TCP support**: works in restrictive environments where UDP is blocked (corporate networks, airports, hotels, etc.)\n- **Password-protected private keys**: WireGuard configs store the private key in plain text\n- **Legacy compatibility**: clients exist for pretty much every platform, including older systems\n\n## Features\n\n- Installs and configures a ready-to-use OpenVPN server\n- CLI interface for automation and scripting (non-interactive mode with JSON output)\n- Certificate renewal for both client and server certificates\n- List and monitor connected clients\n- Immediate client disconnect on certificate revocation (via management interface)\n- Uses [official OpenVPN repositories](https://community.openvpn.net/openvpn/wiki/OpenvpnSoftwareRepos) when possible for the latest stable releases\n- Firewall rules and forwarding managed seamlessly (native firewalld and nftables support, iptables fallback)\n- Configurable VPN subnets (IPv4: default `10.8.0.0/24`, IPv6: default `fd42:42:42:42::/112`)\n- Configurable tunnel MTU (default: `1500`)\n- If needed, the script can cleanly remove OpenVPN, including configuration and firewall rules\n- Customisable encryption settings, enhanced default settings (see [Security and Encryption](#security-and-encryption) below)\n- Uses latest OpenVPN features when available (see [Security and Encryption](#security-and-encryption) below)\n- Variety of DNS resolvers to be pushed to the clients\n- Choice to use a self-hosted resolver with Unbound (supports already existing Unbound installations)\n- Choice between TCP and UDP\n- Flexible IPv4/IPv6 support:\n  - IPv4 or IPv6 server endpoint (how clients connect)\n  - IPv4-only, IPv6-only, or dual-stack clients (VPN addressing and internet access)\n  - All combinations supported: 4→4, 4→4/6, 4→6, 6→4, 6→6, 6→4/6\n  - Automatic leak prevention: blocks undesired protocol in single-stack modes\n- Unprivileged mode: run as `nobody`/`nogroup`\n- Block DNS leaks on Windows 10\n- Randomised server certificate name\n- Choice to protect clients with a password (private key encryption)\n- Option to allow multiple devices to use the same client profile simultaneously (disables persistent IP addresses)\n- **Peer fingerprint authentication** (OpenVPN 2.6+): Simplified WireGuard-like authentication without a CA\n- Many other little things!\n\n## Compatibility\n\nThe script supports these Linux distributions:\n\n|                     | Support |\n| ------------------- | ------- |\n| AlmaLinux >= 8      | ✅ 🤖   |\n| Amazon Linux 2023   | ✅ 🤖   |\n| Arch Linux          | ✅ 🤖   |\n| CentOS Stream >= 8  | ✅ 🤖   |\n| Debian >= 11        | ✅ 🤖   |\n| Fedora >= 40        | ✅ 🤖   |\n| openSUSE Leap >= 16 | ✅ 🤖   |\n| openSUSE Tumbleweed | ✅ 🤖   |\n| Oracle Linux >= 8   | ✅ 🤖   |\n| Rocky Linux >= 8    | ✅ 🤖   |\n| Ubuntu >= 18.04     | ✅ 🤖   |\n\nTo be noted:\n\n- The script is regularly tested against the distributions marked with a 🤖 only.\n  - It's only tested on `amd64` architecture.\n- The script requires `systemd`.\n\n### Recommended providers\n\n- [Vultr](https://umami.stanislas.cloud/q/1HH9Thp8i): Worldwide locations, IPv6 support, starting at \\$2.5/month\n- [Hetzner](https://umami.stanislas.cloud/q/HdzaOJWq7): Worldwide locations, IPv6, 20 TB of traffic, starting at €3.59/month\n- [Digital Ocean](https://umami.stanislas.cloud/q/sEVh1l79B): Worldwide locations, IPv6 support, starting at \\$4/month\n\n## Usage\n\nFirst, download the script on your server and make it executable:\n\n```bash\ncurl -O https://raw.githubusercontent.com/angristan/openvpn-install/master/openvpn-install.sh\nchmod +x openvpn-install.sh\n```\n\nYou need to run the script as root and have the TUN module enabled.\n\n### Interactive Mode\n\nThe easiest way to get started is the interactive menu:\n\n```bash\n./openvpn-install.sh interactive\n```\n\nThis will guide you through installation and client management.\n\nIn 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.\n\nIf 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.\n\n### CLI Mode\n\n> [!WARNING]\n> 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.\n\nFor automation and scripting, use the CLI interface:\n\n```bash\n# Install with defaults\n./openvpn-install.sh install\n\n# Add a client\n./openvpn-install.sh client add alice\n\n# List clients\n./openvpn-install.sh client list\n\n# Revoke a client (immediately disconnects if connected)\n./openvpn-install.sh client revoke alice\n```\n\n#### Commands\n\n```text\nopenvpn-install <command> [options]\n\nCommands:\n  install       Install and configure OpenVPN server\n  uninstall     Remove OpenVPN server\n  client        Manage client certificates\n  server        Server management\n  interactive   Launch interactive menu\n\nGlobal Options:\n  --verbose     Show detailed output\n  --log <path>  Log file path (default: openvpn-install.log)\n  --no-log      Disable file logging\n  --no-color    Disable colored output\n  -h, --help    Show help\n```\n\nRun `./openvpn-install.sh <command> --help` for command-specific options.\n\n#### Client Management\n\n```bash\n# Add a new client\n./openvpn-install.sh client add alice\n\n# Add a password-protected client\n./openvpn-install.sh client add bob --password\n\n# Revoke a client\n./openvpn-install.sh client revoke alice\n\n# Renew a client certificate\n./openvpn-install.sh client renew bob --cert-days 365\n```\n\nList all clients:\n\n```text\n$ ./openvpn-install.sh client list\n══ Client Certificates ══\n[INFO] Found 3 client certificate(s)\n\n   Name      Status   Expiry      Remaining\n   ----      ------   ------      ---------\n   alice     Valid    2035-01-15  3650 days\n   bob       Valid    2035-01-15  3650 days\n   charlie   Revoked  2035-01-15  unknown\n```\n\nJSON output for scripting:\n\n```text\n$ ./openvpn-install.sh client list --format json | jq\n{\n  \"clients\": [\n    {\n      \"name\": \"alice\",\n      \"status\": \"valid\",\n      \"expiry\": \"2035-01-15\",\n      \"days_remaining\": 3650\n    },\n    {\n      \"name\": \"bob\",\n      \"status\": \"valid\",\n      \"expiry\": \"2035-01-15\",\n      \"days_remaining\": 3650\n    },\n    {\n      \"name\": \"charlie\",\n      \"status\": \"revoked\",\n      \"expiry\": \"2035-01-15\",\n      \"days_remaining\": null\n    }\n  ]\n}\n```\n\n#### Server Management\n\n```bash\n# Renew server certificate\n./openvpn-install.sh server renew\n\n# Uninstall OpenVPN\n./openvpn-install.sh uninstall\n```\n\nShow connected clients (data refreshes every 60 seconds):\n\n```text\n$ ./openvpn-install.sh server status\n══ Connected Clients ══\n[INFO] Found 2 connected client(s)\n\n   Name    Real Address          VPN IP      Connected Since   Transfer\n   ----    ------------          ------      ---------------   --------\n   alice   203.0.113.45:52341    10.8.0.2    2025-01-15 14:32  ↓1.2M ↑500K\n   bob     198.51.100.22:41892   10.8.0.3    2025-01-15 09:15  ↓800K ↑200K\n\n[INFO] Note: Data refreshes every 60 seconds.\n```\n\n#### Install Options\n\nThe `install` command supports many options for customization:\n\n```bash\n# Custom port and protocol\n./openvpn-install.sh install --port 443 --protocol tcp\n\n# Custom DNS provider\n./openvpn-install.sh install --dns quad9\n\n# Custom encryption settings\n./openvpn-install.sh install --cipher AES-256-GCM --cert-type rsa --rsa-bits 4096\n\n# Custom VPN subnet\n./openvpn-install.sh install --subnet-ipv4 10.9.0.0\n\n# Enable dual-stack (IPv4 + IPv6) for clients\n./openvpn-install.sh install --client-ipv4 --client-ipv6\n\n# IPv6-only clients (no IPv4)\n./openvpn-install.sh install --no-client-ipv4 --client-ipv6\n\n# IPv6 endpoint (server listens on IPv6, clients connect via IPv6)\n./openvpn-install.sh install --endpoint-type 6 --endpoint 2001:db8::1\n\n# Custom IPv6 subnet for dual-stack setup\n./openvpn-install.sh install --client-ipv6 --subnet-ipv6 fd00:1234:5678::\n\n# Skip initial client creation\n./openvpn-install.sh install --no-client\n\n# Full example with multiple options\n./openvpn-install.sh install \\\n  --port 443 \\\n  --protocol tcp \\\n  --dns cloudflare \\\n  --cipher AES-256-GCM \\\n  --client mydevice \\\n  --client-cert-days 365\n```\n\n**Network Options:**\n\n- `--endpoint <host>` - Public IP or hostname for clients (default: auto-detected)\n- `--endpoint-type <4|6>` - Endpoint IP version (default: `4`)\n- `--ip <addr>` - Server listening IP (default: auto-detected)\n- `--client-ipv4` - Enable IPv4 for VPN clients (default: enabled)\n- `--no-client-ipv4` - Disable IPv4 for VPN clients\n- `--client-ipv6` - Enable IPv6 for VPN clients (default: disabled)\n- `--no-client-ipv6` - Disable IPv6 for VPN clients\n- `--subnet-ipv4 <x.x.x.0>` - IPv4 VPN subnet (default: `10.8.0.0`)\n- `--subnet-ipv6 <prefix>` - IPv6 VPN subnet (default: `fd42:42:42:42::`)\n- `--port <num>` - OpenVPN port (default: `1194`)\n- `--port-random` - Use random port (49152-65535)\n- `--protocol <udp|tcp>` - Protocol (default: `udp`)\n- `--mtu <size>` - Tunnel MTU (default: `1500`)\n\n**DNS Options:**\n\n- `--dns <provider>` - DNS provider (default: `cloudflare`). Options: `system`, `unbound`, `cloudflare`, `quad9`, `quad9-uncensored`, `fdn`, `dnswatch`, `opendns`, `google`, `yandex`, `adguard`, `nextdns`, `custom`\n- `--dns-primary <ip>` - Custom primary DNS (requires `--dns custom`)\n- `--dns-secondary <ip>` - Custom secondary DNS (requires `--dns custom`)\n\n**Security Options:**\n\n- `--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`\n- `--cert-type <ecdsa|rsa>` - Certificate type (default: `ecdsa`)\n- `--cert-curve <curve>` - ECDSA curve (default: `prime256v1`). Options: `prime256v1`, `secp384r1`, `secp521r1`\n- `--rsa-bits <2048|3072|4096>` - RSA key size (default: `2048`)\n- `--hmac <alg>` - HMAC algorithm (default: `SHA256`). Options: `SHA256`, `SHA384`, `SHA512`\n- `--tls-sig <mode>` - TLS mode (default: `crypt-v2`). Options: `crypt-v2`, `crypt`, `auth`\n- `--auth-mode <mode>` - Authentication mode (default: `pki`). Options: `pki` (CA-based), `fingerprint` (peer-fingerprint, requires OpenVPN 2.6+)\n- `--tls-version-min <1.2|1.3>` - Minimum TLS version (default: `1.2`)\n- `--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`)\n- `--tls-groups <list>` - Key exchange groups, colon-separated (default: `X25519:prime256v1:secp384r1:secp521r1`)\n- `--server-cert-days <n>` - Server cert validity in days (default: `3650`)\n\n**Client Options:**\n\n- `--client <name>` - Initial client name (default: `client`)\n- `--client-password [pass]` - Password-protect client key (default: no password)\n- `--client-cert-days <n>` - Client cert validity in days (default: `3650`)\n- `--no-client` - Skip initial client creation\n\n**Other Options:**\n\n- `--multi-client` - Allow same cert on multiple devices (default: disabled)\n\n#### Automation Examples\n\n**Batch client creation:**\n\n```bash\n#!/bin/bash\nfor user in alice bob charlie; do\n  ./openvpn-install.sh client add \"$user\"\ndone\n```\n\n**Create clients from a file:**\n\n```bash\n#!/bin/bash\nwhile read -r user; do\n  ./openvpn-install.sh client add \"$user\"\ndone < users.txt\n```\n\n**JSON output for scripting:**\n\n```bash\n# Get client list as JSON\n./openvpn-install.sh client list --format json | jq '.clients[] | select(.status == \"valid\")'\n\n# Get connected clients as JSON\n./openvpn-install.sh server status --format json\n```\n\n## Fork\n\nThis script is based on the great work of [Nyr and its contributors](https://github.com/Nyr/openvpn-install).\n\nSince 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.\n\n## FAQ\n\nMore Q&A in [FAQ.md](FAQ.md).\n\n**Q:** Which provider do you recommend?\n\n**A:** I recommend these:\n\n- [Vultr](https://www.vultr.com/?ref=8948982-8H): Worldwide locations, IPv6 support, starting at \\$2.5/month\n- [Hetzner](https://hetzner.cloud/?ref=ywtlvZsjgeDq): Worldwide locations, IPv6, 20 TB of traffic, starting at €3.59/month\n- [Digital Ocean](https://m.do.co/c/ed0ba143fe53): Worldwide locations, IPv6 support, starting at \\$4/month\n\n---\n\n**Q:** Which OpenVPN client do you recommend?\n\n**A:** If possible, an official OpenVPN 2.4 client.\n\n- Windows: [The official OpenVPN community client](https://openvpn.net/index.php/download/community-downloads.html).\n- 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.\n- macOS: [Tunnelblick](https://tunnelblick.net/), [Viscosity](https://www.sparklabs.com/viscosity/), [OpenVPN for Mac](https://openvpn.net/client-connect-vpn-for-mac-os/).\n- Android: [OpenVPN for Android](https://play.google.com/store/apps/details?id=de.blinkt.openvpn).\n- iOS: [The official OpenVPN Connect client](https://itunes.apple.com/us/app/openvpn-connect/id590379981).\n\n---\n\n**Q:** Am I safe from the NSA by using your script?\n\n**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.\n\n---\n\n**Q:** Is there an OpenVPN documentation?\n\n**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.\n\n---\n\nMore Q&A in [FAQ.md](FAQ.md).\n\n## Contributing\n\n### Discuss changes\n\nPlease open an issue before submitting a PR if you want to discuss a change, especially if it's a big one.\n\n## Security and Encryption\n\n> [!NOTE]\n> 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.\n\nOpenVPN 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:\n\n- **OpenVPN 2.4** (2016): Added ECDSA, ECDH, AES-GCM, NCP (cipher negotiation), and tls-crypt\n- **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\n- **OpenVPN 2.6** (2023): TLS 1.2 minimum by default, compression blocked by default, `--peer-fingerprint` for PKI-less setups, and DCO kernel acceleration\n\nIf 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.\n\nCertificate 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.\n\n### Compression\n\nThis 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.\n\nOpenVPN 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.\n\n### TLS version\n\n> [!NOTE]\n> OpenVPN 2.6+ defaults to TLS 1.2 minimum. Prior versions accepted TLS 1.0 by default.\n\nOpenVPN 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).\n\nThis 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.\n\n**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.\n\nThe 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:\n\n- `TLS_AES_256_GCM_SHA384`\n- `TLS_AES_128_GCM_SHA256`\n- `TLS_CHACHA20_POLY1305_SHA256`\n\nTLS 1.2 is supported since OpenVPN 2.3.3. TLS 1.3 is supported since OpenVPN 2.5.\n\n### Certificate\n\nOpenVPN uses an RSA certificate with a 2048 bits key by default.\n\nOpenVPN 2.4 added support for ECDSA. Elliptic curve cryptography is faster, lighter and more secure.\n\nThis script provides:\n\n- ECDSA: `prime256v1`/`secp384r1`/`secp521r1` curves\n- RSA: `2048`/`3072`/`4096` bits keys\n\nIt defaults to ECDSA with `prime256v1`.\n\nOpenVPN uses `SHA-256` as the signature hash by default, and so does the script. It provides no other choice as of now.\n\n### Authentication Mode\n\nThe script supports two authentication modes:\n\n#### PKI Mode (default)\n\nTraditional 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).\n\nThis is the recommended mode for larger deployments where you need:\n\n- Centralized certificate management\n- Standard CRL-based revocation\n- Compatibility with all OpenVPN versions\n\n#### Peer Fingerprint Mode (OpenVPN 2.6+)\n\nA 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.\n\n```bash\n# Install with fingerprint mode\n./openvpn-install.sh install --auth-mode fingerprint\n```\n\nBenefits:\n\n- Simpler setup: No CA infrastructure needed\n- Easier to understand: Similar to SSH's `known_hosts` model\n- Ideal for small setups: Home networks, labs, small teams\n\nHow it works:\n\n1. Server generates a self-signed certificate and stores its fingerprint\n2. Each client generates a self-signed certificate\n3. Client fingerprints are added to the server's `<peer-fingerprint>` block\n4. Clients verify the server using the server's fingerprint\n5. Revocation removes the fingerprint from the server config (no CRL needed)\n\nTrade-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.\n\n### Data channel\n\n> [!NOTE]\n> 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.\n\nBy 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.\n\n> The default is BF-CBC, an abbreviation for Blowfish in Cipher Block Chaining mode.\n>\n> 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.\n> 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.\n>\n> OpenVPN's default cipher, BF-CBC, is affected by this attack.\n\nIndeed, 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.\n\n> 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.\n\nAES-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).\n\nAES-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.\n\nChaCha20-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.\n\nThe script supports the following ciphers:\n\n- `AES-128-GCM`\n- `AES-192-GCM`\n- `AES-256-GCM`\n- `AES-128-CBC`\n- `AES-192-CBC`\n- `AES-256-CBC`\n- `CHACHA20-POLY1305` (requires OpenVPN 2.5+)\n\nAnd defaults to `AES-128-GCM`.\n\nOpenVPN 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.\n\n### Control channel\n\nOpenVPN 2.4 will negotiate the best cipher available by default (e.g ECDHE+AES-256-GCM)\n\n#### TLS 1.2 ciphers (`--tls-cipher`)\n\nThe script proposes the following options, depending on the certificate:\n\n- ECDSA:\n  - `TLS-ECDHE-ECDSA-WITH-AES-128-GCM-SHA256`\n  - `TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384`\n  - `TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256` (requires OpenVPN 2.5+)\n- RSA:\n  - `TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256`\n  - `TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384`\n  - `TLS-ECDHE-RSA-WITH-CHACHA20-POLY1305-SHA256` (requires OpenVPN 2.5+)\n\nIt defaults to `TLS-ECDHE-*-WITH-AES-128-GCM-SHA256`.\n\n#### TLS 1.3 ciphers (`--tls-ciphersuites`)\n\nWhen TLS 1.3 is negotiated, a separate set of cipher suites is used. These are configured via `--tls-ciphersuites` and use OpenSSL naming conventions:\n\n- `TLS_AES_256_GCM_SHA384`\n- `TLS_AES_128_GCM_SHA256`\n- `TLS_CHACHA20_POLY1305_SHA256`\n\nBy 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).\n\n### Key exchange\n\nOpenVPN 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.\n\nOpenVPN 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.\n\nThe script configures `tls-groups` with the following preference list:\n\n```\nX25519:prime256v1:secp384r1:secp521r1\n```\n\n- **X25519**: Fast, modern curve (Curve25519), widely supported\n- **prime256v1**: NIST P-256, most compatible\n- **secp384r1**: NIST P-384, higher security\n- **secp521r1**: NIST P-521, highest security\n\nYou can customize this with `--tls-groups`.\n\n### HMAC digest algorithm\n\nFrom the OpenVPN wiki, about `--auth`:\n\n> 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.\n>\n> 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.\n\nThe script provides the following choices:\n\n- `SHA256`\n- `SHA384`\n- `SHA512`\n\nIt defaults to `SHA256`.\n\n### `tls-auth`, `tls-crypt`, and `tls-crypt-v2`\n\nFrom the OpenVPN wiki, about `tls-auth`:\n\n> Add an additional layer of HMAC authentication on top of the TLS control channel to mitigate DoS attacks and attacks on the TLS stack.\n>\n> 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.\n\nAbout `tls-crypt`:\n\n> Encrypt and authenticate all control channel packets with the key from keyfile. (See --tls-auth for more background.)\n>\n> Encrypting (and authenticating) control channel packets:\n>\n> - provides more privacy by hiding the certificate used for the TLS connection,\n> - makes it harder to identify OpenVPN traffic as such,\n> - provides \"poor-man's\" post-quantum security, against attackers who will never know the pre-shared key (i.e. no forward secrecy).\n\nSo both provide an additional layer of security and mitigate DoS attacks. They aren't used by default by OpenVPN.\n\n`tls-crypt` is an OpenVPN 2.4 feature that provides encryption in addition to authentication (unlike `tls-auth`). It is more privacy-friendly.\n\n`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:\n\n- **Better security**: If a client key is compromised, other clients are not affected\n- **Easier key management**: Client keys can be revoked individually without regenerating the server key\n- **Scalability**: Better suited for large deployments with many clients\n\nThe script supports all three options:\n\n- `tls-crypt-v2` (default): Per-client keys for better security\n- `tls-crypt`: Shared key for all clients, compatible with OpenVPN 2.4+\n- `tls-auth`: HMAC authentication only (no encryption), compatible with older clients\n\n### Certificate type verification (`remote-cert-tls`)\n\nThe 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.\n\nSimilarly, 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.\n\n### Data Channel Offload (DCO)\n\n[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.\n\nDCO was merged into the Linux kernel 6.16 (April 2025).\n\n**Requirements:**\n\n- OpenVPN 2.6.0 or later\n- Linux kernel 6.16+ (built-in) or `ovpn-dco` kernel module\n- UDP protocol (TCP is not supported)\n- AEAD cipher (`AES-128-GCM`, `AES-256-GCM`, or `CHACHA20-POLY1305`)\n\nThe 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.\n\n**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.\n\nThe script will display the DCO availability status during installation.\n\n## Say thanks\n\nYou can [say thanks](https://saythanks.io/to/angristan) if you want!\n\n## Credits & Licence\n\nMany thanks to the [contributors](https://github.com/Angristan/OpenVPN-install/graphs/contributors) and Nyr's original work.\n\nThis project is under the [MIT Licence](https://raw.githubusercontent.com/Angristan/openvpn-install/master/LICENSE)\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=angristan/openvpn-install&type=Date)](https://star-history.com/#angristan/openvpn-install&Date)\n"
  },
  {
    "path": "biome.json",
    "content": "{\n  \"$schema\": \"https://biomejs.dev/schemas/1.9.4/schema.json\",\n  \"formatter\": {\n    \"indentStyle\": \"space\",\n    \"indentWidth\": 2\n  }\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "---\nservices:\n  openvpn-server:\n    build:\n      context: .\n      dockerfile: test/Dockerfile.server\n      args:\n        BASE_IMAGE: ${BASE_IMAGE:-ubuntu:24.04}\n        ENABLE_FIREWALLD: ${ENABLE_FIREWALLD:-n}\n        ENABLE_NFTABLES: ${ENABLE_NFTABLES:-n}\n    container_name: openvpn-server\n    hostname: openvpn-server\n    privileged: true\n    cgroupns: host\n    devices:\n      - /dev/net/tun:/dev/net/tun\n    sysctls:\n      - net.ipv4.ip_forward=1\n    volumes:\n      - shared-config:/shared\n      - /sys/fs/cgroup:/sys/fs/cgroup:rw\n    tmpfs:\n      - /run\n      - /run/lock\n    networks:\n      vpn-test:\n        ipv4_address: 172.28.0.10\n    stop_signal: SIGRTMIN+3\n    healthcheck:\n      test: [\"CMD\", \"pgrep\", \"openvpn\"]\n      interval: 5s\n      timeout: 3s\n      retries: 30\n\n  openvpn-client:\n    build:\n      context: .\n      dockerfile: test/Dockerfile.client\n    container_name: openvpn-client\n    hostname: openvpn-client\n    cap_add:\n      - NET_ADMIN\n    devices:\n      - /dev/net/tun:/dev/net/tun\n    volumes:\n      - shared-config:/shared\n    networks:\n      vpn-test:\n        ipv4_address: 172.28.0.20\n    depends_on:\n      openvpn-server:\n        condition: service_healthy\n\nvolumes:\n  shared-config:\n\nnetworks:\n  vpn-test:\n    driver: bridge\n    ipam:\n      config:\n        - subnet: 172.28.0.0/24\n"
  },
  {
    "path": "openvpn-install.sh",
    "content": "#!/bin/bash\n# shellcheck disable=SC1091,SC2034\n# SC1091: Not following /etc/os-release (sourced dynamically)\n# SC2034: Variables used indirectly or exported for subprocesses\n\n# Secure OpenVPN server installer for Debian, Ubuntu, CentOS, Amazon Linux 2023, Fedora, Oracle Linux, Arch Linux, Rocky Linux and AlmaLinux.\n# https://github.com/angristan/openvpn-install\n\n# Configuration constants\nreadonly DEFAULT_CERT_VALIDITY_DURATION_DAYS=3650 # 10 years\nreadonly DEFAULT_CRL_VALIDITY_DURATION_DAYS=5475  # 15 years\nreadonly EASYRSA_VERSION=\"3.2.6\"\nreadonly EASYRSA_SHA256=\"c2572990ce91112eef8d1b8e4a3b58790da95b68501785c621f69121dfbd22d7\"\n\n# =============================================================================\n# Logging Configuration\n# =============================================================================\n# Set VERBOSE=1 to see command output, VERBOSE=0 (default) for quiet mode\n# Set LOG_FILE to customize log location (default: openvpn-install.log in current dir)\n# Set LOG_FILE=\"\" to disable file logging\nVERBOSE=${VERBOSE:-0}\nLOG_FILE=${LOG_FILE:-openvpn-install.log}\nOUTPUT_FORMAT=${OUTPUT_FORMAT:-table} # table or json - json suppresses log output\n\n# Color definitions (disabled if not a terminal, unless FORCE_COLOR=1)\nif [[ -t 1 ]] || [[ $FORCE_COLOR == \"1\" ]]; then\n\treadonly COLOR_RESET='\\033[0m'\n\treadonly COLOR_RED='\\033[0;31m'\n\treadonly COLOR_GREEN='\\033[0;32m'\n\treadonly COLOR_YELLOW='\\033[0;33m'\n\treadonly COLOR_BLUE='\\033[0;34m'\n\treadonly COLOR_CYAN='\\033[0;36m'\n\treadonly COLOR_DIM='\\033[0;90m'\n\treadonly COLOR_BOLD='\\033[1m'\nelse\n\treadonly COLOR_RESET=''\n\treadonly COLOR_RED=''\n\treadonly COLOR_GREEN=''\n\treadonly COLOR_YELLOW=''\n\treadonly COLOR_BLUE=''\n\treadonly COLOR_CYAN=''\n\treadonly COLOR_DIM=''\n\treadonly COLOR_BOLD=''\nfi\n\n# Write to log file (no colors, with timestamp)\n_log_to_file() {\n\tif [[ -n \"$LOG_FILE\" ]]; then\n\t\techo \"$(date '+%Y-%m-%d %H:%M:%S') $*\" >>\"$LOG_FILE\"\n\tfi\n}\n\n# Logging functions\nlog_info() {\n\t[[ $OUTPUT_FORMAT == \"json\" ]] && return\n\techo -e \"${COLOR_BLUE}[INFO]${COLOR_RESET} $*\"\n\t_log_to_file \"[INFO] $*\"\n}\n\nlog_warn() {\n\t[[ $OUTPUT_FORMAT == \"json\" ]] && return\n\techo -e \"${COLOR_YELLOW}[WARN]${COLOR_RESET} $*\"\n\t_log_to_file \"[WARN] $*\"\n}\n\nlog_error() {\n\techo -e \"${COLOR_RED}[ERROR]${COLOR_RESET} $*\" >&2\n\t_log_to_file \"[ERROR] $*\"\n\tif [[ -n \"$LOG_FILE\" ]]; then\n\t\techo -e \"${COLOR_YELLOW}        Check the log file for details: ${LOG_FILE}${COLOR_RESET}\" >&2\n\tfi\n}\n\nlog_fatal() {\n\techo -e \"${COLOR_RED}[ERROR]${COLOR_RESET} $*\" >&2\n\t_log_to_file \"[FATAL] $*\"\n\tif [[ -n \"$LOG_FILE\" ]]; then\n\t\techo -e \"${COLOR_YELLOW}        Check the log file for details: ${LOG_FILE}${COLOR_RESET}\" >&2\n\t\t_log_to_file \"Script exited with error\"\n\tfi\n\texit 1\n}\n\nlog_success() {\n\t[[ $OUTPUT_FORMAT == \"json\" ]] && return\n\techo -e \"${COLOR_GREEN}[OK]${COLOR_RESET} $*\"\n\t_log_to_file \"[OK] $*\"\n}\n\nlog_debug() {\n\tif [[ $VERBOSE -eq 1 && $OUTPUT_FORMAT != \"json\" ]]; then\n\t\techo -e \"${COLOR_DIM}[DEBUG]${COLOR_RESET} $*\"\n\tfi\n\t_log_to_file \"[DEBUG] $*\"\n}\n\nlog_prompt() {\n\t# For user-facing prompts/questions (no prefix, just cyan)\n\t# Skip display in non-interactive mode\n\tif [[ $NON_INTERACTIVE_INSTALL != \"y\" ]]; then\n\t\techo -e \"${COLOR_CYAN}$*${COLOR_RESET}\"\n\tfi\n\t_log_to_file \"[PROMPT] $*\"\n}\n\nlog_header() {\n\t# For section headers\n\t# Skip display in non-interactive mode\n\tif [[ $NON_INTERACTIVE_INSTALL != \"y\" ]]; then\n\t\techo \"\"\n\t\techo -e \"${COLOR_BOLD}${COLOR_BLUE}=== $* ===${COLOR_RESET}\"\n\t\techo \"\"\n\tfi\n\t_log_to_file \"=== $* ===\"\n}\n\nlog_menu() {\n\t# For menu options - only show in interactive mode\n\tif [[ $NON_INTERACTIVE_INSTALL != \"y\" ]]; then\n\t\techo \"$@\"\n\tfi\n}\n\n# Run a command with optional output suppression\n# Usage: run_cmd \"description\" command [args...]\nrun_cmd() {\n\tlocal desc=\"$1\"\n\tshift\n\t# Display the command being run\n\techo -e \"${COLOR_DIM}> $*${COLOR_RESET}\"\n\t_log_to_file \"[CMD] $*\"\n\tif [[ $VERBOSE -eq 1 ]]; then\n\t\tif [[ -n \"$LOG_FILE\" ]]; then\n\t\t\t\"$@\" 2>&1 | tee -a \"$LOG_FILE\"\n\t\telse\n\t\t\t\"$@\"\n\t\tfi\n\telse\n\t\tif [[ -n \"$LOG_FILE\" ]]; then\n\t\t\t\"$@\" >>\"$LOG_FILE\" 2>&1\n\t\telse\n\t\t\t\"$@\" >/dev/null 2>&1\n\t\tfi\n\tfi\n\tlocal ret=$?\n\tif [[ $ret -eq 0 ]]; then\n\t\tlog_debug \"$desc completed successfully\"\n\telse\n\t\tlog_error \"$desc failed with exit code $ret\"\n\tfi\n\treturn $ret\n}\n\n# Run a command that must succeed, exit on failure\n# Usage: run_cmd_fatal \"description\" command [args...]\nrun_cmd_fatal() {\n\tlocal desc=\"$1\"\n\tshift\n\tif ! run_cmd \"$desc\" \"$@\"; then\n\t\tlog_fatal \"$desc failed\"\n\tfi\n}\n\n# =============================================================================\n# CLI Configuration\n# =============================================================================\nreadonly SCRIPT_NAME=\"openvpn-install\"\n\n# =============================================================================\n# Help Text Functions\n# =============================================================================\nshow_help() {\n\tcat <<-EOF\n\t\tOpenVPN installer and manager\n\n\t\tUsage: $SCRIPT_NAME <command> [options]\n\n\t\tCommands:\n\t\t\tinstall       Install and configure OpenVPN server\n\t\t\tuninstall     Remove OpenVPN server\n\t\t\tclient        Manage client certificates\n\t\t\tserver        Server management\n\t\t\tinteractive   Launch interactive menu\n\n\t\tGlobal Options:\n\t\t\t--verbose     Show detailed output\n\t\t\t--log <path>  Log file path (default: openvpn-install.log)\n\t\t\t--no-log      Disable file logging\n\t\t\t--no-color    Disable colored output\n\t\t\t-h, --help    Show help\n\n\t\tRun '$SCRIPT_NAME <command> --help' for command-specific help.\n\tEOF\n}\n\nshow_install_help() {\n\tcat <<-EOF\n\t\tInstall and configure OpenVPN server\n\n\t\tUsage: $SCRIPT_NAME install [options]\n\n\t\tOptions:\n\t\t\t-i, --interactive     Run interactive install wizard\n\n\t\tNetwork Options:\n\t\t\t--endpoint <host>     Public IP or hostname for clients (auto-detected)\n\t\t\t--endpoint-type <4|6> Endpoint IP version: 4 or 6 (default: 4)\n\t\t\t--ip <addr>           Server listening IP (auto-detected)\n\t\t\t--client-ipv4         Enable IPv4 for VPN clients (default: enabled)\n\t\t\t--no-client-ipv4      Disable IPv4 for VPN clients\n\t\t\t--client-ipv6         Enable IPv6 for VPN clients\n\t\t\t--no-client-ipv6      Disable IPv6 for VPN clients (default)\n\t\t\t--subnet-ipv4 <x.x.x.0>  IPv4 VPN subnet (default: 10.8.0.0)\n\t\t\t--subnet-ipv6 <prefix>   IPv6 VPN subnet (default: fd42:42:42:42::)\n\t\t\t--port <num>          OpenVPN port (default: 1194)\n\t\t\t--port-random         Use random port (49152-65535)\n\t\t\t--protocol <proto>    Protocol: udp or tcp (default: udp)\n\t\t\t--mtu <size>          Tunnel MTU (default: 1500)\n\n\t\tDNS Options:\n\t\t\t--dns <provider>      DNS provider (default: cloudflare)\n\t\t\t\tProviders: system, unbound, cloudflare, quad9, quad9-uncensored,\n\t\t\t\tfdn, dnswatch, opendns, google, yandex, adguard, nextdns, custom\n\t\t\t--dns-primary <ip>    Custom primary DNS (requires --dns custom)\n\t\t\t--dns-secondary <ip>  Custom secondary DNS (optional)\n\n\t\tSecurity Options:\n\t\t\t--cipher <cipher>     Data channel cipher (default: AES-128-GCM)\n\t\t\t\tCiphers: AES-128-GCM, AES-192-GCM, AES-256-GCM, AES-128-CBC,\n\t\t\t\tAES-192-CBC, AES-256-CBC, CHACHA20-POLY1305\n\t\t\t--cert-type <type>    Certificate type: ecdsa or rsa (default: ecdsa)\n\t\t\t--cert-curve <curve>  ECDSA curve (default: prime256v1)\n\t\t\t\tCurves: prime256v1, secp384r1, secp521r1\n\t\t\t--rsa-bits <size>     RSA key size: 2048, 3072, 4096 (default: 2048)\n\t\t\t--cc-cipher <cipher>  Control channel cipher (auto-selected)\n\t\t\t--tls-version-min <ver>  Minimum TLS version: 1.2 or 1.3 (default: 1.2)\n\t\t\t--tls-ciphersuites <list>  TLS 1.3 cipher suites, colon-separated\n\t\t\t--tls-groups <list>   Key exchange groups, colon-separated\n\t\t\t\t(default: X25519:prime256v1:secp384r1:secp521r1)\n\t\t\t--hmac <alg>          HMAC algorithm: SHA256, SHA384, SHA512 (default: SHA256)\n\t\t\t--tls-sig <mode>      TLS mode: crypt-v2, crypt, auth (default: crypt-v2)\n\t\t\t--auth-mode <mode>    Auth mode: pki, fingerprint (default: pki)\n\t\t\t\tfingerprint requires OpenVPN 2.6+\n\t\t\t--server-cert-days <n>  Server cert validity in days (default: 3650)\n\n\t\tOther Options:\n\t\t\t--multi-client        Allow same cert on multiple devices\n\n\t\tInitial Client Options:\n\t\t\t--client <name>       Initial client name (default: client)\n\t\t\t--client-password [p] Password-protect client (prompts if no value given)\n\t\t\t--client-cert-days <n>  Client cert validity in days (default: 3650)\n\t\t\t--no-client           Skip initial client creation\n\n\t\tExamples:\n\t\t\t$SCRIPT_NAME install\n\t\t\t$SCRIPT_NAME install --port 443 --protocol tcp\n\t\t\t$SCRIPT_NAME install --dns quad9 --cipher AES-256-GCM\n\t\t\t$SCRIPT_NAME install -i\n\tEOF\n}\n\nshow_uninstall_help() {\n\tcat <<-EOF\n\t\tRemove OpenVPN server\n\n\t\tUsage: $SCRIPT_NAME uninstall [options]\n\n\t\tOptions:\n\t\t\t-f, --force   Skip confirmation prompt\n\n\t\tExamples:\n\t\t\t$SCRIPT_NAME uninstall\n\t\t\t$SCRIPT_NAME uninstall --force\n\tEOF\n}\n\nshow_client_help() {\n\tcat <<-EOF\n\t\tManage client certificates\n\n\t\tUsage: $SCRIPT_NAME client <subcommand> [options]\n\n\t\tSubcommands:\n\t\t\tadd <name>     Add a new client\n\t\t\tlist           List all clients\n\t\t\trevoke <name>  Revoke a client certificate\n\t\t\trenew <name>   Renew a client certificate\n\n\t\tRun '$SCRIPT_NAME client <subcommand> --help' for more info.\n\tEOF\n}\n\nshow_client_add_help() {\n\tcat <<-EOF\n\t\tAdd a new VPN client\n\n\t\tUsage: $SCRIPT_NAME client add <name> [options]\n\n\t\tOptions:\n\t\t\t--password [pass]   Password-protect client (prompts if no value given)\n\t\t\t--cert-days <n>     Certificate validity in days (default: 3650)\n\t\t\t--output <path>     Output path for .ovpn file (default: ~/<name>.ovpn)\n\n\t\tExamples:\n\t\t\t$SCRIPT_NAME client add alice\n\t\t\t$SCRIPT_NAME client add bob --password\n\t\t\t$SCRIPT_NAME client add charlie --cert-days 365 --output /tmp/charlie.ovpn\n\tEOF\n}\n\nshow_client_list_help() {\n\tcat <<-EOF\n\t\tList all client certificates\n\n\t\tUsage: $SCRIPT_NAME client list [options]\n\n\t\tOptions:\n\t\t\t--format <fmt>  Output format: table or json (default: table)\n\n\t\tExamples:\n\t\t\t$SCRIPT_NAME client list\n\t\t\t$SCRIPT_NAME client list --format json\n\tEOF\n}\n\nshow_client_revoke_help() {\n\tcat <<-EOF\n\t\tRevoke a client certificate\n\n\t\tUsage: $SCRIPT_NAME client revoke <name> [options]\n\n\t\tOptions:\n\t\t\t-f, --force   Skip confirmation prompt\n\n\t\tExamples:\n\t\t\t$SCRIPT_NAME client revoke alice\n\t\t\t$SCRIPT_NAME client revoke bob --force\n\tEOF\n}\n\nshow_client_renew_help() {\n\tcat <<-EOF\n\t\tRenew a client certificate\n\n\t\tUsage: $SCRIPT_NAME client renew <name> [options]\n\n\t\tOptions:\n\t\t\t--cert-days <n>   New certificate validity in days (default: 3650)\n\n\t\tExamples:\n\t\t\t$SCRIPT_NAME client renew alice\n\t\t\t$SCRIPT_NAME client renew bob --cert-days 365\n\tEOF\n}\n\nshow_server_help() {\n\tcat <<-EOF\n\t\tServer management\n\n\t\tUsage: $SCRIPT_NAME server <subcommand> [options]\n\n\t\tSubcommands:\n\t\t\tstatus   List currently connected clients\n\t\t\trenew    Renew server certificate\n\n\t\tRun '$SCRIPT_NAME server <subcommand> --help' for more info.\n\tEOF\n}\n\nshow_server_status_help() {\n\tcat <<-EOF\n\t\tList currently connected clients\n\n\t\tNote: Client data is updated every 60 seconds by OpenVPN.\n\n\t\tUsage: $SCRIPT_NAME server status [options]\n\n\t\tOptions:\n\t\t\t--format <fmt>  Output format: table or json (default: table)\n\n\t\tExamples:\n\t\t\t$SCRIPT_NAME server status\n\t\t\t$SCRIPT_NAME server status --format json\n\tEOF\n}\n\nshow_server_renew_help() {\n\tcat <<-EOF\n\t\tRenew server certificate\n\n\t\tUsage: $SCRIPT_NAME server renew [options]\n\n\t\tOptions:\n\t\t\t--cert-days <n>   New certificate validity in days (default: 3650)\n\t\t\t-f, --force       Skip confirmation/warning\n\n\t\tExamples:\n\t\t\t$SCRIPT_NAME server renew\n\t\t\t$SCRIPT_NAME server renew --cert-days 1825\n\tEOF\n}\n\n# =============================================================================\n# CLI Command Handlers\n# =============================================================================\n\n# Check if OpenVPN is installed\nisOpenVPNInstalled() {\n\t[[ -e /etc/openvpn/server/server.conf ]]\n}\n\n# Require OpenVPN to be installed\nrequireOpenVPN() {\n\tif ! isOpenVPNInstalled; then\n\t\tlog_fatal \"OpenVPN is not installed. Run '$SCRIPT_NAME install' first.\"\n\tfi\n}\n\n# Require OpenVPN to NOT be installed\nrequireNoOpenVPN() {\n\tif isOpenVPNInstalled; then\n\t\tlog_fatal \"OpenVPN is already installed. Use '$SCRIPT_NAME client' to manage clients or '$SCRIPT_NAME uninstall' to remove.\"\n\tfi\n}\n\n# Parse DNS provider string to DNS number\nparse_dns_provider() {\n\tcase \"$1\" in\n\tsystem | unbound | cloudflare | quad9 | quad9-uncensored | fdn | dnswatch | opendns | google | yandex | adguard | nextdns | custom)\n\t\tDNS=\"$1\"\n\t\t;;\n\t*) log_fatal \"Invalid DNS provider: $1. See '$SCRIPT_NAME install --help' for valid providers.\" ;;\n\tesac\n}\n\n# Parse cipher string\nparse_cipher() {\n\tcase \"$1\" in\n\tAES-128-GCM | AES-192-GCM | AES-256-GCM | AES-128-CBC | AES-192-CBC | AES-256-CBC | CHACHA20-POLY1305)\n\t\tCIPHER=\"$1\"\n\t\t;;\n\t*) log_fatal \"Invalid cipher: $1. See '$SCRIPT_NAME install --help' for valid ciphers.\" ;;\n\tesac\n}\n\n# Parse curve string\nparse_curve() {\n\tcase \"$1\" in\n\tprime256v1 | secp384r1 | secp521r1) echo \"$1\" ;;\n\t*) log_fatal \"Invalid curve: $1. Valid curves: prime256v1, secp384r1, secp521r1\" ;;\n\tesac\n}\n\n# =============================================================================\n# Configuration Constants\n# =============================================================================\n# Protocol options\nreadonly PROTOCOLS=(\"udp\" \"tcp\")\n\n# DNS providers (use string names)\nreadonly DNS_PROVIDERS=(\"system\" \"unbound\" \"cloudflare\" \"quad9\" \"quad9-uncensored\" \"fdn\" \"dnswatch\" \"opendns\" \"google\" \"yandex\" \"adguard\" \"nextdns\" \"custom\")\n\n# Cipher options\nreadonly CIPHERS=(\"AES-128-GCM\" \"AES-192-GCM\" \"AES-256-GCM\" \"AES-128-CBC\" \"AES-192-CBC\" \"AES-256-CBC\" \"CHACHA20-POLY1305\")\n\n# Certificate types (use strings)\nreadonly CERT_TYPES=(\"ecdsa\" \"rsa\")\n\n# ECDSA curves\nreadonly CERT_CURVES=(\"prime256v1\" \"secp384r1\" \"secp521r1\")\n\n# RSA key sizes\nreadonly RSA_KEY_SIZES=(\"2048\" \"3072\" \"4096\")\n\n# TLS versions\nreadonly TLS_VERSIONS=(\"1.2\" \"1.3\")\n\n# TLS signature modes (use strings)\nreadonly TLS_SIG_MODES=(\"crypt-v2\" \"crypt\" \"auth\")\n\n# Authentication modes: pki (CA-based) or fingerprint (peer-fingerprint, OpenVPN 2.6+)\nreadonly AUTH_MODES=(\"pki\" \"fingerprint\")\n\n# HMAC algorithms\nreadonly HMAC_ALGS=(\"SHA256\" \"SHA384\" \"SHA512\")\n\n# TLS 1.3 cipher suite options\nreadonly TLS13_OPTIONS=(\"all\" \"aes-256-only\" \"aes-128-only\" \"chacha20-only\")\n\n# TLS groups options\nreadonly TLS_GROUPS_OPTIONS=(\"all\" \"x25519-only\" \"nist-only\")\n\n# =============================================================================\n# Set Installation Defaults\n# =============================================================================\n# Centralized function to set all defaults - called before configuration\nset_installation_defaults() {\n\t# Network\n\tENDPOINT_TYPE=\"${ENDPOINT_TYPE:-4}\"\n\tCLIENT_IPV4=\"${CLIENT_IPV4:-y}\"\n\tCLIENT_IPV6=\"${CLIENT_IPV6:-n}\"\n\tVPN_SUBNET_IPV4=\"${VPN_SUBNET_IPV4:-10.8.0.0}\"\n\tVPN_SUBNET_IPV6=\"${VPN_SUBNET_IPV6:-fd42:42:42:42::}\"\n\tPORT=\"${PORT:-1194}\"\n\tPROTOCOL=\"${PROTOCOL:-udp}\"\n\n\t# DNS (use string name)\n\tDNS=\"${DNS:-cloudflare}\"\n\n\t# Multi-client\n\tMULTI_CLIENT=\"${MULTI_CLIENT:-n}\"\n\n\t# Encryption\n\tCIPHER=\"${CIPHER:-AES-128-GCM}\"\n\tCERT_TYPE=\"${CERT_TYPE:-ecdsa}\"\n\tCERT_CURVE=\"${CERT_CURVE:-prime256v1}\"\n\tRSA_KEY_SIZE=\"${RSA_KEY_SIZE:-2048}\"\n\tTLS_VERSION_MIN=\"${TLS_VERSION_MIN:-1.2}\"\n\tTLS13_CIPHERSUITES=\"${TLS13_CIPHERSUITES:-TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256}\"\n\tTLS_GROUPS=\"${TLS_GROUPS:-X25519:prime256v1:secp384r1:secp521r1}\"\n\tHMAC_ALG=\"${HMAC_ALG:-SHA256}\"\n\tTLS_SIG=\"${TLS_SIG:-crypt-v2}\"\n\tAUTH_MODE=\"${AUTH_MODE:-pki}\"\n\n\t# Derive CC_CIPHER from CERT_TYPE if not set\n\tif [[ -z $CC_CIPHER ]]; then\n\t\tif [[ $CERT_TYPE == \"ecdsa\" ]]; then\n\t\t\tCC_CIPHER=\"TLS-ECDHE-ECDSA-WITH-AES-128-GCM-SHA256\"\n\t\telse\n\t\t\tCC_CIPHER=\"TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256\"\n\t\tfi\n\tfi\n\n\t# Client\n\tCLIENT=\"${CLIENT:-client}\"\n\tPASS=\"${PASS:-1}\"\n\tCLIENT_CERT_DURATION_DAYS=\"${CLIENT_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}\"\n\tSERVER_CERT_DURATION_DAYS=\"${SERVER_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}\"\n\n\t# Note: Gateway values (VPN_GATEWAY_IPV4, VPN_GATEWAY_IPV6) and IPV6_SUPPORT\n\t# are computed in prepare_network_config() which is called after validation\n}\n\n# Version comparison: returns 0 if version1 >= version2\nversion_ge() {\n\tlocal ver1=\"$1\" ver2=\"$2\"\n\t# Use sort -V for version comparison\n\t[[ \"$(printf '%s\\n%s' \"$ver1\" \"$ver2\" | sort -V | head -n1)\" == \"$ver2\" ]]\n}\n\n# Get installed OpenVPN version (e.g., \"2.6.12\")\nget_openvpn_version() {\n\topenvpn --version 2>/dev/null | head -1 | awk '{print $2}'\n}\n\n# Validation functions\nvalidate_port() {\n\tlocal port=\"$1\"\n\tif ! [[ \"$port\" =~ ^[0-9]+$ ]] || [[ \"$port\" -lt 1 ]] || [[ \"$port\" -gt 65535 ]]; then\n\t\tlog_fatal \"Invalid port: $port. Must be a number between 1 and 65535.\"\n\tfi\n}\n\nvalidate_subnet_ipv4() {\n\tlocal subnet=\"$1\"\n\t# Check format: x.x.x.0 where x is 0-255\n\tif ! [[ \"$subnet\" =~ ^([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.0$ ]]; then\n\t\tlog_fatal \"Invalid IPv4 subnet: $subnet. Must be in format x.x.x.0 (e.g., 10.8.0.0)\"\n\tfi\n\tlocal octet1=\"${BASH_REMATCH[1]}\"\n\tlocal octet2=\"${BASH_REMATCH[2]}\"\n\tlocal octet3=\"${BASH_REMATCH[3]}\"\n\t# Validate each octet is 0-255\n\tif [[ \"$octet1\" -gt 255 ]] || [[ \"$octet2\" -gt 255 ]] || [[ \"$octet3\" -gt 255 ]]; then\n\t\tlog_fatal \"Invalid IPv4 subnet: $subnet. Octets must be 0-255.\"\n\tfi\n\t# Check for RFC1918 private address ranges\n\tif ! { [[ \"$octet1\" -eq 10 ]] ||\n\t\t[[ \"$octet1\" -eq 172 && \"$octet2\" -ge 16 && \"$octet2\" -le 31 ]] ||\n\t\t[[ \"$octet1\" -eq 192 && \"$octet2\" -eq 168 ]]; }; then\n\t\tlog_fatal \"Invalid IPv4 subnet: $subnet. Must be a private network (10.x.x.0, 172.16-31.x.0, or 192.168.x.0).\"\n\tfi\n}\n\nvalidate_subnet_ipv6() {\n\tlocal subnet=\"$1\"\n\t# Accept format: IPv6 address ending with :: (prefix only, no CIDR notation here)\n\t# We expect formats like: fd42:42:42:42:: or fdxx:xxxx:xxxx:xxxx::\n\t# The script will append /112 for the server directive\n\n\t# IPv6 ULA validation (fd00::/8 range with at least /48 prefix)\n\t# ULA format: fdxx:xxxx:xxxx:: or fdxx:xxxx:xxxx:xxxx:: where x is hex\n\tif ! [[ \"$subnet\" =~ ^fd[0-9a-fA-F]{2}(:[0-9a-fA-F]{1,4}){2,5}::$ ]]; then\n\t\tlog_fatal \"Invalid IPv6 subnet: $subnet. Must be a ULA address with at least a /48 prefix, ending with :: (e.g., fd42:42:42::)\"\n\tfi\n}\n\nvalidate_positive_int() {\n\tlocal value=\"$1\"\n\tlocal name=\"$2\"\n\tif ! [[ \"$value\" =~ ^[0-9]+$ ]] || [[ \"$value\" -lt 1 ]]; then\n\t\tlog_fatal \"Invalid $name: $value. Must be a positive integer.\"\n\tfi\n}\n\nvalidate_mtu() {\n\tlocal mtu=\"$1\"\n\tif ! [[ \"$mtu\" =~ ^[0-9]+$ ]] || [[ \"$mtu\" -lt 576 ]] || [[ \"$mtu\" -gt 65535 ]]; then\n\t\tlog_fatal \"Invalid MTU: $mtu. Must be between 576 and 65535.\"\n\tfi\n}\n\n# Maximum length for client names (OpenSSL CN limit)\nreadonly MAX_CLIENT_NAME_LENGTH=64\n\n# Check if client name is valid (non-fatal, returns true/false)\nis_valid_client_name() {\n\tlocal name=\"$1\"\n\t[[ \"$name\" =~ ^[a-zA-Z0-9_-]+$ ]] && [[ ${#name} -le $MAX_CLIENT_NAME_LENGTH ]]\n}\n\n# Validate client name and exit with error if invalid\nvalidate_client_name() {\n\tlocal name=\"$1\"\n\tif [[ -z \"$name\" ]]; then\n\t\tlog_fatal \"Client name cannot be empty.\"\n\tfi\n\tif ! [[ \"$name\" =~ ^[a-zA-Z0-9_-]+$ ]]; then\n\t\tlog_fatal \"Invalid client name: $name. Only alphanumeric characters, underscores, and hyphens are allowed.\"\n\tfi\n\tif [[ ${#name} -gt $MAX_CLIENT_NAME_LENGTH ]]; then\n\t\tlog_fatal \"Client name too long: ${#name} characters. Maximum is $MAX_CLIENT_NAME_LENGTH characters (OpenSSL CN limit).\"\n\tfi\n}\n\n# Validate all configuration values (catches invalid env vars in non-interactive mode)\nvalidate_configuration() {\n\t# Validate PROTOCOL\n\tcase \"$PROTOCOL\" in\n\tudp | tcp) ;;\n\t*) log_fatal \"Invalid protocol: $PROTOCOL. Must be 'udp' or 'tcp'.\" ;;\n\tesac\n\n\t# Validate DNS\n\tcase \"$DNS\" in\n\tsystem | unbound | cloudflare | quad9 | quad9-uncensored | fdn | dnswatch | opendns | google | yandex | adguard | nextdns | custom) ;;\n\t*) log_fatal \"Invalid DNS provider: $DNS. Valid providers: system, unbound, cloudflare, quad9, quad9-uncensored, fdn, dnswatch, opendns, google, yandex, adguard, nextdns, custom\" ;;\n\tesac\n\n\t# Validate CERT_TYPE\n\tcase \"$CERT_TYPE\" in\n\tecdsa | rsa) ;;\n\t*) log_fatal \"Invalid cert type: $CERT_TYPE. Must be 'ecdsa' or 'rsa'.\" ;;\n\tesac\n\n\t# Validate TLS_SIG\n\tcase \"$TLS_SIG\" in\n\tcrypt-v2 | crypt | auth) ;;\n\t*) log_fatal \"Invalid TLS signature mode: $TLS_SIG. Must be 'crypt-v2', 'crypt', or 'auth'.\" ;;\n\tesac\n\n\t# Validate AUTH_MODE\n\tcase \"$AUTH_MODE\" in\n\tpki | fingerprint) ;;\n\t*) log_fatal \"Invalid auth mode: $AUTH_MODE. Must be 'pki' or 'fingerprint'.\" ;;\n\tesac\n\n\t# Fingerprint mode requires OpenVPN 2.6+\n\tif [[ $AUTH_MODE == \"fingerprint\" ]]; then\n\t\tlocal openvpn_ver\n\t\topenvpn_ver=$(get_openvpn_version)\n\t\tif [[ -n \"$openvpn_ver\" ]] && ! version_ge \"$openvpn_ver\" \"2.6.0\"; then\n\t\t\tlog_fatal \"Fingerprint mode requires OpenVPN 2.6.0 or later. Installed version: $openvpn_ver\"\n\t\tfi\n\tfi\n\n\t# Validate PORT\n\tif ! [[ \"$PORT\" =~ ^[0-9]+$ ]] || [[ \"$PORT\" -lt 1 ]] || [[ \"$PORT\" -gt 65535 ]]; then\n\t\tlog_fatal \"Invalid port: $PORT. Must be a number between 1 and 65535.\"\n\tfi\n\n\t# Validate CLIENT_IPV4/CLIENT_IPV6\n\tif [[ $CLIENT_IPV4 != \"y\" ]] && [[ $CLIENT_IPV6 != \"y\" ]]; then\n\t\tlog_fatal \"At least one of CLIENT_IPV4 or CLIENT_IPV6 must be 'y'\"\n\tfi\n\n\t# Validate ENDPOINT_TYPE\n\tcase \"$ENDPOINT_TYPE\" in\n\t4 | 6) ;;\n\t*) log_fatal \"Invalid endpoint type: $ENDPOINT_TYPE. Must be '4' or '6'.\" ;;\n\tesac\n\n\t# Validate CIPHER\n\tcase \"$CIPHER\" in\n\tAES-128-GCM | AES-192-GCM | AES-256-GCM | AES-128-CBC | AES-192-CBC | AES-256-CBC | CHACHA20-POLY1305) ;;\n\t*) 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\" ;;\n\tesac\n\n\t# Validate CERT_CURVE (only if ECDSA)\n\tif [[ $CERT_TYPE == \"ecdsa\" ]]; then\n\t\tcase \"$CERT_CURVE\" in\n\t\tprime256v1 | secp384r1 | secp521r1) ;;\n\t\t*) log_fatal \"Invalid cert curve: $CERT_CURVE. Must be 'prime256v1', 'secp384r1', or 'secp521r1'.\" ;;\n\t\tesac\n\tfi\n\n\t# Validate RSA_KEY_SIZE (only if RSA)\n\tif [[ $CERT_TYPE == \"rsa\" ]]; then\n\t\tcase \"$RSA_KEY_SIZE\" in\n\t\t2048 | 3072 | 4096) ;;\n\t\t*) log_fatal \"Invalid RSA key size: $RSA_KEY_SIZE. Must be 2048, 3072, or 4096.\" ;;\n\t\tesac\n\tfi\n\n\t# Validate TLS_VERSION_MIN\n\tcase \"$TLS_VERSION_MIN\" in\n\t1.2 | 1.3) ;;\n\t*) log_fatal \"Invalid TLS version: $TLS_VERSION_MIN. Must be '1.2' or '1.3'.\" ;;\n\tesac\n\n\t# Validate HMAC_ALG\n\tcase \"$HMAC_ALG\" in\n\tSHA256 | SHA384 | SHA512) ;;\n\t*) log_fatal \"Invalid HMAC algorithm: $HMAC_ALG. Must be SHA256, SHA384, or SHA512.\" ;;\n\tesac\n\n\t# Validate MTU if set\n\tif [[ -n $MTU ]]; then\n\t\tif ! [[ \"$MTU\" =~ ^[0-9]+$ ]] || [[ \"$MTU\" -lt 576 ]] || [[ \"$MTU\" -gt 65535 ]]; then\n\t\t\tlog_fatal \"Invalid MTU: $MTU. Must be a number between 576 and 65535.\"\n\t\tfi\n\tfi\n\n\t# Validate custom DNS if selected\n\tif [[ $DNS == \"custom\" ]] && [[ -z $DNS1 ]]; then\n\t\tlog_fatal \"Custom DNS selected but DNS1 (primary DNS) is not set. Use --dns-primary to specify.\"\n\tfi\n\n\t# Validate VPN subnets using the dedicated validation functions\n\t# These check format, octet ranges, and RFC1918/ULA compliance\n\tif [[ -n $VPN_SUBNET_IPV4 ]]; then\n\t\tvalidate_subnet_ipv4 \"$VPN_SUBNET_IPV4\"\n\tfi\n\n\tif [[ $CLIENT_IPV6 == \"y\" ]] && [[ -n $VPN_SUBNET_IPV6 ]]; then\n\t\tvalidate_subnet_ipv6 \"$VPN_SUBNET_IPV6\"\n\tfi\n}\n\n# =============================================================================\n# Interactive Helper Functions\n# =============================================================================\n# Generic select-from-menu function for arrays\n# Usage: select_from_array \"prompt\" array_name \"default_value\" result_var\n# Note: Uses namerefs (-n) for arrays\nselect_from_array() {\n\tlocal prompt=\"$1\"\n\tlocal -n _options_ref=\"$2\"\n\tlocal default=\"$3\"\n\tlocal -n _result_ref=\"$4\"\n\n\t# If already set (non-interactive mode), just return\n\tif [[ -n $_result_ref ]]; then\n\t\treturn\n\tfi\n\n\t# Find default index (1-based for display)\n\tlocal default_idx=1\n\tfor i in \"${!_options_ref[@]}\"; do\n\t\tif [[ \"${_options_ref[$i]}\" == \"$default\" ]]; then\n\t\t\tdefault_idx=$((i + 1))\n\t\t\tbreak\n\t\tfi\n\tdone\n\n\t# Display menu\n\tlocal count=${#_options_ref[@]}\n\tfor i in \"${!_options_ref[@]}\"; do\n\t\tlog_menu \"   $((i + 1))) ${_options_ref[$i]}\"\n\tdone\n\n\t# Read selection\n\tlocal choice\n\tuntil [[ $choice =~ ^[0-9]+$ ]] && ((choice >= 1 && choice <= count)); do\n\t\tread -rp \"$prompt [1-$count]: \" -e -i \"$default_idx\" choice\n\tdone\n\n\t_result_ref=\"${_options_ref[$((choice - 1))]}\"\n}\n\n# Select with custom labels (for menu items that need different display text)\n# Usage: select_with_labels \"prompt\" labels_array values_array \"default_value\" result_var\nselect_with_labels() {\n\tlocal prompt=\"$1\"\n\tlocal -n _labels_ref=\"$2\"\n\tlocal -n _values_ref=\"$3\"\n\tlocal default=\"$4\"\n\tlocal -n _result_ref=\"$5\"\n\n\t# If already set (non-interactive mode), just return\n\tif [[ -n $_result_ref ]]; then\n\t\treturn\n\tfi\n\n\t# Find default index\n\tlocal default_idx=1\n\tfor i in \"${!_values_ref[@]}\"; do\n\t\tif [[ \"${_values_ref[$i]}\" == \"$default\" ]]; then\n\t\t\tdefault_idx=$((i + 1))\n\t\t\tbreak\n\t\tfi\n\tdone\n\n\t# Display menu\n\tlocal count=${#_labels_ref[@]}\n\tfor i in \"${!_labels_ref[@]}\"; do\n\t\tlog_menu \"   $((i + 1))) ${_labels_ref[$i]}\"\n\tdone\n\n\t# Read selection\n\tlocal choice\n\tuntil [[ $choice =~ ^[0-9]+$ ]] && ((choice >= 1 && choice <= count)); do\n\t\tread -rp \"$prompt [1-$count]: \" -e -i \"$default_idx\" choice\n\tdone\n\n\t_result_ref=\"${_values_ref[$((choice - 1))]}\"\n}\n\n# Prompt for yes/no with default\n# Usage: prompt_yes_no \"prompt\" \"default\" result_var\nprompt_yes_no() {\n\tlocal prompt=\"$1\"\n\tlocal default=\"$2\"\n\tlocal -n _result_ref=\"$3\"\n\n\t# If already set, just return\n\tif [[ $_result_ref =~ ^[yn]$ ]]; then\n\t\treturn\n\tfi\n\n\tuntil [[ $_result_ref =~ ^[yn]$ ]]; do\n\t\tread -rp \"$prompt [y/n]: \" -e -i \"$default\" _result_ref\n\tdone\n}\n\n# Prompt for a value with validation function\n# Usage: prompt_validated \"prompt\" \"validator_func\" \"default\" result_var\n# The validator should return 0 for valid, non-0 for invalid\nprompt_validated() {\n\tlocal prompt=\"$1\"\n\tlocal validator=\"$2\"\n\tlocal default=\"$3\"\n\tlocal -n _result_ref=\"$4\"\n\n\t# If already set and valid, return\n\tif [[ -n $_result_ref ]] && $validator \"$_result_ref\" 2>/dev/null; then\n\t\treturn\n\tfi\n\n\t_result_ref=\"\"\n\tuntil [[ -n $_result_ref ]] && $validator \"$_result_ref\" 2>/dev/null; do\n\t\tread -rp \"$prompt: \" -e -i \"$default\" _result_ref\n\tdone\n}\n\n# Non-fatal port validator (returns 0/1)\nis_valid_port() {\n\tlocal port=\"$1\"\n\t[[ \"$port\" =~ ^[0-9]+$ ]] && ((port >= 1 && port <= 65535))\n}\n\n# Non-fatal MTU validator (returns 0/1)\nis_valid_mtu() {\n\tlocal mtu=\"$1\"\n\t[[ \"$mtu\" =~ ^[0-9]+$ ]] && ((mtu >= 576 && mtu <= 65535))\n}\n\n# Handle install command\ncmd_install() {\n\tlocal interactive=false\n\tlocal no_client=false\n\tlocal client_password_flag=false\n\tlocal client_password_value=\"\"\n\n\twhile [[ $# -gt 0 ]]; do\n\t\tcase \"$1\" in\n\t\t-i | --interactive)\n\t\t\tinteractive=true\n\t\t\tshift\n\t\t\t;;\n\t\t--endpoint)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--endpoint requires an argument\"\n\t\t\tENDPOINT=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--ip)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--ip requires an argument\"\n\t\t\tIP=\"$2\"\n\t\t\tAPPROVE_IP=y\n\t\t\tshift 2\n\t\t\t;;\n\t\t--endpoint-type)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--endpoint-type requires an argument\"\n\t\t\tcase \"$2\" in\n\t\t\t4) ENDPOINT_TYPE=\"4\" ;;\n\t\t\t6) ENDPOINT_TYPE=\"6\" ;;\n\t\t\t*) log_fatal \"Invalid endpoint type: $2. Use '4' or '6'.\" ;;\n\t\t\tesac\n\t\t\tshift 2\n\t\t\t;;\n\t\t--client-ipv4)\n\t\t\tCLIENT_IPV4=y\n\t\t\tshift\n\t\t\t;;\n\t\t--no-client-ipv4)\n\t\t\tCLIENT_IPV4=n\n\t\t\tshift\n\t\t\t;;\n\t\t--client-ipv6)\n\t\t\tCLIENT_IPV6=y\n\t\t\tshift\n\t\t\t;;\n\t\t--no-client-ipv6)\n\t\t\tCLIENT_IPV6=n\n\t\t\tshift\n\t\t\t;;\n\t\t--ipv6)\n\t\t\t# Legacy flag: enable IPv6 for clients (backward compatibility)\n\t\t\tCLIENT_IPV6=y\n\t\t\tshift\n\t\t\t;;\n\t\t--subnet-ipv4)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--subnet-ipv4 requires an argument\"\n\t\t\tvalidate_subnet_ipv4 \"$2\"\n\t\t\tVPN_SUBNET_IPV4=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--subnet-ipv6)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--subnet-ipv6 requires an argument\"\n\t\t\tvalidate_subnet_ipv6 \"$2\"\n\t\t\tVPN_SUBNET_IPV6=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--subnet)\n\t\t\t# Legacy flag: --subnet now maps to --subnet-ipv4\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--subnet requires an argument\"\n\t\t\tvalidate_subnet_ipv4 \"$2\"\n\t\t\tVPN_SUBNET_IPV4=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--port)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--port requires an argument\"\n\t\t\tvalidate_port \"$2\"\n\t\t\tPORT=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--port-random)\n\t\t\tPORT=\"random\"\n\t\t\tshift\n\t\t\t;;\n\t\t--protocol)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--protocol requires an argument\"\n\t\t\tcase \"$2\" in\n\t\t\tudp | tcp)\n\t\t\t\tPROTOCOL=\"$2\"\n\t\t\t\t;;\n\t\t\t*) log_fatal \"Invalid protocol: $2. Use 'udp' or 'tcp'.\" ;;\n\t\t\tesac\n\t\t\tshift 2\n\t\t\t;;\n\t\t--mtu)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--mtu requires an argument\"\n\t\t\tvalidate_mtu \"$2\"\n\t\t\tMTU=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--dns)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--dns requires an argument\"\n\t\t\tparse_dns_provider \"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--dns-primary)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--dns-primary requires an argument\"\n\t\t\tDNS1=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--dns-secondary)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--dns-secondary requires an argument\"\n\t\t\tDNS2=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--multi-client)\n\t\t\tMULTI_CLIENT=y\n\t\t\tshift\n\t\t\t;;\n\t\t--cipher)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--cipher requires an argument\"\n\t\t\tparse_cipher \"$2\"\n\t\t\tCUSTOMIZE_ENC=y\n\t\t\tshift 2\n\t\t\t;;\n\t\t--cert-type)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--cert-type requires an argument\"\n\t\t\tcase \"$2\" in\n\t\t\tecdsa | rsa) CERT_TYPE=\"$2\" ;;\n\t\t\t*) log_fatal \"Invalid cert-type: $2. Use 'ecdsa' or 'rsa'.\" ;;\n\t\t\tesac\n\t\t\tshift 2\n\t\t\t;;\n\t\t--cert-curve)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--cert-curve requires an argument\"\n\t\t\tCERT_CURVE=$(parse_curve \"$2\")\n\t\t\tCUSTOMIZE_ENC=y\n\t\t\tshift 2\n\t\t\t;;\n\t\t--rsa-bits)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--rsa-bits requires an argument\"\n\t\t\tcase \"$2\" in\n\t\t\t2048 | 3072 | 4096) RSA_KEY_SIZE=\"$2\" ;;\n\t\t\t*) log_fatal \"Invalid RSA key size: $2. Use 2048, 3072, or 4096.\" ;;\n\t\t\tesac\n\t\t\tCUSTOMIZE_ENC=y\n\t\t\tshift 2\n\t\t\t;;\n\t\t--cc-cipher)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--cc-cipher requires an argument\"\n\t\t\tCC_CIPHER=\"$2\"\n\t\t\tCUSTOMIZE_ENC=y\n\t\t\tshift 2\n\t\t\t;;\n\t\t--tls-ciphersuites)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--tls-ciphersuites requires an argument\"\n\t\t\tTLS13_CIPHERSUITES=\"$2\"\n\t\t\tCUSTOMIZE_ENC=y\n\t\t\tshift 2\n\t\t\t;;\n\t\t--tls-version-min)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--tls-version-min requires an argument\"\n\t\t\tcase \"$2\" in\n\t\t\t1.2 | 1.3) TLS_VERSION_MIN=\"$2\" ;;\n\t\t\t*) log_fatal \"Invalid TLS version: $2. Use '1.2' or '1.3'.\" ;;\n\t\t\tesac\n\t\t\tCUSTOMIZE_ENC=y\n\t\t\tshift 2\n\t\t\t;;\n\t\t--tls-groups)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--tls-groups requires an argument\"\n\t\t\tTLS_GROUPS=\"$2\"\n\t\t\tCUSTOMIZE_ENC=y\n\t\t\tshift 2\n\t\t\t;;\n\t\t--hmac)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--hmac requires an argument\"\n\t\t\tcase \"$2\" in\n\t\t\tSHA256 | SHA384 | SHA512) HMAC_ALG=\"$2\" ;;\n\t\t\t*) log_fatal \"Invalid HMAC algorithm: $2. Use SHA256, SHA384, or SHA512.\" ;;\n\t\t\tesac\n\t\t\tCUSTOMIZE_ENC=y\n\t\t\tshift 2\n\t\t\t;;\n\t\t--tls-sig)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--tls-sig requires an argument\"\n\t\t\tcase \"$2\" in\n\t\t\tcrypt-v2 | crypt | auth) TLS_SIG=\"$2\" ;;\n\t\t\t*) log_fatal \"Invalid TLS mode: $2. Use 'crypt-v2', 'crypt', or 'auth'.\" ;;\n\t\t\tesac\n\t\t\tshift 2\n\t\t\t;;\n\t\t--auth-mode)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--auth-mode requires an argument\"\n\t\t\tcase \"$2\" in\n\t\t\tpki | fingerprint) AUTH_MODE=\"$2\" ;;\n\t\t\t*) log_fatal \"Invalid auth mode: $2. Use 'pki' or 'fingerprint'.\" ;;\n\t\t\tesac\n\t\t\tshift 2\n\t\t\t;;\n\t\t--server-cert-days)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--server-cert-days requires an argument\"\n\t\t\tvalidate_positive_int \"$2\" \"server-cert-days\"\n\t\t\tSERVER_CERT_DURATION_DAYS=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--client)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--client requires an argument\"\n\t\t\tvalidate_client_name \"$2\"\n\t\t\tCLIENT=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--client-password)\n\t\t\tclient_password_flag=true\n\t\t\t# Check if next arg is a value or another flag\n\t\t\tif [[ -n \"${2:-}\" ]] && [[ ! \"$2\" =~ ^- ]]; then\n\t\t\t\tclient_password_value=\"$2\"\n\t\t\t\tshift\n\t\t\tfi\n\t\t\tshift\n\t\t\t;;\n\t\t--client-cert-days)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--client-cert-days requires an argument\"\n\t\t\tvalidate_positive_int \"$2\" \"client-cert-days\"\n\t\t\tCLIENT_CERT_DURATION_DAYS=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--no-client)\n\t\t\tno_client=true\n\t\t\tshift\n\t\t\t;;\n\t\t-h | --help)\n\t\t\tshow_install_help\n\t\t\texit 0\n\t\t\t;;\n\t\t*)\n\t\t\tlog_fatal \"Unknown option: $1. See '$SCRIPT_NAME install --help' for usage.\"\n\t\t\t;;\n\t\tesac\n\tdone\n\n\t# Validate custom DNS settings\n\tif [[ -n \"${DNS1:-}\" || -n \"${DNS2:-}\" ]] && [[ \"${DNS:-}\" != \"custom\" ]]; then\n\t\tlog_fatal \"--dns-primary and --dns-secondary require --dns custom\"\n\tfi\n\n\t# Check if already installed\n\trequireNoOpenVPN\n\n\tif [[ $interactive == true ]]; then\n\t\t# Run interactive installer\n\t\tinstallQuestions\n\telse\n\t\t# Non-interactive mode - set flags and defaults\n\t\tNON_INTERACTIVE_INSTALL=y\n\t\tAPPROVE_INSTALL=y\n\t\tAPPROVE_IP=${APPROVE_IP:-y}\n\t\tCONTINUE=y\n\n\t\t# Handle random port\n\t\tif [[ $PORT == \"random\" ]]; then\n\t\t\tPORT=$(shuf -i 49152-65535 -n1)\n\t\t\tlog_info \"Random Port: $PORT\"\n\t\tfi\n\n\t\t# Client setup\n\t\tif [[ $no_client == true ]]; then\n\t\t\tNEW_CLIENT=n\n\t\telse\n\t\t\tNEW_CLIENT=y\n\t\t\tif [[ $client_password_flag == true ]]; then\n\t\t\t\tPASS=2\n\t\t\t\tif [[ -n \"$client_password_value\" ]]; then\n\t\t\t\t\tPASSPHRASE=\"$client_password_value\"\n\t\t\t\tfi\n\t\t\tfi\n\t\tfi\n\n\t\t# Set all defaults for any unset values\n\t\tset_installation_defaults\n\n\t\t# Validate configuration values (catches invalid env vars)\n\t\tvalidate_configuration\n\n\t\t# Detect IPs and set up network config (interactive mode does this in installQuestions)\n\t\tdetect_server_ips\n\tfi\n\n\t# Prepare derived network configuration (gateways, etc.)\n\tprepare_network_config\n\n\tinstallOpenVPN\n}\n\n# Handle uninstall command\ncmd_uninstall() {\n\tlocal force=false\n\n\twhile [[ $# -gt 0 ]]; do\n\t\tcase \"$1\" in\n\t\t-f | --force)\n\t\t\tforce=true\n\t\t\tshift\n\t\t\t;;\n\t\t-h | --help)\n\t\t\tshow_uninstall_help\n\t\t\texit 0\n\t\t\t;;\n\t\t*)\n\t\t\tlog_fatal \"Unknown option: $1. See '$SCRIPT_NAME uninstall --help' for usage.\"\n\t\t\t;;\n\t\tesac\n\tdone\n\n\trequireOpenVPN\n\n\tif [[ $force == true ]]; then\n\t\tREMOVE=y\n\tfi\n\n\tremoveOpenVPN\n}\n\n# Handle client command\ncmd_client() {\n\tlocal subcmd=\"${1:-}\"\n\tshift || true\n\n\tcase \"$subcmd\" in\n\t\"\" | \"-h\" | \"--help\")\n\t\tshow_client_help\n\t\texit 0\n\t\t;;\n\tadd)\n\t\tcmd_client_add \"$@\"\n\t\t;;\n\tlist)\n\t\tcmd_client_list \"$@\"\n\t\t;;\n\trevoke)\n\t\tcmd_client_revoke \"$@\"\n\t\t;;\n\trenew)\n\t\tcmd_client_renew \"$@\"\n\t\t;;\n\t*)\n\t\tlog_fatal \"Unknown client subcommand: $subcmd. See '$SCRIPT_NAME client --help' for usage.\"\n\t\t;;\n\tesac\n}\n\n# Handle client add command\ncmd_client_add() {\n\tlocal client_name=\"\"\n\tlocal password_flag=false\n\tlocal password_value=\"\"\n\n\t# First non-flag argument is the client name\n\twhile [[ $# -gt 0 ]]; do\n\t\tcase \"$1\" in\n\t\t--password)\n\t\t\tpassword_flag=true\n\t\t\t# Check if next arg is a value or another flag\n\t\t\tif [[ -n \"${2:-}\" ]] && [[ ! \"$2\" =~ ^- ]]; then\n\t\t\t\tpassword_value=\"$2\"\n\t\t\t\tshift\n\t\t\tfi\n\t\t\tshift\n\t\t\t;;\n\t\t--cert-days)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--cert-days requires an argument\"\n\t\t\tvalidate_positive_int \"$2\" \"cert-days\"\n\t\t\tCLIENT_CERT_DURATION_DAYS=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--output)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--output requires an argument\"\n\t\t\tCLIENT_FILEPATH=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t-h | --help)\n\t\t\tshow_client_add_help\n\t\t\texit 0\n\t\t\t;;\n\t\t-*)\n\t\t\tlog_fatal \"Unknown option: $1. See '$SCRIPT_NAME client add --help' for usage.\"\n\t\t\t;;\n\t\t*)\n\t\t\tif [[ -z \"$client_name\" ]]; then\n\t\t\t\tclient_name=\"$1\"\n\t\t\telse\n\t\t\t\tlog_fatal \"Unexpected argument: $1\"\n\t\t\tfi\n\t\t\tshift\n\t\t\t;;\n\t\tesac\n\tdone\n\n\t[[ -z \"$client_name\" ]] && log_fatal \"Client name is required. See '$SCRIPT_NAME client add --help' for usage.\"\n\tvalidate_client_name \"$client_name\"\n\n\trequireOpenVPN\n\n\t# Set up variables for newClient function\n\tCLIENT=\"$client_name\"\n\tCLIENT_CERT_DURATION_DAYS=${CLIENT_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}\n\n\tif [[ $password_flag == true ]]; then\n\t\tPASS=2\n\t\tif [[ -n \"$password_value\" ]]; then\n\t\t\tPASSPHRASE=\"$password_value\"\n\t\tfi\n\telse\n\t\tPASS=1\n\tfi\n\n\tnewClient\n\texit 0\n}\n\n# Handle client list command\ncmd_client_list() {\n\tlocal format=\"table\"\n\n\twhile [[ $# -gt 0 ]]; do\n\t\tcase \"$1\" in\n\t\t--format)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--format requires an argument\"\n\t\t\tcase \"$2\" in\n\t\t\ttable | json) format=\"$2\" ;;\n\t\t\t*) log_fatal \"Invalid format: $2. Use 'table' or 'json'.\" ;;\n\t\t\tesac\n\t\t\tshift 2\n\t\t\t;;\n\t\t-h | --help)\n\t\t\tshow_client_list_help\n\t\t\texit 0\n\t\t\t;;\n\t\t*)\n\t\t\tlog_fatal \"Unknown option: $1. See '$SCRIPT_NAME client list --help' for usage.\"\n\t\t\t;;\n\t\tesac\n\tdone\n\n\trequireOpenVPN\n\n\tOUTPUT_FORMAT=\"$format\" listClients\n}\n\n# Handle client revoke command\ncmd_client_revoke() {\n\tlocal client_name=\"\"\n\tlocal force=false\n\n\twhile [[ $# -gt 0 ]]; do\n\t\tcase \"$1\" in\n\t\t-f | --force)\n\t\t\tforce=true\n\t\t\tshift\n\t\t\t;;\n\t\t-h | --help)\n\t\t\tshow_client_revoke_help\n\t\t\texit 0\n\t\t\t;;\n\t\t-*)\n\t\t\tlog_fatal \"Unknown option: $1. See '$SCRIPT_NAME client revoke --help' for usage.\"\n\t\t\t;;\n\t\t*)\n\t\t\tif [[ -z \"$client_name\" ]]; then\n\t\t\t\tclient_name=\"$1\"\n\t\t\telse\n\t\t\t\tlog_fatal \"Unexpected argument: $1\"\n\t\t\tfi\n\t\t\tshift\n\t\t\t;;\n\t\tesac\n\tdone\n\n\t[[ -z \"$client_name\" ]] && log_fatal \"Client name is required. See '$SCRIPT_NAME client revoke --help' for usage.\"\n\n\trequireOpenVPN\n\n\tCLIENT=\"$client_name\"\n\tif [[ $force == true ]]; then\n\t\tREVOKE_CONFIRM=y\n\tfi\n\n\trevokeClient\n}\n\n# Handle client renew command\ncmd_client_renew() {\n\tlocal client_name=\"\"\n\n\twhile [[ $# -gt 0 ]]; do\n\t\tcase \"$1\" in\n\t\t--cert-days)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--cert-days requires an argument\"\n\t\t\tvalidate_positive_int \"$2\" \"cert-days\"\n\t\t\tCLIENT_CERT_DURATION_DAYS=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t-h | --help)\n\t\t\tshow_client_renew_help\n\t\t\texit 0\n\t\t\t;;\n\t\t-*)\n\t\t\tlog_fatal \"Unknown option: $1. See '$SCRIPT_NAME client renew --help' for usage.\"\n\t\t\t;;\n\t\t*)\n\t\t\tif [[ -z \"$client_name\" ]]; then\n\t\t\t\tclient_name=\"$1\"\n\t\t\telse\n\t\t\t\tlog_fatal \"Unexpected argument: $1\"\n\t\t\tfi\n\t\t\tshift\n\t\t\t;;\n\t\tesac\n\tdone\n\n\t[[ -z \"$client_name\" ]] && log_fatal \"Client name is required. See '$SCRIPT_NAME client renew --help' for usage.\"\n\n\trequireOpenVPN\n\n\tCLIENT=\"$client_name\"\n\tCLIENT_CERT_DURATION_DAYS=${CLIENT_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}\n\n\trenewClient\n}\n\n# Handle server command\ncmd_server() {\n\tlocal subcmd=\"${1:-}\"\n\tshift || true\n\n\tcase \"$subcmd\" in\n\t\"\" | \"-h\" | \"--help\")\n\t\tshow_server_help\n\t\texit 0\n\t\t;;\n\tstatus)\n\t\tcmd_server_status \"$@\"\n\t\t;;\n\trenew)\n\t\tcmd_server_renew \"$@\"\n\t\t;;\n\t*)\n\t\tlog_fatal \"Unknown server subcommand: $subcmd. See '$SCRIPT_NAME server --help' for usage.\"\n\t\t;;\n\tesac\n}\n\n# Handle server status command\ncmd_server_status() {\n\tlocal format=\"table\"\n\n\twhile [[ $# -gt 0 ]]; do\n\t\tcase \"$1\" in\n\t\t--format)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--format requires an argument\"\n\t\t\tcase \"$2\" in\n\t\t\ttable | json) format=\"$2\" ;;\n\t\t\t*) log_fatal \"Invalid format: $2. Use 'table' or 'json'.\" ;;\n\t\t\tesac\n\t\t\tshift 2\n\t\t\t;;\n\t\t-h | --help)\n\t\t\tshow_server_status_help\n\t\t\texit 0\n\t\t\t;;\n\t\t*)\n\t\t\tlog_fatal \"Unknown option: $1. See '$SCRIPT_NAME server status --help' for usage.\"\n\t\t\t;;\n\t\tesac\n\tdone\n\n\trequireOpenVPN\n\n\tOUTPUT_FORMAT=\"$format\" listConnectedClients\n}\n\n# Handle server renew command\ncmd_server_renew() {\n\tlocal force=false\n\n\twhile [[ $# -gt 0 ]]; do\n\t\tcase \"$1\" in\n\t\t--cert-days)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--cert-days requires an argument\"\n\t\t\tvalidate_positive_int \"$2\" \"cert-days\"\n\t\t\tSERVER_CERT_DURATION_DAYS=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t-f | --force)\n\t\t\tforce=true\n\t\t\tshift\n\t\t\t;;\n\t\t-h | --help)\n\t\t\tshow_server_renew_help\n\t\t\texit 0\n\t\t\t;;\n\t\t*)\n\t\t\tlog_fatal \"Unknown option: $1. See '$SCRIPT_NAME server renew --help' for usage.\"\n\t\t\t;;\n\t\tesac\n\tdone\n\n\trequireOpenVPN\n\n\tSERVER_CERT_DURATION_DAYS=${SERVER_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}\n\tif [[ $force == true ]]; then\n\t\tCONTINUE=y\n\tfi\n\n\trenewServer\n}\n\n# Handle interactive command (legacy menu)\ncmd_interactive() {\n\twhile [[ $# -gt 0 ]]; do\n\t\tcase \"$1\" in\n\t\t-h | --help)\n\t\t\techo \"Launch interactive menu for OpenVPN management\"\n\t\t\techo \"\"\n\t\t\techo \"Usage: $SCRIPT_NAME interactive\"\n\t\t\texit 0\n\t\t\t;;\n\t\t*)\n\t\t\tlog_fatal \"Unknown option: $1\"\n\t\t\t;;\n\t\tesac\n\tdone\n\n\tif isOpenVPNInstalled; then\n\t\tmanageMenu\n\telse\n\t\tinstallQuestions\n\t\tinstallOpenVPN\n\tfi\n}\n\n# Main argument parser\nparse_args() {\n\t# Parse global options first\n\twhile [[ $# -gt 0 ]]; do\n\t\tcase \"$1\" in\n\t\t--verbose)\n\t\t\tVERBOSE=1\n\t\t\tshift\n\t\t\t;;\n\t\t--log)\n\t\t\t[[ -z \"${2:-}\" ]] && log_fatal \"--log requires an argument\"\n\t\t\tLOG_FILE=\"$2\"\n\t\t\tshift 2\n\t\t\t;;\n\t\t--no-log)\n\t\t\tLOG_FILE=\"\"\n\t\t\tshift\n\t\t\t;;\n\t\t--no-color)\n\t\t\t# Colors already set at script start, but we can unset them\n\t\t\tCOLOR_RESET=''\n\t\t\tCOLOR_RED=''\n\t\t\tCOLOR_GREEN=''\n\t\t\tCOLOR_YELLOW=''\n\t\t\tCOLOR_BLUE=''\n\t\t\tCOLOR_CYAN=''\n\t\t\tCOLOR_DIM=''\n\t\t\tCOLOR_BOLD=''\n\t\t\tshift\n\t\t\t;;\n\t\t-h | --help)\n\t\t\tshow_help\n\t\t\texit 0\n\t\t\t;;\n\t\t-*)\n\t\t\t# Could be a command-specific option, let command handle it\n\t\t\tbreak\n\t\t\t;;\n\t\t*)\n\t\t\t# First non-option is the command\n\t\t\tbreak\n\t\t\t;;\n\t\tesac\n\tdone\n\n\t# Get the command\n\tlocal cmd=\"${1:-}\"\n\tshift || true\n\n\t# Check if user just wants help (don't require root for help)\n\t# Also detect --format json early to suppress log output before initialCheck\n\tlocal wants_help=false\n\tlocal prev_arg=\"\"\n\tfor arg in \"$@\"; do\n\t\tif [[ \"$arg\" == \"-h\" || \"$arg\" == \"--help\" ]]; then\n\t\t\twants_help=true\n\t\tfi\n\t\tif [[ \"$prev_arg\" == \"--format\" && \"$arg\" == \"json\" ]]; then\n\t\t\tOUTPUT_FORMAT=\"json\"\n\t\tfi\n\t\tprev_arg=\"$arg\"\n\tdone\n\n\t# Dispatch to command handler\n\tcase \"$cmd\" in\n\t\"\")\n\t\tshow_help\n\t\texit 0\n\t\t;;\n\tinstall)\n\t\t[[ $wants_help == false ]] && initialCheck\n\t\tcmd_install \"$@\"\n\t\t;;\n\tuninstall)\n\t\t[[ $wants_help == false ]] && initialCheck\n\t\tcmd_uninstall \"$@\"\n\t\t;;\n\tclient)\n\t\t[[ $wants_help == false ]] && initialCheck\n\t\tcmd_client \"$@\"\n\t\t;;\n\tserver)\n\t\t[[ $wants_help == false ]] && initialCheck\n\t\tcmd_server \"$@\"\n\t\t;;\n\tinteractive)\n\t\t[[ $wants_help == false ]] && initialCheck\n\t\tcmd_interactive \"$@\"\n\t\t;;\n\t*)\n\t\tlog_fatal \"Unknown command: $cmd. See '$SCRIPT_NAME --help' for usage.\"\n\t\t;;\n\tesac\n}\n\n# =============================================================================\n# System Check Functions\n# =============================================================================\nfunction isRoot() {\n\tif [ \"$EUID\" -ne 0 ]; then\n\t\treturn 1\n\tfi\n}\n\nfunction tunAvailable() {\n\tif [ ! -e /dev/net/tun ]; then\n\t\treturn 1\n\tfi\n}\n\nfunction checkOS() {\n\tif [[ -e /etc/debian_version ]]; then\n\t\tOS=\"debian\"\n\t\tsource /etc/os-release\n\n\t\tif [[ $ID == \"debian\" || $ID == \"raspbian\" ]]; then\n\t\t\tif [[ $VERSION_ID -lt 11 ]]; then\n\t\t\t\tlog_warn \"Your version of Debian is not supported.\"\n\t\t\t\tlog_info \"However, if you're using Debian >= 11 or unstable/testing, you can continue at your own risk.\"\n\t\t\t\tuntil [[ $CONTINUE =~ (y|n) ]]; do\n\t\t\t\t\tread -rp \"Continue? [y/n]: \" -e CONTINUE\n\t\t\t\tdone\n\t\t\t\tif [[ $CONTINUE == \"n\" ]]; then\n\t\t\t\t\texit 1\n\t\t\t\tfi\n\t\t\tfi\n\t\telif [[ $ID == \"ubuntu\" ]]; then\n\t\t\tOS=\"ubuntu\"\n\t\t\tMAJOR_UBUNTU_VERSION=$(echo \"$VERSION_ID\" | cut -d '.' -f1)\n\t\t\tif [[ $MAJOR_UBUNTU_VERSION -lt 18 ]]; then\n\t\t\t\tlog_warn \"Your version of Ubuntu is not supported.\"\n\t\t\t\tlog_info \"However, if you're using Ubuntu >= 18.04 or beta, you can continue at your own risk.\"\n\t\t\t\tuntil [[ $CONTINUE =~ (y|n) ]]; do\n\t\t\t\t\tread -rp \"Continue? [y/n]: \" -e CONTINUE\n\t\t\t\tdone\n\t\t\t\tif [[ $CONTINUE == \"n\" ]]; then\n\t\t\t\t\texit 1\n\t\t\t\tfi\n\t\t\tfi\n\t\tfi\n\telif [[ -e /etc/os-release ]]; then\n\t\tsource /etc/os-release\n\t\tif [[ $ID == \"fedora\" || $ID_LIKE == \"fedora\" ]]; then\n\t\t\tOS=\"fedora\"\n\t\tfi\n\t\tif [[ $ID == \"opensuse-tumbleweed\" ]]; then\n\t\t\tOS=\"opensuse\"\n\t\tfi\n\t\tif [[ $ID == \"opensuse-leap\" ]]; then\n\t\t\tOS=\"opensuse\"\n\t\t\tif [[ ${VERSION_ID%.*} -lt 16 ]]; then\n\t\t\t\tlog_info \"The script only supports openSUSE Leap 16+.\"\n\t\t\t\tlog_fatal \"Your version of openSUSE Leap is not supported.\"\n\t\t\tfi\n\t\tfi\n\t\tif [[ $ID == \"centos\" || $ID == \"rocky\" || $ID == \"almalinux\" ]]; then\n\t\t\tOS=\"centos\"\n\t\tfi\n\t\tif [[ $ID == \"ol\" ]]; then\n\t\t\tOS=\"oracle\"\n\t\tfi\n\t\tif [[ $OS =~ (centos|oracle) ]] && [[ ${VERSION_ID%.*} -lt 8 ]]; then\n\t\t\tlog_info \"The script only supports CentOS Stream / Rocky Linux / AlmaLinux / Oracle Linux version 8+.\"\n\t\t\tlog_fatal \"Your version is not supported.\"\n\t\tfi\n\t\tif [[ $ID == \"amzn\" ]]; then\n\t\t\tif [[ \"$PRETTY_NAME\" =~ ^Amazon\\ Linux\\ 2023\\.([0-9]+) ]] && [[ \"${BASH_REMATCH[1]}\" -ge 6 ]]; then\n\t\t\t\tOS=\"amzn2023\"\n\t\t\telse\n\t\t\t\tlog_info \"The script only supports Amazon Linux 2023.6+\"\n\t\t\t\tlog_info \"Amazon Linux 2 is EOL and no longer supported.\"\n\t\t\t\tlog_fatal \"Your version of Amazon Linux is not supported.\"\n\t\t\tfi\n\t\tfi\n\t\tif [[ $ID == \"arch\" ]]; then\n\t\t\tOS=\"arch\"\n\t\tfi\n\telif [[ -e /etc/arch-release ]]; then\n\t\tOS=arch\n\telse\n\t\tlog_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.\"\n\tfi\n}\n\nfunction checkArchPendingKernelUpgrade() {\n\tif [[ $OS != \"arch\" ]]; then\n\t\treturn 0\n\tfi\n\n\t# Check if running kernel's modules are available\n\t# (detects if kernel was upgraded but system not rebooted)\n\t# Skip this check in containers - they share host kernel but have their own /lib/modules\n\tif [[ -f /.dockerenv ]] || grep -qE '(docker|lxc|containerd)' /proc/1/cgroup 2>/dev/null; then\n\t\tlog_info \"Running in container, skipping kernel modules check\"\n\telse\n\t\tlocal running_kernel\n\t\trunning_kernel=$(uname -r)\n\t\tif [[ ! -d \"/lib/modules/${running_kernel}\" ]]; then\n\t\t\tlog_error \"Kernel modules for running kernel ($running_kernel) not found!\"\n\t\t\tlog_info \"This usually means the kernel was upgraded but the system wasn't rebooted.\"\n\t\t\tlog_fatal \"Please reboot your system and run this script again.\"\n\t\tfi\n\tfi\n\n\tlog_info \"Checking for pending kernel upgrades on Arch Linux...\"\n\n\t# Sync package database to check for updates\n\tif ! pacman -Sy &>/dev/null; then\n\t\tlog_warn \"Failed to sync package database, skipping kernel upgrade check\"\n\t\treturn 0\n\tfi\n\n\t# Check for pending linux kernel upgrades\n\tlocal pending_kernels\n\tpending_kernels=$(pacman -Qu 2>/dev/null | grep -E '^linux' || true)\n\n\tif [[ -n \"$pending_kernels\" ]]; then\n\t\tlog_warn \"Linux kernel upgrade(s) pending:\"\n\t\techo \"$pending_kernels\" | while read -r line; do\n\t\t\tlog_info \"  $line\"\n\t\tdone\n\t\techo \"\"\n\t\tlog_info \"This script uses 'pacman -Syu' which will upgrade your kernel.\"\n\t\tlog_info \"After a kernel upgrade, the TUN module won't be available until you reboot.\"\n\t\techo \"\"\n\t\tlog_info \"Please upgrade your system and reboot first:\"\n\t\tlog_info \"  sudo pacman -Syu\"\n\t\tlog_info \"  sudo reboot\"\n\t\techo \"\"\n\t\tlog_fatal \"Aborting. Run this script again after upgrading and rebooting.\"\n\tfi\n\n\tlog_success \"No pending kernel upgrades\"\n}\n\nfunction initialCheck() {\n\tlog_debug \"Checking root privileges...\"\n\tif ! isRoot; then\n\t\tlog_fatal \"Sorry, you need to run this script as root.\"\n\tfi\n\tlog_debug \"Root check passed\"\n\n\tlog_debug \"Checking TUN device availability...\"\n\tif ! tunAvailable; then\n\t\tlog_fatal \"TUN is not available.\"\n\tfi\n\tlog_debug \"TUN device available at /dev/net/tun\"\n\n\tlog_debug \"Detecting operating system...\"\n\tcheckOS\n\tlog_debug \"Detected OS: $OS (${PRETTY_NAME:-unknown})\"\n\tcheckArchPendingKernelUpgrade\n}\n\n# Check if OpenVPN version is at least the specified version\n# Usage: openvpnVersionAtLeast \"2.5\"\n# Returns 0 if version is >= specified, 1 otherwise\nfunction openvpnVersionAtLeast() {\n\tlocal required_version=\"$1\"\n\tlocal installed_version\n\n\tif ! command -v openvpn &>/dev/null; then\n\t\treturn 1\n\tfi\n\n\tinstalled_version=$(openvpn --version 2>/dev/null | head -1 | awk '{print $2}')\n\tif [[ -z \"$installed_version\" ]]; then\n\t\treturn 1\n\tfi\n\n\t# Compare versions using sort -V\n\tif [[ \"$(printf '%s\\n' \"$required_version\" \"$installed_version\" | sort -V | head -n1)\" == \"$required_version\" ]]; then\n\t\treturn 0\n\tfi\n\treturn 1\n}\n\n# Check if kernel version is at least the specified version\n# Usage: kernelVersionAtLeast \"6.16\"\n# Returns 0 if version is >= specified, 1 otherwise\nfunction kernelVersionAtLeast() {\n\tlocal required_version=\"$1\"\n\tlocal kernel_version\n\n\tkernel_version=$(uname -r | cut -d'-' -f1)\n\tif [[ -z \"$kernel_version\" ]]; then\n\t\treturn 1\n\tfi\n\n\tif [[ \"$(printf '%s\\n' \"$required_version\" \"$kernel_version\" | sort -V | head -n1)\" == \"$required_version\" ]]; then\n\t\treturn 0\n\tfi\n\treturn 1\n}\n\n# Check if Data Channel Offload (DCO) is available\n# DCO requires: OpenVPN 2.6+, kernel support (Linux 6.16+ or ovpn-dco module)\n# Returns 0 if DCO is available, 1 otherwise\nfunction isDCOAvailable() {\n\t# DCO requires OpenVPN 2.6+\n\tif ! openvpnVersionAtLeast \"2.6\"; then\n\t\treturn 1\n\tfi\n\n\t# DCO is built into Linux 6.16+, or available via ovpn-dco module\n\tif kernelVersionAtLeast \"6.16\"; then\n\t\treturn 0\n\telif lsmod 2>/dev/null | grep -q \"^ovpn_dco\" || modinfo ovpn-dco &>/dev/null; then\n\t\treturn 0\n\tfi\n\treturn 1\n}\n\nfunction installOpenVPNRepo() {\n\tlog_info \"Setting up official OpenVPN repository...\"\n\n\tif [[ $OS =~ (debian|ubuntu) ]]; then\n\t\trun_cmd_fatal \"Update package lists\" apt-get update\n\t\trun_cmd_fatal \"Installing prerequisites\" apt-get install -y ca-certificates curl\n\n\t\t# Create keyrings directory\n\t\trun_cmd \"Creating keyrings directory\" mkdir -p /etc/apt/keyrings\n\n\t\t# Download and install GPG key\n\t\tif ! 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\n\t\t\tlog_fatal \"Failed to download OpenVPN repository GPG key\"\n\t\tfi\n\n\t\t# Add repository - using stable release\n\t\tif [[ -z \"${VERSION_CODENAME}\" ]]; then\n\t\t\tlog_fatal \"VERSION_CODENAME is not set. Unable to configure OpenVPN repository.\"\n\t\tfi\n\t\techo \"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\n\n\t\tlog_info \"Updating package lists with new repository...\"\n\t\trun_cmd_fatal \"Update package lists\" apt-get update\n\n\t\tlog_info \"OpenVPN official repository configured\"\n\n\telif [[ $OS =~ (centos|oracle) ]]; then\n\t\t# For RHEL-based systems, use Fedora Copr (OpenVPN 2.6 stable)\n\t\t# EPEL is required for pkcs11-helper dependency\n\t\tlog_info \"Configuring OpenVPN Copr repository for RHEL-based system...\"\n\n\t\t# Oracle Linux uses oracle-epel-release-el* instead of epel-release\n\t\tif [[ $OS == \"oracle\" ]]; then\n\t\t\tEPEL_PACKAGE=\"oracle-epel-release-el${VERSION_ID%.*}\"\n\t\telse\n\t\t\tEPEL_PACKAGE=\"epel-release\"\n\t\tfi\n\n\t\tif ! command -v dnf &>/dev/null; then\n\t\t\trun_cmd_fatal \"Installing EPEL repository\" yum install -y \"$EPEL_PACKAGE\"\n\t\t\trun_cmd_fatal \"Installing yum-plugin-copr\" yum install -y yum-plugin-copr\n\t\t\trun_cmd_fatal \"Enabling OpenVPN Copr repo\" yum copr enable -y @OpenVPN/openvpn-release-2.6\n\t\telse\n\t\t\trun_cmd_fatal \"Installing EPEL repository\" dnf install -y \"$EPEL_PACKAGE\"\n\t\t\trun_cmd_fatal \"Installing dnf-plugins-core\" dnf install -y dnf-plugins-core\n\t\t\trun_cmd_fatal \"Enabling OpenVPN Copr repo\" dnf copr enable -y @OpenVPN/openvpn-release-2.6\n\t\tfi\n\n\t\tlog_info \"OpenVPN Copr repository configured\"\n\n\telif [[ $OS == \"fedora\" ]]; then\n\t\t# Fedora already ships with recent OpenVPN 2.6.x, no Copr needed\n\t\tlog_info \"Fedora already has recent OpenVPN packages, using distribution version\"\n\n\telse\n\t\tlog_info \"No official OpenVPN repository available for this OS, using distribution packages\"\n\tfi\n}\n\nfunction installUnbound() {\n\tlog_info \"Installing Unbound DNS resolver...\"\n\n\t# Install Unbound if not present\n\tif [[ ! -e /etc/unbound/unbound.conf ]]; then\n\t\tif [[ $OS =~ (debian|ubuntu) ]]; then\n\t\t\trun_cmd_fatal \"Installing Unbound\" apt-get install -y unbound\n\t\telif [[ $OS =~ (centos|oracle) ]]; then\n\t\t\trun_cmd_fatal \"Installing Unbound\" yum install -y unbound\n\t\telif [[ $OS =~ (fedora|amzn2023) ]]; then\n\t\t\trun_cmd_fatal \"Installing Unbound\" dnf install -y unbound\n\t\telif [[ $OS == \"opensuse\" ]]; then\n\t\t\trun_cmd_fatal \"Installing Unbound\" zypper install -y unbound\n\t\telif [[ $OS == \"arch\" ]]; then\n\t\t\trun_cmd_fatal \"Installing Unbound\" pacman -Syu --noconfirm unbound\n\t\tfi\n\tfi\n\n\t# Configure Unbound for OpenVPN (runs whether freshly installed or pre-existing)\n\t# Create conf.d directory (works on all distros)\n\trun_cmd \"Creating Unbound config directory\" mkdir -p /etc/unbound/unbound.conf.d\n\n\t# Ensure main config includes conf.d directory\n\t# Modern Debian/Ubuntu use include-toplevel, others need include directive\n\tif ! grep -qE \"include(-toplevel)?:\\s*.*/etc/unbound/unbound.conf.d\" /etc/unbound/unbound.conf 2>/dev/null; then\n\t\t# Add include directive for conf.d if not present\n\t\techo 'include: \"/etc/unbound/unbound.conf.d/*.conf\"' >>/etc/unbound/unbound.conf\n\tfi\n\n\t# Generate OpenVPN-specific Unbound configuration\n\t# Using consistent best-practice settings across all distros\n\t{\n\t\techo 'server:'\n\t\techo '    # OpenVPN DNS resolver configuration'\n\n\t\t# IPv4 VPN interface (only if clients get IPv4)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo \"    interface: $VPN_GATEWAY_IPV4\"\n\t\t\techo \"    access-control: $VPN_SUBNET_IPV4/24 allow\"\n\t\tfi\n\n\t\t# IPv6 VPN interface (only if clients get IPv6)\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo \"    interface: $VPN_GATEWAY_IPV6\"\n\t\t\techo \"    access-control: ${VPN_SUBNET_IPV6}/112 allow\"\n\t\tfi\n\n\t\techo ''\n\t\techo '    # Security hardening'\n\t\techo '    hide-identity: yes'\n\t\techo '    hide-version: yes'\n\t\techo '    harden-glue: yes'\n\t\techo '    harden-dnssec-stripped: yes'\n\t\techo ''\n\t\techo '    # Performance optimizations'\n\t\techo '    prefetch: yes'\n\t\techo '    use-caps-for-id: yes'\n\t\techo '    qname-minimisation: yes'\n\t\techo ''\n\t\techo '    # Allow binding before tun interface exists'\n\t\techo '    ip-freebind: yes'\n\t\techo ''\n\t\techo '    # DNS rebinding protection'\n\t\techo '    private-address: 10.0.0.0/8'\n\t\techo '    private-address: 172.16.0.0/12'\n\t\techo '    private-address: 192.168.0.0/16'\n\t\techo '    private-address: 169.254.0.0/16'\n\t\techo '    private-address: 127.0.0.0/8'\n\t\techo '    private-address: fd00::/8'\n\t\techo '    private-address: fe80::/10'\n\t\techo '    private-address: ::ffff:0:0/96'\n\n\t\t# Add VPN subnet to private addresses if IPv6 enabled\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo \"    private-address: ${VPN_SUBNET_IPV6}/112\"\n\t\tfi\n\n\t\t# Disable remote-control (requires SSL certs on openSUSE)\n\t\tif [[ $OS == \"opensuse\" ]]; then\n\t\t\techo ''\n\t\t\techo 'remote-control:'\n\t\t\techo '    control-enable: no'\n\t\tfi\n\t} >/etc/unbound/unbound.conf.d/openvpn.conf\n\n\trun_cmd \"Enabling Unbound service\" systemctl enable unbound\n\trun_cmd \"Starting Unbound service\" systemctl restart unbound\n\n\t# Validate Unbound is running\n\tfor i in {1..10}; do\n\t\tif pgrep -x unbound >/dev/null; then\n\t\t\treturn 0\n\t\tfi\n\t\tsleep 1\n\tdone\n\tlog_fatal \"Unbound failed to start. Check 'journalctl -u unbound' for details.\"\n}\n\nfunction resolvePublicIPv4() {\n\tlocal public_ip=\"\"\n\n\t# Try to resolve public IPv4 using: https://api.seeip.org\n\tif [[ -z $public_ip ]]; then\n\t\tpublic_ip=$(curl -f -m 5 -sS --retry 2 --retry-connrefused -4 https://api.seeip.org 2>/dev/null)\n\tfi\n\n\t# Try to resolve using: https://ifconfig.me\n\tif [[ -z $public_ip ]]; then\n\t\tpublic_ip=$(curl -f -m 5 -sS --retry 2 --retry-connrefused -4 https://ifconfig.me 2>/dev/null)\n\tfi\n\n\t# Try to resolve using: https://api.ipify.org\n\tif [[ -z $public_ip ]]; then\n\t\tpublic_ip=$(curl -f -m 5 -sS --retry 2 --retry-connrefused -4 https://api.ipify.org 2>/dev/null)\n\tfi\n\n\t# Try to resolve using: ns1.google.com\n\tif [[ -z $public_ip ]]; then\n\t\tpublic_ip=$(dig -4 TXT +short o-o.myaddr.l.google.com @ns1.google.com | tr -d '\"')\n\tfi\n\n\techo \"$public_ip\"\n}\n\nfunction resolvePublicIPv6() {\n\tlocal public_ip=\"\"\n\n\t# Try to resolve public IPv6 using: https://api6.seeip.org\n\tif [[ -z $public_ip ]]; then\n\t\tpublic_ip=$(curl -f -m 5 -sS --retry 2 --retry-connrefused -6 https://api6.seeip.org 2>/dev/null)\n\tfi\n\n\t# Try to resolve using: https://ifconfig.me (IPv6)\n\tif [[ -z $public_ip ]]; then\n\t\tpublic_ip=$(curl -f -m 5 -sS --retry 2 --retry-connrefused -6 https://ifconfig.me 2>/dev/null)\n\tfi\n\n\t# Try to resolve using: https://api64.ipify.org (dual-stack, prefer IPv6)\n\tif [[ -z $public_ip ]]; then\n\t\tpublic_ip=$(curl -f -m 5 -sS --retry 2 --retry-connrefused -6 https://api64.ipify.org 2>/dev/null)\n\tfi\n\n\t# Try to resolve using: ns1.google.com\n\tif [[ -z $public_ip ]]; then\n\t\tpublic_ip=$(dig -6 TXT +short o-o.myaddr.l.google.com @ns1.google.com | tr -d '\"')\n\tfi\n\n\techo \"$public_ip\"\n}\n\n# Legacy wrapper for backward compatibility\nfunction resolvePublicIP() {\n\tif [[ $ENDPOINT_TYPE == \"6\" ]]; then\n\t\tresolvePublicIPv6\n\telse\n\t\tresolvePublicIPv4\n\tfi\n}\n\n# Detect server's IPv4 and IPv6 addresses\nfunction detect_server_ips() {\n\tIP_IPV4=$(ip -4 addr | sed -ne 's|^.* inet \\([^/]*\\)/.* scope global.*$|\\1|p' | head -1)\n\tIP_IPV6=$(ip -6 addr | sed -ne 's|^.* inet6 \\([^/]*\\)/.* scope global.*$|\\1|p' | head -1)\n\n\t# Set IP based on ENDPOINT_TYPE\n\tif [[ $ENDPOINT_TYPE == \"6\" ]]; then\n\t\tIP=\"$IP_IPV6\"\n\telse\n\t\tIP=\"$IP_IPV4\"\n\tfi\n}\n\n# Calculate derived network configuration values\nfunction prepare_network_config() {\n\t# Calculate IPv4 gateway (always needed for leak prevention)\n\tVPN_GATEWAY_IPV4=\"${VPN_SUBNET_IPV4%.*}.1\"\n\n\t# Calculate IPv6 gateway if IPv6 is enabled\n\tif [[ $CLIENT_IPV6 == \"y\" ]]; then\n\t\tVPN_GATEWAY_IPV6=\"${VPN_SUBNET_IPV6}1\"\n\tfi\n\n\t# Set legacy variable for backward compatibility\n\tIPV6_SUPPORT=\"$CLIENT_IPV6\"\n}\n\nfunction installQuestions() {\n\tlog_header \"OpenVPN Installer\"\n\tlog_prompt \"The git repository is available at: https://github.com/angristan/openvpn-install\"\n\n\tlog_prompt \"I need to ask you a few questions before starting the setup.\"\n\tlog_prompt \"You can leave the default options and just press enter if you are okay with them.\"\n\n\t# ==========================================================================\n\t# Step 1: Detect server IP addresses\n\t# ==========================================================================\n\tlog_menu \"\"\n\tlog_prompt \"Detecting server IP addresses...\"\n\n\t# Detect IPv4 address\n\tIP_IPV4=$(ip -4 addr | sed -ne 's|^.* inet \\([^/]*\\)/.* scope global.*$|\\1|p' | head -1)\n\t# Detect IPv6 address\n\tIP_IPV6=$(ip -6 addr | sed -ne 's|^.* inet6 \\([^/]*\\)/.* scope global.*$|\\1|p' | head -1)\n\n\tif [[ -n $IP_IPV4 ]]; then\n\t\tlog_prompt \"  IPv4 address detected: $IP_IPV4\"\n\telse\n\t\tlog_prompt \"  No IPv4 address detected\"\n\tfi\n\tif [[ -n $IP_IPV6 ]]; then\n\t\tlog_prompt \"  IPv6 address detected: $IP_IPV6\"\n\telse\n\t\tlog_prompt \"  No IPv6 address detected\"\n\tfi\n\n\t# ==========================================================================\n\t# Step 2: Endpoint type selection\n\t# ==========================================================================\n\tlog_menu \"\"\n\tlog_prompt \"What IP version should clients use to connect to this server?\"\n\n\t# Determine default based on available addresses\n\tif [[ -n $IP_IPV4 ]]; then\n\t\tENDPOINT_TYPE_DEFAULT=1\n\telif [[ -n $IP_IPV6 ]]; then\n\t\tENDPOINT_TYPE_DEFAULT=2\n\telse\n\t\tlog_fatal \"No IPv4 or IPv6 address detected on this server.\"\n\tfi\n\n\tlog_menu \"   1) IPv4\"\n\tlog_menu \"   2) IPv6\"\n\tuntil [[ $ENDPOINT_TYPE_CHOICE =~ ^[1-2]$ ]]; do\n\t\tread -rp \"Endpoint type [1-2]: \" -e -i $ENDPOINT_TYPE_DEFAULT ENDPOINT_TYPE_CHOICE\n\tdone\n\tcase $ENDPOINT_TYPE_CHOICE in\n\t1)\n\t\tENDPOINT_TYPE=\"4\"\n\t\tIP=\"$IP_IPV4\"\n\t\t;;\n\t2)\n\t\tENDPOINT_TYPE=\"6\"\n\t\tIP=\"$IP_IPV6\"\n\t\t;;\n\tesac\n\n\t# ==========================================================================\n\t# Step 3: Endpoint address (handle NAT for IPv4, direct for IPv6)\n\t# ==========================================================================\n\tAPPROVE_IP=${APPROVE_IP:-n}\n\tif [[ $APPROVE_IP =~ n ]]; then\n\t\tlog_menu \"\"\n\t\tif [[ $ENDPOINT_TYPE == \"4\" ]]; then\n\t\t\tlog_prompt \"Server listening IPv4 address:\"\n\t\t\tread -rp \"IPv4 address: \" -e -i \"$IP\" IP\n\t\telse\n\t\t\tlog_prompt \"Server listening IPv6 address:\"\n\t\t\tread -rp \"IPv6 address: \" -e -i \"$IP\" IP\n\t\tfi\n\tfi\n\n\t# If IPv4 and private IP, server is behind NAT\n\tif [[ $ENDPOINT_TYPE == \"4\" ]] && echo \"$IP\" | grep -qE '^(10\\.|172\\.1[6789]\\.|172\\.2[0-9]\\.|172\\.3[01]\\.|192\\.168)'; then\n\t\tlog_menu \"\"\n\t\tlog_prompt \"It seems this server is behind NAT. What is its public IPv4 address or hostname?\"\n\t\tlog_prompt \"We need it for the clients to connect to the server.\"\n\n\t\tif [[ -z $ENDPOINT ]]; then\n\t\t\tDEFAULT_ENDPOINT=$(resolvePublicIPv4)\n\t\tfi\n\n\t\tuntil [[ $ENDPOINT != \"\" ]]; do\n\t\t\tread -rp \"Public IPv4 address or hostname: \" -e -i \"$DEFAULT_ENDPOINT\" ENDPOINT\n\t\tdone\n\telif [[ $ENDPOINT_TYPE == \"6\" ]]; then\n\t\t# For IPv6, check if it's a link-local address (starts with fe80)\n\t\tif echo \"$IP\" | grep -qiE '^fe80'; then\n\t\t\tlog_menu \"\"\n\t\t\tlog_prompt \"The detected IPv6 address is link-local. What is the public IPv6 address or hostname?\"\n\t\t\tlog_prompt \"We need it for the clients to connect to the server.\"\n\n\t\t\tif [[ -z $ENDPOINT ]]; then\n\t\t\t\tDEFAULT_ENDPOINT=$(resolvePublicIPv6)\n\t\t\tfi\n\n\t\t\tuntil [[ $ENDPOINT != \"\" ]]; do\n\t\t\t\tread -rp \"Public IPv6 address or hostname: \" -e -i \"$DEFAULT_ENDPOINT\" ENDPOINT\n\t\t\tdone\n\t\tfi\n\tfi\n\n\t# ==========================================================================\n\t# Step 4: Client IP versions\n\t# ==========================================================================\n\tlog_menu \"\"\n\tlog_prompt \"What IP versions should VPN clients use?\"\n\tlog_prompt \"This determines both their VPN addresses and internet access through the tunnel.\"\n\n\t# Check IPv6 connectivity for suggestion\n\tif type ping6 >/dev/null 2>&1; then\n\t\tPING6=\"ping6 -c1 -W2 ipv6.google.com > /dev/null 2>&1\"\n\telse\n\t\tPING6=\"ping -6 -c1 -W2 ipv6.google.com > /dev/null 2>&1\"\n\tfi\n\tHAS_IPV6_CONNECTIVITY=\"n\"\n\tif eval \"$PING6\"; then\n\t\tHAS_IPV6_CONNECTIVITY=\"y\"\n\tfi\n\n\t# Default suggestion based on connectivity\n\tif [[ $HAS_IPV6_CONNECTIVITY == \"y\" ]]; then\n\t\tCLIENT_IP_DEFAULT=3 # Dual-stack if IPv6 available\n\telse\n\t\tCLIENT_IP_DEFAULT=1 # IPv4 only otherwise\n\tfi\n\n\tlog_menu \"   1) IPv4 only\"\n\tlog_menu \"   2) IPv6 only\"\n\tlog_menu \"   3) Dual-stack (IPv4 + IPv6)\"\n\tuntil [[ $CLIENT_IP_CHOICE =~ ^[1-3]$ ]]; do\n\t\tread -rp \"Client IP versions [1-3]: \" -e -i $CLIENT_IP_DEFAULT CLIENT_IP_CHOICE\n\tdone\n\tcase $CLIENT_IP_CHOICE in\n\t1)\n\t\tCLIENT_IPV4=\"y\"\n\t\tCLIENT_IPV6=\"n\"\n\t\t;;\n\t2)\n\t\tCLIENT_IPV4=\"n\"\n\t\tCLIENT_IPV6=\"y\"\n\t\t;;\n\t3)\n\t\tCLIENT_IPV4=\"y\"\n\t\tCLIENT_IPV6=\"y\"\n\t\t;;\n\tesac\n\n\t# ==========================================================================\n\t# Step 5: IPv4 subnet (prompt only if IPv4 enabled, but always set for leak prevention)\n\t# ==========================================================================\n\tif [[ $CLIENT_IPV4 == \"y\" ]]; then\n\t\tlog_menu \"\"\n\t\tlog_prompt \"IPv4 VPN subnet:\"\n\t\tlog_menu \"   1) Default: 10.8.0.0/24\"\n\t\tlog_menu \"   2) Custom\"\n\t\tuntil [[ $SUBNET_IPV4_CHOICE =~ ^[1-2]$ ]]; do\n\t\t\tread -rp \"IPv4 subnet choice [1-2]: \" -e -i 1 SUBNET_IPV4_CHOICE\n\t\tdone\n\t\tcase $SUBNET_IPV4_CHOICE in\n\t\t1)\n\t\t\tVPN_SUBNET_IPV4=\"10.8.0.0\"\n\t\t\t;;\n\t\t2)\n\t\t\t# Skip prompt if VPN_SUBNET_IPV4 is already set (e.g., via environment variable)\n\t\t\tif [[ -z $VPN_SUBNET_IPV4 ]]; then\n\t\t\t\tuntil [[ $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\n\t\t\t\t\tread -rp \"Custom IPv4 subnet (e.g., 10.9.0.0): \" VPN_SUBNET_IPV4\n\t\t\t\tdone\n\t\t\tfi\n\t\t\t;;\n\t\tesac\n\telse\n\t\t# IPv6-only mode: still need IPv4 subnet for leak prevention (redirect-gateway def1)\n\t\tVPN_SUBNET_IPV4=\"10.8.0.0\"\n\tfi\n\n\t# ==========================================================================\n\t# Step 6: IPv6 subnet (if IPv6 enabled for clients)\n\t# ==========================================================================\n\tif [[ $CLIENT_IPV6 == \"y\" ]]; then\n\t\tlog_menu \"\"\n\t\tlog_prompt \"IPv6 VPN subnet:\"\n\t\tlog_menu \"   1) Default: fd42:42:42:42::/112\"\n\t\tlog_menu \"   2) Custom\"\n\t\tuntil [[ $SUBNET_IPV6_CHOICE =~ ^[1-2]$ ]]; do\n\t\t\tread -rp \"IPv6 subnet choice [1-2]: \" -e -i 1 SUBNET_IPV6_CHOICE\n\t\tdone\n\t\tcase $SUBNET_IPV6_CHOICE in\n\t\t1)\n\t\t\tVPN_SUBNET_IPV6=\"fd42:42:42:42::\"\n\t\t\t;;\n\t\t2)\n\t\t\t# Skip prompt if VPN_SUBNET_IPV6 is already set (e.g., via environment variable)\n\t\t\tif [[ -z $VPN_SUBNET_IPV6 ]]; then\n\t\t\t\tuntil [[ $VPN_SUBNET_IPV6 =~ ^fd[0-9a-fA-F]{0,2}(:[0-9a-fA-F]{0,4}){0,6}::$ ]]; do\n\t\t\t\t\tread -rp \"Custom IPv6 subnet (e.g., fd12:3456:789a::): \" VPN_SUBNET_IPV6\n\t\t\t\tdone\n\t\t\tfi\n\t\t\t;;\n\t\tesac\n\tfi\n\n\tlog_menu \"\"\n\tlog_prompt \"What port do you want OpenVPN to listen to?\"\n\tlog_menu \"   1) Default: 1194\"\n\tlog_menu \"   2) Custom\"\n\tlog_menu \"   3) Random [49152-65535]\"\n\tuntil [[ $PORT_CHOICE =~ ^[1-3]$ ]]; do\n\t\tread -rp \"Port choice [1-3]: \" -e -i 1 PORT_CHOICE\n\tdone\n\tcase $PORT_CHOICE in\n\t1)\n\t\tPORT=\"1194\"\n\t\t;;\n\t2)\n\t\tuntil [[ $PORT =~ ^[0-9]+$ ]] && [ \"$PORT\" -ge 1 ] && [ \"$PORT\" -le 65535 ]; do\n\t\t\tread -rp \"Custom port [1-65535]: \" -e -i 1194 PORT\n\t\tdone\n\t\t;;\n\t3)\n\t\t# Generate random number within private ports range\n\t\tPORT=$(shuf -i 49152-65535 -n1)\n\t\tlog_info \"Random Port: $PORT\"\n\t\t;;\n\tesac\n\tlog_menu \"\"\n\tlog_prompt \"What protocol do you want OpenVPN to use?\"\n\tlog_prompt \"UDP is faster. Unless it is not available, you shouldn't use TCP.\"\n\tlog_menu \"   1) UDP\"\n\tlog_menu \"   2) TCP\"\n\tuntil [[ $PROTOCOL_CHOICE =~ ^[1-2]$ ]]; do\n\t\tread -rp \"Protocol [1-2]: \" -e -i 1 PROTOCOL_CHOICE\n\tdone\n\tcase $PROTOCOL_CHOICE in\n\t1)\n\t\tPROTOCOL=\"udp\"\n\t\t;;\n\t2)\n\t\tPROTOCOL=\"tcp\"\n\t\t;;\n\tesac\n\tlog_menu \"\"\n\tlog_prompt \"What DNS resolvers do you want to use with the VPN?\"\n\tlocal 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\")\n\tlocal dns_valid=false\n\tuntil [[ $dns_valid == true ]]; do\n\t\tselect_with_labels \"DNS\" dns_labels DNS_PROVIDERS \"cloudflare\" DNS\n\t\tif [[ $DNS == \"unbound\" ]] && [[ -e /etc/unbound/unbound.conf ]]; then\n\t\t\tlog_menu \"\"\n\t\t\tlog_prompt \"Unbound is already installed.\"\n\t\t\tlog_prompt \"You can allow the script to configure it in order to use it from your OpenVPN clients\"\n\t\t\tlog_prompt \"We will simply add a second server to /etc/unbound/unbound.conf for the OpenVPN subnet.\"\n\t\t\tlog_prompt \"No changes are made to the current configuration.\"\n\t\t\tlog_menu \"\"\n\n\t\t\tlocal unbound_continue\n\t\t\tuntil [[ $unbound_continue =~ ^[yn]$ ]]; do\n\t\t\t\tread -rp \"Apply configuration changes to Unbound? [y/n]: \" -e unbound_continue\n\t\t\tdone\n\t\t\tif [[ $unbound_continue == \"n\" ]]; then\n\t\t\t\tunset DNS\n\t\t\telse\n\t\t\t\tdns_valid=true\n\t\t\tfi\n\t\telif [[ $DNS == \"custom\" ]]; then\n\t\t\tuntil [[ $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\n\t\t\t\tread -rp \"Primary DNS: \" -e DNS1\n\t\t\tdone\n\t\t\tuntil [[ $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\n\t\t\t\tread -rp \"Secondary DNS (optional): \" -e DNS2\n\t\t\t\tif [[ $DNS2 == \"\" ]]; then\n\t\t\t\t\tbreak\n\t\t\t\tfi\n\t\t\tdone\n\t\t\tdns_valid=true\n\t\telse\n\t\t\tdns_valid=true\n\t\tfi\n\tdone\n\tlog_menu \"\"\n\tlog_prompt \"Do you want to allow a single .ovpn profile to be used on multiple devices simultaneously?\"\n\tlog_prompt \"Note: Enabling this disables persistent IP addresses for clients.\"\n\tuntil [[ $MULTI_CLIENT =~ (y|n) ]]; do\n\t\tread -rp \"Allow multiple devices per client? [y/n]: \" -e -i n MULTI_CLIENT\n\tdone\n\tlog_menu \"\"\n\tlog_prompt \"Do you want to customize the tunnel MTU?\"\n\tlog_menu \"   MTU controls the maximum packet size. Lower values can help\"\n\tlog_menu \"   with connectivity issues on some networks (e.g., PPPoE, mobile).\"\n\tlog_menu \"   1) Default (1500) - works for most networks\"\n\tlog_menu \"   2) Custom\"\n\tuntil [[ $MTU_CHOICE =~ ^[1-2]$ ]]; do\n\t\tread -rp \"MTU choice [1-2]: \" -e -i 1 MTU_CHOICE\n\tdone\n\tif [[ $MTU_CHOICE == \"2\" ]]; then\n\t\tuntil [[ $MTU =~ ^[0-9]+$ ]] && [[ $MTU -ge 576 ]] && [[ $MTU -le 65535 ]]; do\n\t\t\tread -rp \"MTU [576-65535]: \" -e -i 1500 MTU\n\t\tdone\n\tfi\n\tlog_menu \"\"\n\tlog_prompt \"Choose the authentication mode:\"\n\tlog_menu \"   1) PKI (Certificate Authority) - Traditional CA-based authentication (recommended for larger setups)\"\n\tlog_menu \"   2) Peer Fingerprint - Simplified WireGuard-like authentication using certificate fingerprints\"\n\tlog_menu \"      Note: Fingerprint mode requires OpenVPN 2.6+ and is ideal for small/home setups\"\n\tlocal auth_mode_choice\n\tuntil [[ $auth_mode_choice =~ ^[1-2]$ ]]; do\n\t\tread -rp \"Authentication mode [1-2]: \" -e -i 1 auth_mode_choice\n\tdone\n\tcase $auth_mode_choice in\n\t1)\n\t\tAUTH_MODE=\"pki\"\n\t\t;;\n\t2)\n\t\tAUTH_MODE=\"fingerprint\"\n\t\t# Verify OpenVPN 2.6+ is available for fingerprint mode\n\t\tlocal openvpn_ver\n\t\topenvpn_ver=$(get_openvpn_version)\n\t\tif [[ -n \"$openvpn_ver\" ]] && ! version_ge \"$openvpn_ver\" \"2.6.0\"; then\n\t\t\tlog_warn \"OpenVPN $openvpn_ver detected. Fingerprint mode requires 2.6.0+.\"\n\t\t\tlog_warn \"OpenVPN 2.6+ will be installed during setup.\"\n\t\tfi\n\t\t;;\n\tesac\n\tlog_menu \"\"\n\tlog_prompt \"Do you want to customize encryption settings?\"\n\tlog_prompt \"Unless you know what you're doing, you should stick with the default parameters provided by the script.\"\n\tlog_prompt \"Note that whatever you choose, all the choices presented in the script are safe (unlike OpenVPN's defaults).\"\n\tlog_prompt \"See https://github.com/angristan/openvpn-install#security-and-encryption to learn more.\"\n\tlog_menu \"\"\n\tuntil [[ $CUSTOMIZE_ENC =~ (y|n) ]]; do\n\t\tread -rp \"Customize encryption settings? [y/n]: \" -e -i n CUSTOMIZE_ENC\n\tdone\n\tif [[ $CUSTOMIZE_ENC == \"n\" ]]; then\n\t\t# Use default, sane and fast parameters\n\t\tCIPHER=\"AES-128-GCM\"\n\t\tCERT_TYPE=\"ecdsa\"\n\t\tCERT_CURVE=\"prime256v1\"\n\t\tCC_CIPHER=\"TLS-ECDHE-ECDSA-WITH-AES-128-GCM-SHA256\"\n\t\tTLS13_CIPHERSUITES=\"TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256\"\n\t\tTLS_VERSION_MIN=\"1.2\"\n\t\tTLS_GROUPS=\"X25519:prime256v1:secp384r1:secp521r1\"\n\t\tHMAC_ALG=\"SHA256\"\n\t\tTLS_SIG=\"crypt-v2\"\n\telse\n\t\tlog_menu \"\"\n\t\tlog_prompt \"Choose which cipher you want to use for the data channel:\"\n\t\tlog_menu \"   1) AES-128-GCM (recommended)\"\n\t\tlog_menu \"   2) AES-192-GCM\"\n\t\tlog_menu \"   3) AES-256-GCM\"\n\t\tlog_menu \"   4) AES-128-CBC\"\n\t\tlog_menu \"   5) AES-192-CBC\"\n\t\tlog_menu \"   6) AES-256-CBC\"\n\t\tlog_menu \"   7) CHACHA20-POLY1305 (requires OpenVPN 2.5+, good for devices without AES-NI)\"\n\t\tuntil [[ $CIPHER_CHOICE =~ ^[1-7]$ ]]; do\n\t\t\tread -rp \"Cipher [1-7]: \" -e -i 1 CIPHER_CHOICE\n\t\tdone\n\t\tcase $CIPHER_CHOICE in\n\t\t1)\n\t\t\tCIPHER=\"AES-128-GCM\"\n\t\t\t;;\n\t\t2)\n\t\t\tCIPHER=\"AES-192-GCM\"\n\t\t\t;;\n\t\t3)\n\t\t\tCIPHER=\"AES-256-GCM\"\n\t\t\t;;\n\t\t4)\n\t\t\tCIPHER=\"AES-128-CBC\"\n\t\t\t;;\n\t\t5)\n\t\t\tCIPHER=\"AES-192-CBC\"\n\t\t\t;;\n\t\t6)\n\t\t\tCIPHER=\"AES-256-CBC\"\n\t\t\t;;\n\t\t7)\n\t\t\tCIPHER=\"CHACHA20-POLY1305\"\n\t\t\t;;\n\t\tesac\n\t\tlog_menu \"\"\n\t\tlog_prompt \"Choose what kind of certificate you want to use:\"\n\t\tlog_menu \"   1) ECDSA (recommended)\"\n\t\tlog_menu \"   2) RSA\"\n\t\tlocal cert_type_choice\n\t\tuntil [[ $cert_type_choice =~ ^[1-2]$ ]]; do\n\t\t\tread -rp \"Certificate key type [1-2]: \" -e -i 1 cert_type_choice\n\t\tdone\n\t\tcase $cert_type_choice in\n\t\t1)\n\t\t\tCERT_TYPE=\"ecdsa\"\n\t\t\tlog_menu \"\"\n\t\t\tlog_prompt \"Choose which curve you want to use for the certificate's key:\"\n\t\t\tselect_from_array \"Curve\" CERT_CURVES \"prime256v1\" CERT_CURVE\n\t\t\t;;\n\t\t2)\n\t\t\tCERT_TYPE=\"rsa\"\n\t\t\tlog_menu \"\"\n\t\t\tlog_prompt \"Choose which size you want to use for the certificate's RSA key:\"\n\t\t\tselect_from_array \"RSA key size\" RSA_KEY_SIZES \"2048\" RSA_KEY_SIZE\n\t\t\t;;\n\t\tesac\n\t\tlog_menu \"\"\n\t\tlog_prompt \"Choose which cipher you want to use for the control channel:\"\n\t\tlocal cc_labels cc_values\n\t\tif [[ $CERT_TYPE == \"ecdsa\" ]]; then\n\t\t\tcc_labels=(\"ECDHE-ECDSA-AES-128-GCM-SHA256 (recommended)\" \"ECDHE-ECDSA-AES-256-GCM-SHA384\" \"ECDHE-ECDSA-CHACHA20-POLY1305 (OpenVPN 2.5+)\")\n\t\t\tcc_values=(\"TLS-ECDHE-ECDSA-WITH-AES-128-GCM-SHA256\" \"TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384\" \"TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256\")\n\t\telse\n\t\t\tcc_labels=(\"ECDHE-RSA-AES-128-GCM-SHA256 (recommended)\" \"ECDHE-RSA-AES-256-GCM-SHA384\" \"ECDHE-RSA-CHACHA20-POLY1305 (OpenVPN 2.5+)\")\n\t\t\tcc_values=(\"TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256\" \"TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384\" \"TLS-ECDHE-RSA-WITH-CHACHA20-POLY1305-SHA256\")\n\t\tfi\n\t\tselect_with_labels \"Control channel cipher\" cc_labels cc_values \"${cc_values[0]}\" CC_CIPHER\n\t\tlog_menu \"\"\n\t\tlog_prompt \"Choose the minimum TLS version:\"\n\t\tlog_menu \"   1) TLS 1.2 (recommended, compatible with all clients)\"\n\t\tlog_menu \"   2) TLS 1.3 (more secure, requires OpenVPN 2.5+ clients)\"\n\t\tuntil [[ $TLS_VERSION_MIN_CHOICE =~ ^[1-2]$ ]]; do\n\t\t\tread -rp \"Minimum TLS version [1-2]: \" -e -i 1 TLS_VERSION_MIN_CHOICE\n\t\tdone\n\t\tcase $TLS_VERSION_MIN_CHOICE in\n\t\t1)\n\t\t\tTLS_VERSION_MIN=\"1.2\"\n\t\t\t;;\n\t\t2)\n\t\t\tTLS_VERSION_MIN=\"1.3\"\n\t\t\t;;\n\t\tesac\n\t\tlog_menu \"\"\n\t\tlog_prompt \"Choose TLS 1.3 cipher suites (used when TLS 1.3 is negotiated):\"\n\t\tlog_menu \"   1) All secure ciphers (recommended)\"\n\t\tlog_menu \"   2) AES-256-GCM only\"\n\t\tlog_menu \"   3) AES-128-GCM only\"\n\t\tlog_menu \"   4) ChaCha20-Poly1305 only\"\n\t\tuntil [[ $TLS13_CIPHER_CHOICE =~ ^[1-4]$ ]]; do\n\t\t\tread -rp \"TLS 1.3 cipher suite [1-4]: \" -e -i 1 TLS13_CIPHER_CHOICE\n\t\tdone\n\t\tcase $TLS13_CIPHER_CHOICE in\n\t\t1)\n\t\t\tTLS13_CIPHERSUITES=\"TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256\"\n\t\t\t;;\n\t\t2)\n\t\t\tTLS13_CIPHERSUITES=\"TLS_AES_256_GCM_SHA384\"\n\t\t\t;;\n\t\t3)\n\t\t\tTLS13_CIPHERSUITES=\"TLS_AES_128_GCM_SHA256\"\n\t\t\t;;\n\t\t4)\n\t\t\tTLS13_CIPHERSUITES=\"TLS_CHACHA20_POLY1305_SHA256\"\n\t\t\t;;\n\t\tesac\n\t\tlog_menu \"\"\n\t\tlog_prompt \"Choose TLS key exchange groups (for ECDH key exchange):\"\n\t\tlog_menu \"   1) All modern curves (recommended)\"\n\t\tlog_menu \"   2) X25519 only (most secure, may have compatibility issues)\"\n\t\tlog_menu \"   3) NIST curves only (prime256v1, secp384r1, secp521r1)\"\n\t\tuntil [[ $TLS_GROUPS_CHOICE =~ ^[1-3]$ ]]; do\n\t\t\tread -rp \"TLS groups [1-3]: \" -e -i 1 TLS_GROUPS_CHOICE\n\t\tdone\n\t\tcase $TLS_GROUPS_CHOICE in\n\t\t1)\n\t\t\tTLS_GROUPS=\"X25519:prime256v1:secp384r1:secp521r1\"\n\t\t\t;;\n\t\t2)\n\t\t\tTLS_GROUPS=\"X25519\"\n\t\t\t;;\n\t\t3)\n\t\t\tTLS_GROUPS=\"prime256v1:secp384r1:secp521r1\"\n\t\t\t;;\n\t\tesac\n\t\tlog_menu \"\"\n\t\t# The \"auth\" options behaves differently with AEAD ciphers (GCM, ChaCha20-Poly1305)\n\t\tif [[ $CIPHER =~ CBC$ ]]; then\n\t\t\tlog_prompt \"The digest algorithm authenticates data channel packets and tls-auth packets from the control channel.\"\n\t\telif [[ $CIPHER =~ GCM$ ]] || [[ $CIPHER == \"CHACHA20-POLY1305\" ]]; then\n\t\t\tlog_prompt \"The digest algorithm authenticates tls-auth packets from the control channel.\"\n\t\tfi\n\t\tlog_prompt \"Which digest algorithm do you want to use for HMAC?\"\n\t\tlog_menu \"   1) SHA-256 (recommended)\"\n\t\tlog_menu \"   2) SHA-384\"\n\t\tlog_menu \"   3) SHA-512\"\n\t\tuntil [[ $HMAC_ALG_CHOICE =~ ^[1-3]$ ]]; do\n\t\t\tread -rp \"Digest algorithm [1-3]: \" -e -i 1 HMAC_ALG_CHOICE\n\t\tdone\n\t\tcase $HMAC_ALG_CHOICE in\n\t\t1)\n\t\t\tHMAC_ALG=\"SHA256\"\n\t\t\t;;\n\t\t2)\n\t\t\tHMAC_ALG=\"SHA384\"\n\t\t\t;;\n\t\t3)\n\t\t\tHMAC_ALG=\"SHA512\"\n\t\t\t;;\n\t\tesac\n\t\tlog_menu \"\"\n\t\tlog_prompt \"You can add an additional layer of security to the control channel.\"\n\t\tlocal 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\")\n\t\tselect_with_labels \"Control channel security\" tls_sig_labels TLS_SIG_MODES \"crypt-v2\" TLS_SIG\n\tfi\n\tlog_menu \"\"\n\tlog_prompt \"Okay, that was all I needed. We are ready to setup your OpenVPN server now.\"\n\tlog_prompt \"You will be able to generate a client at the end of the installation.\"\n\tAPPROVE_INSTALL=${APPROVE_INSTALL:-n}\n\tif [[ $APPROVE_INSTALL =~ n ]]; then\n\t\tread -n1 -r -p \"Press any key to continue...\"\n\tfi\n}\n\nfunction installOpenVPN() {\n\tif [[ $NON_INTERACTIVE_INSTALL == \"y\" ]]; then\n\t\t# Resolve public IP if ENDPOINT not set\n\t\tif [[ -z $ENDPOINT ]]; then\n\t\t\tENDPOINT=$(resolvePublicIP)\n\t\tfi\n\n\t\t# Log non-interactive mode and parameters\n\t\tlog_info \"=== OpenVPN Non-Interactive Install ===\"\n\t\tlog_info \"Running in non-interactive mode with the following settings:\"\n\t\tlog_info \"  ENDPOINT=$ENDPOINT\"\n\t\tlog_info \"  ENDPOINT_TYPE=$ENDPOINT_TYPE\"\n\t\tlog_info \"  CLIENT_IPV4=$CLIENT_IPV4\"\n\t\tlog_info \"  CLIENT_IPV6=$CLIENT_IPV6\"\n\t\tlog_info \"  VPN_SUBNET_IPV4=$VPN_SUBNET_IPV4\"\n\t\tlog_info \"  VPN_SUBNET_IPV6=$VPN_SUBNET_IPV6\"\n\t\tlog_info \"  PORT=$PORT\"\n\t\tlog_info \"  PROTOCOL=$PROTOCOL\"\n\t\tlog_info \"  DNS=$DNS\"\n\t\t[[ -n $MTU ]] && log_info \"  MTU=$MTU\"\n\t\tlog_info \"  MULTI_CLIENT=$MULTI_CLIENT\"\n\t\tlog_info \"  AUTH_MODE=$AUTH_MODE\"\n\t\tlog_info \"  CLIENT=$CLIENT\"\n\t\tlog_info \"  CLIENT_CERT_DURATION_DAYS=$CLIENT_CERT_DURATION_DAYS\"\n\t\tlog_info \"  SERVER_CERT_DURATION_DAYS=$SERVER_CERT_DURATION_DAYS\"\n\tfi\n\n\t# Get the \"public\" interface from the default route\n\tNIC=$(ip -4 route ls | grep default | grep -Po '(?<=dev )(\\S+)' | head -1)\n\tif [[ -z $NIC ]] && [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\tNIC=$(ip -6 route show default | sed -ne 's/^default .* dev \\([^ ]*\\) .*$/\\1/p')\n\tfi\n\n\t# $NIC can not be empty for script rm-openvpn-rules.sh\n\tif [[ -z $NIC ]]; then\n\t\tlog_warn \"Could not detect public interface.\"\n\t\tlog_info \"This needs for setup MASQUERADE.\"\n\t\tuntil [[ $CONTINUE =~ (y|n) ]]; do\n\t\t\tread -rp \"Continue? [y/n]: \" -e CONTINUE\n\t\tdone\n\t\tif [[ $CONTINUE == \"n\" ]]; then\n\t\t\texit 1\n\t\tfi\n\tfi\n\n\t# If OpenVPN isn't installed yet, install it. This script is more-or-less\n\t# idempotent on multiple runs, but will only install OpenVPN from upstream\n\t# the first time.\n\tif [[ ! -e /etc/openvpn/server/server.conf ]]; then\n\t\tlog_header \"Installing OpenVPN\"\n\n\t\t# Setup official OpenVPN repository for latest versions\n\t\tinstallOpenVPNRepo\n\n\t\tlog_info \"Installing OpenVPN and dependencies...\"\n\t\t# socat is used for communicating with the OpenVPN management interface (client disconnect on revoke)\n\t\tif [[ $OS =~ (debian|ubuntu) ]]; then\n\t\t\trun_cmd_fatal \"Installing OpenVPN\" apt-get install -y openvpn iptables openssl curl ca-certificates tar dnsutils socat\n\t\telif [[ $OS == 'centos' ]]; then\n\t\t\trun_cmd_fatal \"Installing OpenVPN\" yum install -y openvpn iptables openssl ca-certificates curl tar bind-utils socat 'policycoreutils-python*'\n\t\telif [[ $OS == 'oracle' ]]; then\n\t\t\trun_cmd_fatal \"Installing OpenVPN\" yum install -y openvpn iptables openssl ca-certificates curl tar bind-utils socat policycoreutils-python-utils\n\t\telif [[ $OS == 'amzn2023' ]]; then\n\t\t\trun_cmd_fatal \"Installing OpenVPN\" dnf install -y openvpn iptables openssl ca-certificates curl tar bind-utils socat\n\t\telif [[ $OS == 'fedora' ]]; then\n\t\t\trun_cmd_fatal \"Installing OpenVPN\" dnf install -y openvpn iptables openssl ca-certificates curl tar bind-utils socat policycoreutils-python-utils\n\t\telif [[ $OS == 'opensuse' ]]; then\n\t\t\trun_cmd_fatal \"Installing OpenVPN\" zypper install -y openvpn iptables openssl ca-certificates curl tar bind-utils socat\n\t\telif [[ $OS == 'arch' ]]; then\n\t\t\trun_cmd_fatal \"Installing OpenVPN\" pacman --needed --noconfirm -Syu openvpn iptables openssl ca-certificates curl tar bind socat\n\t\tfi\n\n\t\t# Verify ChaCha20-Poly1305 compatibility if selected\n\t\tif [[ $CIPHER == \"CHACHA20-POLY1305\" ]] || [[ $CC_CIPHER =~ CHACHA20 ]]; then\n\t\t\tlocal installed_version\n\t\t\tinstalled_version=$(openvpn --version 2>/dev/null | head -1 | awk '{print $2}')\n\t\t\tif ! openvpnVersionAtLeast \"2.5\"; then\n\t\t\t\tlog_fatal \"ChaCha20-Poly1305 requires OpenVPN 2.5 or later. Installed version: $installed_version\"\n\t\t\tfi\n\t\t\tlog_info \"OpenVPN version supports ChaCha20-Poly1305\"\n\t\tfi\n\n\t\t# Check Data Channel Offload (DCO) availability\n\t\tif isDCOAvailable; then\n\t\t\t# Check if configuration is DCO-compatible (udp or udp6)\n\t\t\tif [[ $PROTOCOL =~ ^udp ]] && [[ $CIPHER =~ (GCM|CHACHA20-POLY1305) ]]; then\n\t\t\t\tlog_info \"Data Channel Offload (DCO) is available and will be used for improved performance\"\n\t\t\telse\n\t\t\t\tlog_info \"Data Channel Offload (DCO) is available but not enabled (requires UDP, AEAD cipher)\"\n\t\t\tfi\n\t\telse\n\t\t\tlog_info \"Data Channel Offload (DCO) is not available (requires OpenVPN 2.6+ and kernel support)\"\n\t\tfi\n\n\t\t# Create the server directory (OpenVPN 2.4+ directory structure)\n\t\trun_cmd_fatal \"Creating server directory\" mkdir -p /etc/openvpn/server\n\tfi\n\n\t# Determine which user/group OpenVPN should run as\n\t# - Fedora/RHEL/Amazon create 'openvpn' user with 'openvpn' group\n\t# - Arch creates 'openvpn' user with 'network' group\n\t# - Debian/Ubuntu/openSUSE don't create a dedicated user, use 'nobody'\n\t#\n\t# Also check if the systemd service file already handles user/group switching.\n\t# If so, we shouldn't add user/group to config (would cause double privilege drop).\n\tSYSTEMD_HANDLES_USER=false\n\tfor service_file in /usr/lib/systemd/system/openvpn-server@.service /lib/systemd/system/openvpn-server@.service; do\n\t\tif [[ -f \"$service_file\" ]] && grep -q \"^User=\" \"$service_file\"; then\n\t\t\tSYSTEMD_HANDLES_USER=true\n\t\t\tbreak\n\t\tfi\n\tdone\n\n\tif id openvpn &>/dev/null; then\n\t\tOPENVPN_USER=openvpn\n\t\t# Get the openvpn user's primary group (e.g., 'openvpn' on Fedora, 'network' on Arch)\n\t\tOPENVPN_GROUP=$(id -gn openvpn 2>/dev/null || echo openvpn)\n\telse\n\t\tOPENVPN_USER=nobody\n\t\tif grep -qs \"^nogroup:\" /etc/group; then\n\t\t\tOPENVPN_GROUP=nogroup\n\t\telse\n\t\t\tOPENVPN_GROUP=nobody\n\t\tfi\n\tfi\n\n\t# Install the latest version of easy-rsa from source, if not already installed.\n\tif [[ ! -d /etc/openvpn/server/easy-rsa/ ]]; then\n\t\trun_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\"\n\t\tlog_info \"Verifying Easy-RSA checksum...\"\n\t\tCHECKSUM_OUTPUT=$(echo \"${EASYRSA_SHA256}  $HOME/easy-rsa.tgz\" | sha256sum -c 2>&1) || {\n\t\t\t_log_to_file \"[CHECKSUM] $CHECKSUM_OUTPUT\"\n\t\t\trun_cmd \"Cleaning up failed download\" rm -f ~/easy-rsa.tgz\n\t\t\tlog_fatal \"SHA256 checksum verification failed for easy-rsa download!\"\n\t\t}\n\t\t_log_to_file \"[CHECKSUM] $CHECKSUM_OUTPUT\"\n\t\trun_cmd_fatal \"Creating Easy-RSA directory\" mkdir -p /etc/openvpn/server/easy-rsa\n\t\trun_cmd_fatal \"Extracting Easy-RSA\" tar xzf ~/easy-rsa.tgz --strip-components=1 --no-same-owner --directory /etc/openvpn/server/easy-rsa\n\t\trun_cmd \"Cleaning up archive\" rm -f ~/easy-rsa.tgz\n\n\t\tcd /etc/openvpn/server/easy-rsa/ || return\n\t\tcase $CERT_TYPE in\n\t\tecdsa)\n\t\t\techo \"set_var EASYRSA_ALGO ec\" >vars\n\t\t\techo \"set_var EASYRSA_CURVE $CERT_CURVE\" >>vars\n\t\t\t;;\n\t\trsa)\n\t\t\techo \"set_var EASYRSA_KEY_SIZE $RSA_KEY_SIZE\" >vars\n\t\t\t;;\n\t\tesac\n\n\t\t# Generate a random, alphanumeric identifier of 16 characters for CN and one for server name\n\t\t# Note: 2>/dev/null suppresses \"Broken pipe\" errors from fold when head exits early\n\t\tSERVER_CN=\"cn_$(head /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 2>/dev/null | head -n 1)\"\n\t\techo \"$SERVER_CN\" >SERVER_CN_GENERATED\n\t\tSERVER_NAME=\"server_$(head /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 2>/dev/null | head -n 1)\"\n\t\techo \"$SERVER_NAME\" >SERVER_NAME_GENERATED\n\n\t\t# Create the PKI, set up the CA, the DH params and the server certificate\n\t\tlog_info \"Initializing PKI...\"\n\t\trun_cmd_fatal \"Initializing PKI\" ./easyrsa init-pki\n\n\t\tif [[ $AUTH_MODE == \"pki\" ]]; then\n\t\t\t# Traditional PKI mode with CA\n\t\t\texport EASYRSA_CA_EXPIRE=$DEFAULT_CERT_VALIDITY_DURATION_DAYS\n\t\t\tlog_info \"Building CA...\"\n\t\t\trun_cmd_fatal \"Building CA\" ./easyrsa --batch --req-cn=\"$SERVER_CN\" build-ca nopass\n\n\t\t\texport EASYRSA_CERT_EXPIRE=${SERVER_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}\n\t\t\tlog_info \"Building server certificate...\"\n\t\t\trun_cmd_fatal \"Building server certificate\" ./easyrsa --batch build-server-full \"$SERVER_NAME\" nopass\n\t\t\texport EASYRSA_CRL_DAYS=$DEFAULT_CRL_VALIDITY_DURATION_DAYS\n\t\t\trun_cmd_fatal \"Generating CRL\" ./easyrsa gen-crl\n\t\telse\n\t\t\t# Fingerprint mode with self-signed certificates (OpenVPN 2.6+)\n\t\t\tlog_info \"Building self-signed server certificate for fingerprint mode...\"\n\t\t\texport EASYRSA_CERT_EXPIRE=${SERVER_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}\n\t\t\trun_cmd_fatal \"Building self-signed server certificate\" ./easyrsa --batch self-sign-server \"$SERVER_NAME\" nopass\n\n\t\t\t# Extract and store server fingerprint\n\t\t\tSERVER_FINGERPRINT=$(openssl x509 -in \"pki/issued/$SERVER_NAME.crt\" -fingerprint -sha256 -noout | cut -d'=' -f2)\n\t\t\tif [[ -z $SERVER_FINGERPRINT ]]; then\n\t\t\t\tlog_error \"Failed to extract server certificate fingerprint\"\n\t\t\t\texit 1\n\t\t\tfi\n\t\t\tmkdir -p /etc/openvpn/server\n\t\t\techo \"$SERVER_FINGERPRINT\" >/etc/openvpn/server/server-fingerprint\n\t\t\tlog_info \"Server fingerprint: $SERVER_FINGERPRINT\"\n\t\tfi\n\n\t\tlog_info \"Generating TLS key...\"\n\t\tcase $TLS_SIG in\n\t\tcrypt-v2)\n\t\t\t# Generate tls-crypt-v2 server key\n\t\t\trun_cmd_fatal \"Generating tls-crypt-v2 server key\" openvpn --genkey tls-crypt-v2-server /etc/openvpn/server/tls-crypt-v2.key\n\t\t\t;;\n\t\tcrypt)\n\t\t\t# Generate tls-crypt key\n\t\t\trun_cmd_fatal \"Generating tls-crypt key\" openvpn --genkey secret /etc/openvpn/server/tls-crypt.key\n\t\t\t;;\n\t\tauth)\n\t\t\t# Generate tls-auth key\n\t\t\trun_cmd_fatal \"Generating tls-auth key\" openvpn --genkey secret /etc/openvpn/server/tls-auth.key\n\t\t\t;;\n\t\tesac\n\t\t# Store auth mode for later use\n\t\techo \"$AUTH_MODE\" >AUTH_MODE_GENERATED\n\telse\n\t\t# If easy-rsa is already installed, grab the generated SERVER_NAME\n\t\t# for client configs\n\t\tcd /etc/openvpn/server/easy-rsa/ || return\n\t\tSERVER_NAME=$(cat SERVER_NAME_GENERATED)\n\t\t# Read stored auth mode\n\t\tif [[ -f AUTH_MODE_GENERATED ]]; then\n\t\t\tAUTH_MODE=$(cat AUTH_MODE_GENERATED)\n\t\telse\n\t\t\t# Default to pki for existing installations\n\t\t\tAUTH_MODE=\"pki\"\n\t\tfi\n\tfi\n\n\t# Move all the generated files\n\tlog_info \"Copying certificates...\"\n\tif [[ $AUTH_MODE == \"pki\" ]]; then\n\t\trun_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\n\t\t# Make cert revocation list readable for non-root\n\t\trun_cmd \"Setting CRL permissions\" chmod 644 /etc/openvpn/server/crl.pem\n\telse\n\t\t# Fingerprint mode: only copy server cert and key (no CA or CRL)\n\t\trun_cmd_fatal \"Copying certificates to /etc/openvpn/server\" cp \"pki/issued/$SERVER_NAME.crt\" \"pki/private/$SERVER_NAME.key\" /etc/openvpn/server\n\tfi\n\n\t# Generate server.conf\n\tlog_info \"Generating server configuration...\"\n\techo \"port $PORT\" >/etc/openvpn/server/server.conf\n\n\t# Protocol selection: use proto6 variants if endpoint is IPv6\n\tif [[ $ENDPOINT_TYPE == \"6\" ]]; then\n\t\techo \"proto ${PROTOCOL}6\" >>/etc/openvpn/server/server.conf\n\telse\n\t\techo \"proto $PROTOCOL\" >>/etc/openvpn/server/server.conf\n\tfi\n\n\tif [[ $MULTI_CLIENT == \"y\" ]]; then\n\t\techo \"duplicate-cn\" >>/etc/openvpn/server/server.conf\n\tfi\n\n\techo \"dev tun\" >>/etc/openvpn/server/server.conf\n\t# Only add user/group if systemd doesn't handle it (avoids double privilege drop)\n\tif [[ $SYSTEMD_HANDLES_USER == \"false\" ]]; then\n\t\techo \"user $OPENVPN_USER\ngroup $OPENVPN_GROUP\" >>/etc/openvpn/server/server.conf\n\tfi\n\techo \"persist-key\npersist-tun\nkeepalive 10 120\ntopology subnet\" >>/etc/openvpn/server/server.conf\n\n\t# IPv4 server directive - always assign IPv4 to clients for proper routing\n\t# Even for IPv6-only mode, we need IPv4 addresses so redirect-gateway def1 can block IPv4 leaks\n\techo \"server $VPN_SUBNET_IPV4 255.255.255.0\" >>/etc/openvpn/server/server.conf\n\n\t# IPv6 server directive (only if clients get IPv6)\n\tif [[ $CLIENT_IPV6 == \"y\" ]]; then\n\t\t{\n\t\t\techo \"server-ipv6 ${VPN_SUBNET_IPV6}/112\"\n\t\t\techo \"tun-ipv6\"\n\t\t\techo \"push tun-ipv6\"\n\t\t} >>/etc/openvpn/server/server.conf\n\tfi\n\n\t# ifconfig-pool-persist is incompatible with duplicate-cn\n\tif [[ $MULTI_CLIENT != \"y\" ]]; then\n\t\techo \"ifconfig-pool-persist ipp.txt\" >>/etc/openvpn/server/server.conf\n\tfi\n\n\t# DNS resolvers\n\tcase $DNS in\n\tsystem)\n\t\t# Locate the proper resolv.conf\n\t\t# Needed for systems running systemd-resolved\n\t\tif grep -q \"127.0.0.53\" \"/etc/resolv.conf\"; then\n\t\t\tRESOLVCONF='/run/systemd/resolve/resolv.conf'\n\t\telse\n\t\t\tRESOLVCONF='/etc/resolv.conf'\n\t\tfi\n\t\t# Obtain the resolvers from resolv.conf and use them for OpenVPN\n\t\tsed -ne 's/^nameserver[[:space:]]\\+\\([^[:space:]]\\+\\).*$/\\1/p' $RESOLVCONF | while read -r line; do\n\t\t\t# Copy IPv4 resolvers if client has IPv4, or IPv6 resolvers if client has IPv6\n\t\t\tif [[ $line =~ ^[0-9.]*$ ]] && [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\t\techo \"push \\\"dhcp-option DNS $line\\\"\" >>/etc/openvpn/server/server.conf\n\t\t\telif [[ $line =~ : ]] && [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\t\techo \"push \\\"dhcp-option DNS $line\\\"\" >>/etc/openvpn/server/server.conf\n\t\t\tfi\n\t\tdone\n\t\t;;\n\tunbound)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo \"push \\\"dhcp-option DNS $VPN_GATEWAY_IPV4\\\"\" >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo \"push \\\"dhcp-option DNS $VPN_GATEWAY_IPV6\\\"\" >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\t;;\n\tcloudflare)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 1.0.0.1\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 1.1.1.1\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 2606:4700:4700::1001\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 2606:4700:4700::1111\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\t;;\n\tquad9)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 9.9.9.9\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 149.112.112.112\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 2620:fe::fe\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 2620:fe::9\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\t;;\n\tquad9-uncensored)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 9.9.9.10\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 149.112.112.10\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 2620:fe::10\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 2620:fe::fe:10\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\t;;\n\tfdn)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 80.67.169.40\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 80.67.169.12\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 2001:910:800::40\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 2001:910:800::12\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\t;;\n\tdnswatch)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 84.200.69.80\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 84.200.70.40\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 2001:1608:10:25::1c04:b12f\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 2001:1608:10:25::9249:d69b\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\t;;\n\topendns)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 208.67.222.222\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 208.67.220.220\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 2620:119:35::35\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 2620:119:53::53\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\t;;\n\tgoogle)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 8.8.8.8\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 8.8.4.4\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 2001:4860:4860::8888\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 2001:4860:4860::8844\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\t;;\n\tyandex)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 77.88.8.8\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 77.88.8.1\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 2a02:6b8::feed:0ff\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 2a02:6b8:0:1::feed:0ff\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\t;;\n\tadguard)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 94.140.14.14\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 94.140.15.15\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 2a10:50c0::ad1:ff\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 2a10:50c0::ad2:ff\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\t;;\n\tnextdns)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 45.90.28.167\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 45.90.30.167\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo 'push \"dhcp-option DNS 2a07:a8c0::\"' >>/etc/openvpn/server/server.conf\n\t\t\techo 'push \"dhcp-option DNS 2a07:a8c1::\"' >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\t;;\n\tcustom)\n\t\techo \"push \\\"dhcp-option DNS $DNS1\\\"\" >>/etc/openvpn/server/server.conf\n\t\tif [[ $DNS2 != \"\" ]]; then\n\t\t\techo \"push \\\"dhcp-option DNS $DNS2\\\"\" >>/etc/openvpn/server/server.conf\n\t\tfi\n\t\t;;\n\tesac\n\n\t# Redirect gateway settings - always redirect both IPv4 and IPv6 to prevent leaks\n\t# For IPv4: redirect-gateway def1 routes all IPv4 through VPN (or drops it if IPv4 not configured)\n\t# For IPv6: route-ipv6 + redirect-gateway ipv6 routes all IPv6, or block-ipv6 drops it\n\techo 'push \"redirect-gateway def1 bypass-dhcp\"' >>/etc/openvpn/server/server.conf\n\tif [[ $CLIENT_IPV6 == \"y\" ]]; then\n\t\techo 'push \"route-ipv6 2000::/3\"' >>/etc/openvpn/server/server.conf\n\t\techo 'push \"redirect-gateway ipv6\"' >>/etc/openvpn/server/server.conf\n\telse\n\t\t# Block IPv6 on clients to prevent IPv6 leaks when VPN only handles IPv4\n\t\techo 'push \"block-ipv6\"' >>/etc/openvpn/server/server.conf\n\tfi\n\n\tif [[ -n $MTU ]]; then\n\t\techo \"tun-mtu $MTU\" >>/etc/openvpn/server/server.conf\n\tfi\n\n\t# Use ECDH key exchange (dh none) with tls-groups for curve negotiation\n\techo \"dh none\" >>/etc/openvpn/server/server.conf\n\techo \"tls-groups $TLS_GROUPS\" >>/etc/openvpn/server/server.conf\n\n\tcase $TLS_SIG in\n\tcrypt-v2)\n\t\techo \"tls-crypt-v2 tls-crypt-v2.key\" >>/etc/openvpn/server/server.conf\n\t\t;;\n\tcrypt)\n\t\techo \"tls-crypt tls-crypt.key\" >>/etc/openvpn/server/server.conf\n\t\t;;\n\tauth)\n\t\techo \"tls-auth tls-auth.key 0\" >>/etc/openvpn/server/server.conf\n\t\t;;\n\tesac\n\n\t# Common server config options\n\t# PKI mode adds crl-verify, ca, and remote-cert-tls\n\t# Fingerprint mode: <peer-fingerprint> block is added when first client is created\n\t{\n\t\t[[ $AUTH_MODE == \"pki\" ]] && echo \"crl-verify crl.pem\nca ca.crt\"\n\t\techo \"cert $SERVER_NAME.crt\nkey $SERVER_NAME.key\nauth $HMAC_ALG\ncipher $CIPHER\nignore-unknown-option data-ciphers\ndata-ciphers $CIPHER\nncp-ciphers $CIPHER\ntls-server\ntls-version-min $TLS_VERSION_MIN\"\n\t\t[[ $AUTH_MODE == \"pki\" ]] && echo \"remote-cert-tls client\"\n\t\techo \"tls-cipher $CC_CIPHER\ntls-ciphersuites $TLS13_CIPHERSUITES\nclient-config-dir ccd\nstatus /var/log/openvpn/status.log\nmanagement /var/run/openvpn-server/server.sock unix\nverb 3\"\n\t} >>/etc/openvpn/server/server.conf\n\n\t# Create client-config-dir dir\n\trun_cmd_fatal \"Creating client config directory\" mkdir -p /etc/openvpn/server/ccd\n\t# Create log dir\n\trun_cmd_fatal \"Creating log directory\" mkdir -p /var/log/openvpn\n\n\t# On distros that use a dedicated OpenVPN user (not \"nobody\"), e.g., Fedora, RHEL, Arch,\n\t# set ownership so OpenVPN can read config/certs and write to log directory\n\tif [[ $OPENVPN_USER != \"nobody\" ]]; then\n\t\tlog_info \"Setting ownership for OpenVPN user...\"\n\t\tchown -R \"$OPENVPN_USER:$OPENVPN_GROUP\" /etc/openvpn/server\n\t\tchown \"$OPENVPN_USER:$OPENVPN_GROUP\" /var/log/openvpn\n\tfi\n\n\t# Enable routing\n\tlog_info \"Enabling IP forwarding...\"\n\trun_cmd_fatal \"Creating sysctl.d directory\" mkdir -p /etc/sysctl.d\n\n\t# Enable IPv4 forwarding if clients get IPv4\n\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\techo 'net.ipv4.ip_forward=1' >/etc/sysctl.d/99-openvpn.conf\n\telse\n\t\techo '# IPv4 forwarding not needed (no IPv4 clients)' >/etc/sysctl.d/99-openvpn.conf\n\tfi\n\t# Enable IPv6 forwarding if clients get IPv6\n\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\techo 'net.ipv6.conf.all.forwarding=1' >>/etc/sysctl.d/99-openvpn.conf\n\tfi\n\t# Apply sysctl rules\n\trun_cmd \"Applying sysctl rules\" sysctl --system\n\n\t# If SELinux is enabled and a custom port was selected, we need this\n\tif hash sestatus 2>/dev/null; then\n\t\tif sestatus | grep \"Current mode\" | grep -qs \"enforcing\"; then\n\t\t\tif [[ $PORT != '1194' ]]; then\n\t\t\t\t# Strip \"6\" suffix from protocol (semanage expects \"udp\" or \"tcp\", not \"udp6\"/\"tcp6\")\n\t\t\t\tSELINUX_PROTOCOL=\"${PROTOCOL%6}\"\n\t\t\t\trun_cmd \"Configuring SELinux port\" semanage port -a -t openvpn_port_t -p \"$SELINUX_PROTOCOL\" \"$PORT\"\n\t\t\tfi\n\t\tfi\n\tfi\n\n\t# Finally, restart and enable OpenVPN\n\t# OpenVPN 2.4+ uses openvpn-server@.service with config in /etc/openvpn/server/\n\tlog_info \"Configuring OpenVPN service...\"\n\n\t# Find the service file (location and name vary by distro)\n\t# Modern distros: openvpn-server@.service in /usr/lib/systemd/system/ or /lib/systemd/system/\n\t# openSUSE: openvpn@.service (old-style) that we need to adapt\n\tif [[ -f /usr/lib/systemd/system/openvpn-server@.service ]]; then\n\t\tSERVICE_SOURCE=\"/usr/lib/systemd/system/openvpn-server@.service\"\n\telif [[ -f /lib/systemd/system/openvpn-server@.service ]]; then\n\t\tSERVICE_SOURCE=\"/lib/systemd/system/openvpn-server@.service\"\n\telif [[ -f /usr/lib/systemd/system/openvpn@.service ]]; then\n\t\t# openSUSE uses old-style service, we'll create our own openvpn-server@.service\n\t\tSERVICE_SOURCE=\"/usr/lib/systemd/system/openvpn@.service\"\n\telif [[ -f /lib/systemd/system/openvpn@.service ]]; then\n\t\tSERVICE_SOURCE=\"/lib/systemd/system/openvpn@.service\"\n\telse\n\t\tlog_fatal \"Could not find openvpn-server@.service or openvpn@.service file\"\n\tfi\n\n\t# Don't modify package-provided service, copy to /etc/systemd/system/\n\trun_cmd_fatal \"Copying OpenVPN service file\" cp \"$SERVICE_SOURCE\" /etc/systemd/system/openvpn-server@.service\n\n\t# Workaround to fix OpenVPN service on OpenVZ\n\trun_cmd \"Patching service file (LimitNPROC)\" sed -i 's|LimitNPROC|#LimitNPROC|' /etc/systemd/system/openvpn-server@.service\n\n\t# Ensure the service uses /etc/openvpn/server/ as working directory\n\t# This is needed for openSUSE which uses old-style paths by default\n\tif grep -q \"cd /etc/openvpn/\" /etc/systemd/system/openvpn-server@.service; then\n\t\trun_cmd \"Patching service file (paths)\" sed -i 's|/etc/openvpn/|/etc/openvpn/server/|g' /etc/systemd/system/openvpn-server@.service\n\tfi\n\n\t# Ensure RuntimeDirectory is set for the management socket\n\t# Some distros (e.g., openSUSE) don't include this in their service file\n\tif ! grep -q \"RuntimeDirectory=\" /etc/systemd/system/openvpn-server@.service; then\n\t\trun_cmd \"Patching service file (RuntimeDirectory)\" sed -i '/\\[Service\\]/a RuntimeDirectory=openvpn-server' /etc/systemd/system/openvpn-server@.service\n\tfi\n\n\t# AppArmor: Ubuntu 25.04+ ships an enforcing profile for OpenVPN\n\t# (/etc/apparmor.d/openvpn) that doesn't allow the management unix socket\n\t# in /run/openvpn-server/. Add a local override to permit this.\n\tif [[ -f /etc/apparmor.d/openvpn ]]; then\n\t\tlog_info \"Configuring AppArmor for OpenVPN...\"\n\t\tmkdir -p /etc/apparmor.d/local\n\t\tif [[ ! -f /etc/apparmor.d/local/openvpn ]] || ! grep -q \"openvpn-server\" /etc/apparmor.d/local/openvpn; then\n\t\t\t{\n\t\t\t\techo \"# Allow OpenVPN management socket and status files in openvpn-server directory\"\n\t\t\t\techo \"/{,var/}run/openvpn-server/** rw,\"\n\t\t\t} >>/etc/apparmor.d/local/openvpn\n\t\tfi\n\t\trun_cmd \"Reloading AppArmor profile\" apparmor_parser -r /etc/apparmor.d/openvpn\n\tfi\n\n\trun_cmd \"Reloading systemd\" systemctl daemon-reload\n\trun_cmd \"Enabling OpenVPN service\" systemctl enable openvpn-server@server\n\t# In fingerprint mode, delay service start until first client is created\n\t# (OpenVPN requires at least one fingerprint or a CA to start)\n\tif [[ $AUTH_MODE == \"pki\" ]]; then\n\t\trun_cmd \"Starting OpenVPN service\" systemctl restart openvpn-server@server\n\tfi\n\n\tif [[ $DNS == \"unbound\" ]]; then\n\t\tinstallUnbound\n\tfi\n\n\t# Configure firewall rules\n\t# Use source-based rules for VPN traffic (works reliably regardless of which tun interface OpenVPN uses)\n\tlog_info \"Configuring firewall rules...\"\n\n\tif systemctl is-active --quiet firewalld; then\n\t\t# Use firewalld native commands for systems with firewalld active\n\t\tlog_info \"firewalld detected, using firewall-cmd...\"\n\t\trun_cmd \"Adding OpenVPN port to firewalld\" firewall-cmd --permanent --add-port=\"$PORT/$PROTOCOL\"\n\t\trun_cmd \"Adding masquerade to firewalld\" firewall-cmd --permanent --add-masquerade\n\n\t\t# Add rich rules for VPN traffic (source-based only, as firewalld doesn't reliably\n\t\t# support interface patterns with direct rules when using nftables backend)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\trun_cmd \"Adding IPv4 VPN subnet rule\" firewall-cmd --permanent --add-rich-rule=\"rule family=\\\"ipv4\\\" source address=\\\"$VPN_SUBNET_IPV4/24\\\" accept\"\n\t\tfi\n\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\trun_cmd \"Adding IPv6 VPN subnet rule\" firewall-cmd --permanent --add-rich-rule=\"rule family=\\\"ipv6\\\" source address=\\\"${VPN_SUBNET_IPV6}/112\\\" accept\"\n\t\tfi\n\n\t\trun_cmd \"Reloading firewalld\" firewall-cmd --reload\n\telif systemctl is-active --quiet nftables; then\n\t\t# Use nftables native rules for systems with nftables active\n\t\tlog_info \"nftables detected, configuring nftables rules...\"\n\t\trun_cmd_fatal \"Creating nftables directory\" mkdir -p /etc/nftables\n\n\t\t# Create nftables rules file\n\t\t{\n\t\t\techo \"table inet openvpn {\"\n\t\t\techo \"\tchain input {\"\n\t\t\techo \"\t\ttype filter hook input priority 0; policy accept;\"\n\t\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\t\techo \"\t\tiifname \\\"tun*\\\" ip saddr $VPN_SUBNET_IPV4/24 accept\"\n\t\t\tfi\n\t\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\t\techo \"\t\tiifname \\\"tun*\\\" ip6 saddr ${VPN_SUBNET_IPV6}/112 accept\"\n\t\t\tfi\n\t\t\techo \"\t\tiifname \\\"$NIC\\\" $PROTOCOL dport $PORT accept\"\n\t\t\techo \"\t}\"\n\t\t\techo \"\"\n\t\t\techo \"\tchain forward {\"\n\t\t\techo \"\t\ttype filter hook forward priority 0; policy accept;\"\n\t\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\t\techo \"\t\tiifname \\\"tun*\\\" ip saddr $VPN_SUBNET_IPV4/24 accept\"\n\t\t\t\techo \"\t\toifname \\\"tun*\\\" ip daddr $VPN_SUBNET_IPV4/24 accept\"\n\t\t\tfi\n\t\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\t\techo \"\t\tiifname \\\"tun*\\\" ip6 saddr ${VPN_SUBNET_IPV6}/112 accept\"\n\t\t\t\techo \"\t\toifname \\\"tun*\\\" ip6 daddr ${VPN_SUBNET_IPV6}/112 accept\"\n\t\t\tfi\n\t\t\techo \"\t}\"\n\t\t\techo \"}\"\n\t\t} >/etc/nftables/openvpn.nft\n\n\t\t# IPv4 NAT rules (only if clients get IPv4)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo \"\ntable ip openvpn-nat {\n\tchain postrouting {\n\t\ttype nat hook postrouting priority 100; policy accept;\n\t\tip saddr $VPN_SUBNET_IPV4/24 oifname \\\"$NIC\\\" masquerade\n\t}\n}\" >>/etc/nftables/openvpn.nft\n\t\tfi\n\n\t\t# IPv6 NAT rules (only if clients get IPv6)\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo \"\ntable ip6 openvpn-nat {\n\tchain postrouting {\n\t\ttype nat hook postrouting priority 100; policy accept;\n\t\tip6 saddr ${VPN_SUBNET_IPV6}/112 oifname \\\"$NIC\\\" masquerade\n\t}\n}\" >>/etc/nftables/openvpn.nft\n\t\tfi\n\n\t\t# Add include to nftables.conf if not already present\n\t\tif ! grep -q 'include.*/etc/nftables/openvpn.nft' /etc/nftables.conf; then\n\t\t\trun_cmd \"Adding include to nftables.conf\" sh -c 'echo \"include \\\"/etc/nftables/openvpn.nft\\\"\" >> /etc/nftables.conf'\n\t\tfi\n\n\t\t# Reload nftables to apply rules\n\t\trun_cmd \"Reloading nftables\" systemctl reload nftables\n\telse\n\t\t# Use iptables for systems without firewalld or nftables\n\t\trun_cmd_fatal \"Creating iptables directory\" mkdir -p /etc/iptables\n\n\t\t# Script to add rules\n\t\techo \"#!/bin/sh\" >/etc/iptables/add-openvpn-rules.sh\n\n\t\t# IPv4 rules (only if clients get IPv4)\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo \"iptables -t nat -I POSTROUTING 1 -s $VPN_SUBNET_IPV4/24 -o $NIC -j MASQUERADE\niptables -I INPUT 1 -i tun+ -s $VPN_SUBNET_IPV4/24 -j ACCEPT\niptables -I FORWARD 1 -i tun+ -s $VPN_SUBNET_IPV4/24 -j ACCEPT\niptables -I FORWARD 1 -o tun+ -d $VPN_SUBNET_IPV4/24 -j ACCEPT\niptables -I INPUT 1 -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT\" >>/etc/iptables/add-openvpn-rules.sh\n\t\tfi\n\n\t\t# IPv6 rules (only if clients get IPv6)\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo \"ip6tables -t nat -I POSTROUTING 1 -s ${VPN_SUBNET_IPV6}/112 -o $NIC -j MASQUERADE\nip6tables -I INPUT 1 -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j ACCEPT\nip6tables -I FORWARD 1 -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j ACCEPT\nip6tables -I FORWARD 1 -o tun+ -d ${VPN_SUBNET_IPV6}/112 -j ACCEPT\nip6tables -I INPUT 1 -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT\" >>/etc/iptables/add-openvpn-rules.sh\n\t\tfi\n\n\t\t# Script to remove rules\n\t\techo \"#!/bin/sh\" >/etc/iptables/rm-openvpn-rules.sh\n\n\t\t# IPv4 removal rules\n\t\tif [[ $CLIENT_IPV4 == 'y' ]]; then\n\t\t\techo \"iptables -t nat -D POSTROUTING -s $VPN_SUBNET_IPV4/24 -o $NIC -j MASQUERADE\niptables -D INPUT -i tun+ -s $VPN_SUBNET_IPV4/24 -j ACCEPT\niptables -D FORWARD -i tun+ -s $VPN_SUBNET_IPV4/24 -j ACCEPT\niptables -D FORWARD -o tun+ -d $VPN_SUBNET_IPV4/24 -j ACCEPT\niptables -D INPUT -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT\" >>/etc/iptables/rm-openvpn-rules.sh\n\t\tfi\n\n\t\t# IPv6 removal rules\n\t\tif [[ $CLIENT_IPV6 == 'y' ]]; then\n\t\t\techo \"ip6tables -t nat -D POSTROUTING -s ${VPN_SUBNET_IPV6}/112 -o $NIC -j MASQUERADE\nip6tables -D INPUT -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j ACCEPT\nip6tables -D FORWARD -i tun+ -s ${VPN_SUBNET_IPV6}/112 -j ACCEPT\nip6tables -D FORWARD -o tun+ -d ${VPN_SUBNET_IPV6}/112 -j ACCEPT\nip6tables -D INPUT -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT\" >>/etc/iptables/rm-openvpn-rules.sh\n\t\tfi\n\n\t\trun_cmd \"Making add-openvpn-rules.sh executable\" chmod +x /etc/iptables/add-openvpn-rules.sh\n\t\trun_cmd \"Making rm-openvpn-rules.sh executable\" chmod +x /etc/iptables/rm-openvpn-rules.sh\n\n\t\t# Handle the rules via a systemd script\n\t\techo \"[Unit]\nDescription=iptables rules for OpenVPN\nAfter=firewalld.service\nBefore=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart=/etc/iptables/add-openvpn-rules.sh\nExecStop=/etc/iptables/rm-openvpn-rules.sh\nRemainAfterExit=yes\n\n[Install]\nWantedBy=multi-user.target\" >/etc/systemd/system/iptables-openvpn.service\n\n\t\t# Enable service and apply rules\n\t\trun_cmd \"Reloading systemd\" systemctl daemon-reload\n\t\trun_cmd \"Enabling iptables service\" systemctl enable iptables-openvpn\n\t\trun_cmd \"Starting iptables service\" systemctl start iptables-openvpn\n\tfi\n\n\t# If the server is behind a NAT, use the correct IP address for the clients to connect to\n\tif [[ $ENDPOINT != \"\" ]]; then\n\t\tIP=$ENDPOINT\n\tfi\n\n\t# client-template.txt is created so we have a template to add further users later\n\tlog_info \"Creating client template...\"\n\techo \"client\" >/etc/openvpn/server/client-template.txt\n\tif [[ $PROTOCOL == 'udp' ]]; then\n\t\techo \"proto udp\" >>/etc/openvpn/server/client-template.txt\n\t\techo \"explicit-exit-notify\" >>/etc/openvpn/server/client-template.txt\n\telif [[ $PROTOCOL == 'udp6' ]]; then\n\t\techo \"proto udp6\" >>/etc/openvpn/server/client-template.txt\n\t\techo \"explicit-exit-notify\" >>/etc/openvpn/server/client-template.txt\n\telif [[ $PROTOCOL == 'tcp' ]]; then\n\t\techo \"proto tcp-client\" >>/etc/openvpn/server/client-template.txt\n\telif [[ $PROTOCOL == 'tcp6' ]]; then\n\t\techo \"proto tcp6-client\" >>/etc/openvpn/server/client-template.txt\n\tfi\n\t# Common client template options\n\t# PKI mode adds remote-cert-tls and verify-x509-name\n\t# Fingerprint mode adds peer-fingerprint when generating client config\n\t{\n\t\techo \"remote $IP $PORT\ndev tun\nresolv-retry infinite\nnobind\npersist-key\npersist-tun\"\n\t\t[[ $AUTH_MODE == \"pki\" ]] && echo \"remote-cert-tls server\nverify-x509-name $SERVER_NAME name\"\n\t\techo \"auth $HMAC_ALG\nauth-nocache\ncipher $CIPHER\nignore-unknown-option data-ciphers\ndata-ciphers $CIPHER\nncp-ciphers $CIPHER\ntls-client\ntls-version-min $TLS_VERSION_MIN\ntls-cipher $CC_CIPHER\ntls-ciphersuites $TLS13_CIPHERSUITES\nignore-unknown-option block-outside-dns\nsetenv opt block-outside-dns # Prevent Windows 10 DNS leak\nverb 3\"\n\t} >>/etc/openvpn/server/client-template.txt\n\n\tif [[ -n $MTU ]]; then\n\t\techo \"tun-mtu $MTU\" >>/etc/openvpn/server/client-template.txt\n\tfi\n\n\t# Generate the custom client.ovpn\n\tif [[ $NEW_CLIENT == \"n\" ]]; then\n\t\tif [[ $AUTH_MODE == \"fingerprint\" ]]; then\n\t\t\tlog_info \"No clients added. OpenVPN will not start until you add at least one client.\"\n\t\telse\n\t\t\tlog_info \"No clients added. To add clients, simply run the script again.\"\n\t\tfi\n\telse\n\t\tlog_info \"Generating first client certificate...\"\n\t\tnewClient\n\t\t# In fingerprint mode, start service now that we have at least one fingerprint\n\t\tif [[ $AUTH_MODE == \"fingerprint\" ]]; then\n\t\t\trun_cmd \"Starting OpenVPN service\" systemctl restart openvpn-server@server\n\t\tfi\n\t\tlog_success \"If you want to add more clients, you simply need to run this script another time!\"\n\tfi\n}\n\n# Helper function to get the home directory for storing client configs\nfunction getHomeDir() {\n\tlocal client=\"$1\"\n\tif [ -d \"/home/${client}\" ]; then\n\t\techo \"/home/${client}\"\n\telif [ \"${SUDO_USER}\" ]; then\n\t\tif [ \"${SUDO_USER}\" == \"root\" ]; then\n\t\t\techo \"/root\"\n\t\telse\n\t\t\techo \"/home/${SUDO_USER}\"\n\t\tfi\n\telse\n\t\techo \"/root\"\n\tfi\n}\n\n# Helper function to get the owner of a client config file (if client matches a system user)\nfunction getClientOwner() {\n\tlocal client=\"$1\"\n\t# Check if client name corresponds to an existing system user with a home directory\n\tif id \"$client\" &>/dev/null && [ -d \"/home/${client}\" ]; then\n\t\techo \"${client}\"\n\telif [ \"${SUDO_USER}\" ] && [ \"${SUDO_USER}\" != \"root\" ]; then\n\t\techo \"${SUDO_USER}\"\n\tfi\n}\n\n# Helper function to set proper ownership and permissions on client config file\nfunction setClientConfigPermissions() {\n\tlocal filepath=\"$1\"\n\tlocal owner=\"$2\"\n\n\tif [[ -n \"$owner\" ]]; then\n\t\tlocal owner_group\n\t\towner_group=$(id -gn \"$owner\")\n\t\tchmod go-rw \"$filepath\"\n\t\tchown \"$owner:$owner_group\" \"$filepath\"\n\tfi\n}\n\n# Helper function to write client config file with proper path and permissions\n# Usage: writeClientConfig <client_name>\n# Uses CLIENT_FILEPATH env var if set, otherwise defaults to home directory\n# Side effects: sets GENERATED_CONFIG_PATH global variable with the final path\nfunction writeClientConfig() {\n\tlocal client=\"$1\"\n\tlocal clientFilePath\n\n\t# Determine output file path\n\tif [[ -n \"$CLIENT_FILEPATH\" ]]; then\n\t\tclientFilePath=\"$CLIENT_FILEPATH\"\n\t\t# Ensure parent directory exists for custom paths\n\t\tlocal parentDir\n\t\tparentDir=$(dirname \"$clientFilePath\")\n\t\tif [[ ! -d \"$parentDir\" ]]; then\n\t\t\trun_cmd_fatal \"Creating directory $parentDir\" mkdir -p \"$parentDir\"\n\t\tfi\n\telse\n\t\tlocal homeDir\n\t\thomeDir=$(getHomeDir \"$client\")\n\t\tclientFilePath=\"$homeDir/$client.ovpn\"\n\tfi\n\n\t# Generate the .ovpn config file\n\tgenerateClientConfig \"$client\" \"$clientFilePath\"\n\n\t# Set proper ownership and permissions if client matches a system user\n\tlocal clientOwner\n\tclientOwner=$(getClientOwner \"$client\")\n\tsetClientConfigPermissions \"$clientFilePath\" \"$clientOwner\"\n\n\t# Export path for caller to use\n\tGENERATED_CONFIG_PATH=\"$clientFilePath\"\n}\n\n# Helper function to regenerate the CRL after certificate changes\nfunction regenerateCRL() {\n\texport EASYRSA_CRL_DAYS=$DEFAULT_CRL_VALIDITY_DURATION_DAYS\n\trun_cmd_fatal \"Regenerating CRL\" ./easyrsa gen-crl\n\trun_cmd \"Removing old CRL\" rm -f /etc/openvpn/server/crl.pem\n\trun_cmd_fatal \"Copying new CRL\" cp /etc/openvpn/server/easy-rsa/pki/crl.pem /etc/openvpn/server/crl.pem\n\trun_cmd \"Setting CRL permissions\" chmod 644 /etc/openvpn/server/crl.pem\n}\n\n# Helper function to generate .ovpn client config file\n# Usage: generateClientConfig <client_name> <filepath>\nfunction generateClientConfig() {\n\tlocal client=\"$1\"\n\tlocal filepath=\"$2\"\n\n\t# Read auth mode\n\tlocal auth_mode=\"pki\"\n\tif [[ -f /etc/openvpn/server/easy-rsa/AUTH_MODE_GENERATED ]]; then\n\t\tauth_mode=$(cat /etc/openvpn/server/easy-rsa/AUTH_MODE_GENERATED)\n\tfi\n\n\t# Determine if we use tls-crypt-v2, tls-crypt, or tls-auth\n\tlocal tls_sig=\"\"\n\tif grep -qs \"^tls-crypt-v2\" /etc/openvpn/server/server.conf; then\n\t\ttls_sig=\"1\"\n\telif grep -qs \"^tls-crypt\" /etc/openvpn/server/server.conf; then\n\t\ttls_sig=\"2\"\n\telif grep -qs \"^tls-auth\" /etc/openvpn/server/server.conf; then\n\t\ttls_sig=\"3\"\n\tfi\n\n\t# Generate the custom client.ovpn\n\trun_cmd \"Creating client config\" cp /etc/openvpn/server/client-template.txt \"$filepath\"\n\t{\n\t\tif [[ $auth_mode == \"pki\" ]]; then\n\t\t\t# PKI mode: include CA certificate\n\t\t\techo \"<ca>\"\n\t\t\tcat \"/etc/openvpn/server/easy-rsa/pki/ca.crt\"\n\t\t\techo \"</ca>\"\n\t\telse\n\t\t\t# Fingerprint mode: use server fingerprint instead of CA\n\t\t\tlocal server_fingerprint\n\t\t\tif [[ ! -f /etc/openvpn/server/server-fingerprint ]]; then\n\t\t\t\tlog_error \"Server fingerprint file not found\"\n\t\t\t\texit 1\n\t\t\tfi\n\t\t\tserver_fingerprint=$(cat /etc/openvpn/server/server-fingerprint)\n\t\t\tif [[ -z $server_fingerprint ]]; then\n\t\t\t\tlog_error \"Server fingerprint is empty\"\n\t\t\t\texit 1\n\t\t\tfi\n\t\t\techo \"peer-fingerprint $server_fingerprint\"\n\t\tfi\n\n\t\techo \"<cert>\"\n\t\tawk '/BEGIN/,/END CERTIFICATE/' \"/etc/openvpn/server/easy-rsa/pki/issued/$client.crt\"\n\t\techo \"</cert>\"\n\n\t\techo \"<key>\"\n\t\tcat \"/etc/openvpn/server/easy-rsa/pki/private/$client.key\"\n\t\techo \"</key>\"\n\n\t\tcase $tls_sig in\n\t\t1)\n\t\t\t# Generate per-client tls-crypt-v2 key in /etc/openvpn/server/\n\t\t\t# Using /tmp would fail on Ubuntu 25.04+ due to AppArmor restrictions\n\t\t\ttls_crypt_v2_tmpfile=$(mktemp /etc/openvpn/server/tls-crypt-v2-client.XXXXXX)\n\t\t\tif [[ -z \"$tls_crypt_v2_tmpfile\" ]] || [[ ! -f \"$tls_crypt_v2_tmpfile\" ]]; then\n\t\t\t\tlog_error \"Failed to create temporary file for tls-crypt-v2 client key\"\n\t\t\t\texit 1\n\t\t\tfi\n\t\t\tif ! openvpn --tls-crypt-v2 /etc/openvpn/server/tls-crypt-v2.key \\\n\t\t\t\t--genkey tls-crypt-v2-client \"$tls_crypt_v2_tmpfile\"; then\n\t\t\t\trm -f \"$tls_crypt_v2_tmpfile\"\n\t\t\t\tlog_error \"Failed to generate tls-crypt-v2 client key\"\n\t\t\t\texit 1\n\t\t\tfi\n\t\t\techo \"<tls-crypt-v2>\"\n\t\t\tcat \"$tls_crypt_v2_tmpfile\"\n\t\t\techo \"</tls-crypt-v2>\"\n\t\t\trm -f \"$tls_crypt_v2_tmpfile\"\n\t\t\t;;\n\t\t2)\n\t\t\techo \"<tls-crypt>\"\n\t\t\tcat /etc/openvpn/server/tls-crypt.key\n\t\t\techo \"</tls-crypt>\"\n\t\t\t;;\n\t\t3)\n\t\t\techo \"key-direction 1\"\n\t\t\techo \"<tls-auth>\"\n\t\t\tcat /etc/openvpn/server/tls-auth.key\n\t\t\techo \"</tls-auth>\"\n\t\t\t;;\n\t\tesac\n\t} >>\"$filepath\"\n}\n\n# Helper function to get the current auth mode\n# Returns: \"pki\" or \"fingerprint\"\nfunction getAuthMode() {\n\tif [[ -f /etc/openvpn/server/easy-rsa/AUTH_MODE_GENERATED ]]; then\n\t\tcat /etc/openvpn/server/easy-rsa/AUTH_MODE_GENERATED\n\telse\n\t\techo \"pki\"\n\tfi\n}\n\n# Helper function to get valid client names from server.conf fingerprint block\n# In fingerprint mode, clients are tracked via comments in the <peer-fingerprint> block\n# Format in server.conf:\n#   <peer-fingerprint>\n#   # client_name\n#   SHA256:fingerprint\n#   </peer-fingerprint>\n# Returns: newline-separated list of client names\nfunction getClientsFromFingerprints() {\n\tlocal server_conf=\"/etc/openvpn/server/server.conf\"\n\tif [[ ! -f \"$server_conf\" ]]; then\n\t\treturn\n\tfi\n\t# Extract client names from comments in peer-fingerprint block\n\t# Comments are in format \"# client_name\" on lines before fingerprints\n\tsed -n '/<peer-fingerprint>/,/<\\/peer-fingerprint>/p' \"$server_conf\" | grep \"^# \" | sed 's/^# //'\n}\n\n# Helper function to check if a client exists in fingerprint mode\n# Arguments: client_name\n# Returns: 0 if exists, 1 if not\nfunction clientExistsInFingerprints() {\n\tlocal client_name=\"$1\"\n\tgetClientsFromFingerprints | grep -qx \"$client_name\"\n}\n\n# Helper function to get certificate expiry info\n# Arguments: cert_file_path\n# Outputs: expiry_date|days_remaining (pipe-separated)\nfunction getCertExpiry() {\n\tlocal cert_file=\"$1\"\n\tlocal expiry_date=\"unknown\"\n\tlocal days_remaining=\"null\"\n\n\tif [[ -f \"$cert_file\" ]]; then\n\t\tlocal enddate\n\t\tenddate=$(openssl x509 -enddate -noout -in \"$cert_file\" 2>/dev/null | cut -d= -f2)\n\t\tif [[ -n \"$enddate\" ]]; then\n\t\t\tlocal expiry_epoch\n\t\t\texpiry_epoch=$(date -d \"$enddate\" +%s 2>/dev/null || date -j -f \"%b %d %H:%M:%S %Y %Z\" \"$enddate\" +%s 2>/dev/null)\n\t\t\tif [[ -n \"$expiry_epoch\" ]]; then\n\t\t\t\texpiry_date=$(date -d \"@$expiry_epoch\" +%Y-%m-%d 2>/dev/null || date -r \"$expiry_epoch\" +%Y-%m-%d 2>/dev/null)\n\t\t\t\tlocal now_epoch\n\t\t\t\tnow_epoch=$(date +%s)\n\t\t\t\tdays_remaining=$(((expiry_epoch - now_epoch) / 86400))\n\t\t\tfi\n\t\tfi\n\tfi\n\techo \"$expiry_date|$days_remaining\"\n}\n\n# Helper function to remove certificate files for regeneration\n# Arguments: name (client or server name)\n# Must be called from easy-rsa directory\nfunction removeCertFiles() {\n\tlocal name=\"$1\"\n\trm -f \"pki/issued/$name.crt\" \"pki/private/$name.key\" \"pki/reqs/$name.req\"\n}\n\n# Helper function to extract SHA256 fingerprint from certificate\n# Arguments: cert_file_path\n# Outputs: fingerprint string or empty on failure\nfunction extractFingerprint() {\n\tlocal cert_file=\"$1\"\n\topenssl x509 -in \"$cert_file\" -fingerprint -sha256 -noout 2>/dev/null | cut -d'=' -f2\n}\n\n# Helper function to list valid clients and select one\n# Arguments: show_expiry (optional, \"true\" to show expiry info)\n# Sets global variables:\n#   CLIENT - the selected client name\n#   CLIENTNUMBER - the selected client number (1-based index)\n#   NUMBEROFCLIENTS - total count of valid clients\nfunction selectClient() {\n\tlocal show_expiry=\"${1:-false}\"\n\tlocal client_number\n\tlocal auth_mode\n\tlocal clients_list\n\n\tauth_mode=$(getAuthMode)\n\n\t# Get list of valid clients based on auth mode\n\tif [[ $auth_mode == \"fingerprint\" ]]; then\n\t\t# Fingerprint mode: get clients from server.conf peer-fingerprint block\n\t\tclients_list=$(getClientsFromFingerprints)\n\t\tNUMBEROFCLIENTS=$(echo \"$clients_list\" | grep -c . || echo 0)\n\telse\n\t\t# PKI mode: get valid clients from index.txt\n\t\tclients_list=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt 2>/dev/null | grep \"^V\" | cut -d '=' -f 2)\n\t\tNUMBEROFCLIENTS=$(echo \"$clients_list\" | grep -c . || echo 0)\n\tfi\n\n\tif [[ $NUMBEROFCLIENTS == '0' ]]; then\n\t\tlog_fatal \"You have no existing clients!\"\n\tfi\n\n\t# If CLIENT is set, validate it exists as a valid client\n\tif [[ -n $CLIENT ]]; then\n\t\tif echo \"$clients_list\" | grep -qx \"$CLIENT\"; then\n\t\t\treturn\n\t\telse\n\t\t\tlog_fatal \"Client '$CLIENT' not found or not valid\"\n\t\tfi\n\tfi\n\n\t# Display client list\n\tif [[ $show_expiry == \"true\" ]]; then\n\t\tlocal i=1\n\t\twhile read -r client; do\n\t\t\tlocal client_cert=\"/etc/openvpn/server/easy-rsa/pki/issued/$client.crt\"\n\t\t\tlocal days\n\t\t\tdays=$(getDaysUntilExpiry \"$client_cert\")\n\t\t\tlocal expiry\n\t\t\texpiry=$(formatExpiry \"$days\")\n\t\t\techo \"     $i) $client $expiry\"\n\t\t\t((i++))\n\t\tdone <<<\"$clients_list\"\n\telse\n\t\techo \"$clients_list\" | nl -s ') '\n\tfi\n\n\t# Prompt for selection\n\tuntil [[ ${CLIENTNUMBER:-$client_number} -ge 1 && ${CLIENTNUMBER:-$client_number} -le $NUMBEROFCLIENTS ]]; do\n\t\tif [[ $NUMBEROFCLIENTS == '1' ]]; then\n\t\t\tread -rp \"Select one client [1]: \" client_number\n\t\telse\n\t\t\tread -rp \"Select one client [1-$NUMBEROFCLIENTS]: \" client_number\n\t\tfi\n\tdone\n\tCLIENTNUMBER=\"${CLIENTNUMBER:-$client_number}\"\n\tCLIENT=$(echo \"$clients_list\" | sed -n \"${CLIENTNUMBER}p\")\n}\n\n# Escape a string for JSON output\nfunction json_escape() {\n\tlocal str=\"$1\"\n\t# Escape backslashes first, then quotes, then control characters\n\tstr=\"${str//\\\\/\\\\\\\\}\"\n\tstr=\"${str//\\\"/\\\\\\\"}\"\n\tstr=\"${str//$'\\n'/\\\\n}\"\n\tstr=\"${str//$'\\r'/\\\\r}\"\n\tstr=\"${str//$'\\t'/\\\\t}\"\n\tprintf '%s' \"$str\"\n}\n\nfunction listClients() {\n\tlocal index_file=\"/etc/openvpn/server/easy-rsa/pki/index.txt\"\n\tlocal cert_dir=\"/etc/openvpn/server/easy-rsa/pki/issued\"\n\tlocal number_of_clients\n\tlocal format=\"${OUTPUT_FORMAT:-table}\"\n\tlocal auth_mode\n\n\tauth_mode=$(getAuthMode)\n\n\t# Collect client data based on auth mode\n\tlocal clients_data=()\n\n\tif [[ $auth_mode == \"fingerprint\" ]]; then\n\t\t# Fingerprint mode: get clients from certificates in pki/issued/\n\t\t# Valid clients have their fingerprint in server.conf, revoked ones don't\n\t\tlocal valid_clients\n\t\tvalid_clients=$(getClientsFromFingerprints)\n\n\t\t# Get all client certificates (exclude server certs)\n\t\tlocal all_clients=()\n\t\tfor cert_file in \"$cert_dir\"/*.crt; do\n\t\t\t[[ ! -f \"$cert_file\" ]] && continue\n\t\t\tlocal client_name\n\t\t\tclient_name=$(basename \"$cert_file\" .crt)\n\t\t\t# Skip server certificates and backup files\n\t\t\t[[ \"$client_name\" == server_* ]] && continue\n\t\t\t[[ \"$client_name\" == *.bak ]] && continue\n\t\t\tall_clients+=(\"$client_name\")\n\t\tdone\n\n\t\tnumber_of_clients=${#all_clients[@]}\n\n\t\tif [[ $number_of_clients == '0' ]]; then\n\t\t\tif [[ $format == \"json\" ]]; then\n\t\t\t\techo '{\"clients\":[]}'\n\t\t\telse\n\t\t\t\tlog_warn \"You have no existing client certificates!\"\n\t\t\tfi\n\t\t\treturn\n\t\tfi\n\n\t\tfor client_name in \"${all_clients[@]}\"; do\n\t\t\t[[ -z \"$client_name\" ]] && continue\n\t\t\tlocal status_text\n\t\t\t# Check if client is in the valid fingerprints list\n\t\t\tif echo \"$valid_clients\" | grep -qx \"$client_name\"; then\n\t\t\t\tstatus_text=\"valid\"\n\t\t\telse\n\t\t\t\tstatus_text=\"revoked\"\n\t\t\tfi\n\t\t\tlocal expiry_info\n\t\t\texpiry_info=$(getCertExpiry \"$cert_dir/$client_name.crt\")\n\t\t\tclients_data+=(\"$client_name|$status_text|$expiry_info\")\n\t\tdone\n\telse\n\t\t# PKI mode: get clients from index.txt\n\t\t# Exclude server certificates (CN starting with server_)\n\t\tnumber_of_clients=$(tail -n +2 \"$index_file\" 2>/dev/null | grep \"^[VR]\" | grep -cv \"/CN=server_\" || echo 0)\n\n\t\tif [[ $number_of_clients == '0' ]]; then\n\t\t\tif [[ $format == \"json\" ]]; then\n\t\t\t\techo '{\"clients\":[]}'\n\t\t\telse\n\t\t\t\tlog_warn \"You have no existing client certificates!\"\n\t\t\tfi\n\t\t\treturn\n\t\tfi\n\n\t\twhile read -r line; do\n\t\t\tlocal status=\"${line:0:1}\"\n\t\t\tlocal client_name\n\t\t\tclient_name=$(echo \"$line\" | sed 's/.*\\/CN=//')\n\n\t\t\tlocal status_text\n\t\t\tif [[ \"$status\" == \"V\" ]]; then\n\t\t\t\tstatus_text=\"valid\"\n\t\t\telif [[ \"$status\" == \"R\" ]]; then\n\t\t\t\tstatus_text=\"revoked\"\n\t\t\telse\n\t\t\t\tstatus_text=\"unknown\"\n\t\t\tfi\n\n\t\t\tlocal expiry_info\n\t\t\texpiry_info=$(getCertExpiry \"$cert_dir/$client_name.crt\")\n\t\t\tclients_data+=(\"$client_name|$status_text|$expiry_info\")\n\t\tdone < <(tail -n +2 \"$index_file\" | grep \"^[VR]\" | grep -v \"/CN=server_\" | sort -t$'\\t' -k2)\n\tfi\n\n\tif [[ $format == \"json\" ]]; then\n\t\t# Output JSON\n\t\techo '{\"clients\":['\n\t\tlocal first=true\n\t\tfor client_entry in \"${clients_data[@]}\"; do\n\t\t\tIFS='|' read -r name status expiry days <<<\"$client_entry\"\n\t\t\t[[ $first == true ]] && first=false || printf ','\n\t\t\t# Handle null for days_remaining (no quotes for JSON null)\n\t\t\tlocal days_json\n\t\t\tif [[ \"$days\" == \"null\" || -z \"$days\" ]]; then\n\t\t\t\tdays_json=\"null\"\n\t\t\telse\n\t\t\t\tdays_json=\"$days\"\n\t\t\tfi\n\t\t\tprintf '{\"name\":\"%s\",\"status\":\"%s\",\"expiry\":\"%s\",\"days_remaining\":%s}\\n' \\\n\t\t\t\t\"$(json_escape \"$name\")\" \"$(json_escape \"$status\")\" \"$(json_escape \"$expiry\")\" \"$days_json\"\n\t\tdone\n\t\techo ']}'\n\telse\n\t\t# Output table\n\t\tlog_header \"Client Certificates\"\n\t\tlog_info \"Found $number_of_clients client certificate(s)\"\n\t\tlog_menu \"\"\n\t\tprintf \"   %-25s %-10s %-12s %s\\n\" \"Name\" \"Status\" \"Expiry\" \"Remaining\"\n\t\tprintf \"   %-25s %-10s %-12s %s\\n\" \"----\" \"------\" \"------\" \"---------\"\n\n\t\tfor client_entry in \"${clients_data[@]}\"; do\n\t\t\tIFS='|' read -r name status expiry days <<<\"$client_entry\"\n\t\t\tlocal relative\n\t\t\tif [[ $days == \"null\" ]]; then\n\t\t\t\trelative=\"unknown\"\n\t\t\telif [[ $days -lt 0 ]]; then\n\t\t\t\trelative=\"$((-days)) days ago\"\n\t\t\telif [[ $days -eq 0 ]]; then\n\t\t\t\trelative=\"today\"\n\t\t\telif [[ $days -eq 1 ]]; then\n\t\t\t\trelative=\"1 day\"\n\t\t\telse\n\t\t\t\trelative=\"$days days\"\n\t\t\tfi\n\t\t\t# Capitalize status for table display\n\t\t\tlocal status_display=\"${status^}\"\n\t\t\tprintf \"   %-25s %-10s %-12s %s\\n\" \"$name\" \"$status_display\" \"$expiry\" \"$relative\"\n\t\tdone\n\t\tlog_menu \"\"\n\tfi\n}\n\nfunction formatBytes() {\n\tlocal bytes=$1\n\t# Validate input is numeric\n\tif ! [[ \"$bytes\" =~ ^[0-9]+$ ]]; then\n\t\techo \"N/A\"\n\t\treturn\n\tfi\n\tif [[ $bytes -ge 1073741824 ]]; then\n\t\tawk \"BEGIN {printf \\\"%.1fG\\\", $bytes/1073741824}\"\n\telif [[ $bytes -ge 1048576 ]]; then\n\t\tawk \"BEGIN {printf \\\"%.1fM\\\", $bytes/1048576}\"\n\telif [[ $bytes -ge 1024 ]]; then\n\t\tawk \"BEGIN {printf \\\"%.1fK\\\", $bytes/1024}\"\n\telse\n\t\techo \"${bytes}B\"\n\tfi\n}\n\nfunction listConnectedClients() {\n\tlocal status_file=\"/var/log/openvpn/status.log\"\n\tlocal format=\"${OUTPUT_FORMAT:-table}\"\n\n\tif [[ ! -f \"$status_file\" ]]; then\n\t\tif [[ $format == \"json\" ]]; then\n\t\t\techo '{\"error\":\"Status file not found\",\"clients\":[]}'\n\t\telse\n\t\t\tlog_warn \"Status file not found: $status_file\"\n\t\t\tlog_info \"Make sure OpenVPN is running.\"\n\t\tfi\n\t\treturn\n\tfi\n\n\tlocal client_count\n\tclient_count=$(grep -c \"^CLIENT_LIST\" \"$status_file\" 2>/dev/null) || client_count=0\n\n\tif [[ \"$client_count\" -eq 0 ]]; then\n\t\tif [[ $format == \"json\" ]]; then\n\t\t\techo '{\"clients\":[]}'\n\t\telse\n\t\t\tlog_header \"Connected Clients\"\n\t\t\tlog_info \"No clients currently connected.\"\n\t\t\tlog_info \"Note: Data refreshes every 60 seconds.\"\n\t\tfi\n\t\treturn\n\tfi\n\n\t# Collect client data\n\tlocal clients_data=()\n\twhile IFS=',' read -r _ name real_addr vpn_ip _ bytes_recv bytes_sent connected_since _; do\n\t\tclients_data+=(\"$name|$real_addr|$vpn_ip|$bytes_recv|$bytes_sent|$connected_since\")\n\tdone < <(grep \"^CLIENT_LIST\" \"$status_file\")\n\n\tif [[ $format == \"json\" ]]; then\n\t\techo '{\"clients\":['\n\t\tlocal first=true\n\t\tfor client_entry in \"${clients_data[@]}\"; do\n\t\t\tIFS='|' read -r name real_addr vpn_ip bytes_recv bytes_sent connected_since <<<\"$client_entry\"\n\t\t\t[[ $first == true ]] && first=false || printf ','\n\t\t\tprintf '{\"name\":\"%s\",\"real_address\":\"%s\",\"vpn_ip\":\"%s\",\"bytes_received\":%s,\"bytes_sent\":%s,\"connected_since\":\"%s\"}\\n' \\\n\t\t\t\t\"$(json_escape \"$name\")\" \"$(json_escape \"$real_addr\")\" \"$(json_escape \"$vpn_ip\")\" \\\n\t\t\t\t\"${bytes_recv:-0}\" \"${bytes_sent:-0}\" \"$(json_escape \"$connected_since\")\"\n\t\tdone\n\t\techo ']}'\n\telse\n\t\tlog_header \"Connected Clients\"\n\t\tlog_info \"Found $client_count connected client(s)\"\n\t\tlog_menu \"\"\n\t\tprintf \"   %-20s %-22s %-16s %-20s %s\\n\" \"Name\" \"Real Address\" \"VPN IP\" \"Connected Since\" \"Transfer\"\n\t\tprintf \"   %-20s %-22s %-16s %-20s %s\\n\" \"----\" \"------------\" \"------\" \"---------------\" \"--------\"\n\n\t\tfor client_entry in \"${clients_data[@]}\"; do\n\t\t\tIFS='|' read -r name real_addr vpn_ip bytes_recv bytes_sent connected_since <<<\"$client_entry\"\n\t\t\tlocal recv_human sent_human\n\t\t\trecv_human=$(formatBytes \"$bytes_recv\")\n\t\t\tsent_human=$(formatBytes \"$bytes_sent\")\n\t\t\tlocal transfer=\"↓${recv_human} ↑${sent_human}\"\n\t\t\tprintf \"   %-20s %-22s %-16s %-20s %s\\n\" \"$name\" \"$real_addr\" \"$vpn_ip\" \"$connected_since\" \"$transfer\"\n\t\tdone\n\t\tlog_menu \"\"\n\t\tlog_info \"Note: Data refreshes every 60 seconds.\"\n\tfi\n}\n\nfunction newClient() {\n\tlog_header \"New Client Setup\"\n\n\t# Only prompt for client name if not already set or invalid\n\tif ! is_valid_client_name \"$CLIENT\"; then\n\t\tlog_prompt \"Tell me a name for the client.\"\n\t\tlog_prompt \"The name must consist of alphanumeric characters, underscores, or dashes (max $MAX_CLIENT_NAME_LENGTH characters).\"\n\t\tuntil is_valid_client_name \"$CLIENT\"; do\n\t\t\tread -rp \"Client name: \" -e CLIENT\n\t\tdone\n\tfi\n\n\t# Only prompt for cert duration if not already set\n\tif [[ -z $CLIENT_CERT_DURATION_DAYS ]] || ! [[ $CLIENT_CERT_DURATION_DAYS =~ ^[0-9]+$ ]] || [[ $CLIENT_CERT_DURATION_DAYS -lt 1 ]]; then\n\t\tlog_menu \"\"\n\t\tlog_prompt \"How many days should the client certificate be valid for?\"\n\t\tuntil [[ $CLIENT_CERT_DURATION_DAYS =~ ^[0-9]+$ ]] && [[ $CLIENT_CERT_DURATION_DAYS -ge 1 ]]; do\n\t\t\tread -rp \"Certificate validity (days): \" -e -i $DEFAULT_CERT_VALIDITY_DURATION_DAYS CLIENT_CERT_DURATION_DAYS\n\t\tdone\n\tfi\n\n\t# Only prompt for password if not already set\n\tif ! [[ $PASS =~ ^[1-2]$ ]]; then\n\t\tlog_menu \"\"\n\t\tlog_prompt \"Do you want to protect the configuration file with a password?\"\n\t\tlog_prompt \"(e.g. encrypt the private key with a password)\"\n\t\tlog_menu \"   1) Add a passwordless client\"\n\t\tlog_menu \"   2) Use a password for the client\"\n\t\tuntil [[ $PASS =~ ^[1-2]$ ]]; do\n\t\t\tread -rp \"Select an option [1-2]: \" -e -i 1 PASS\n\t\tdone\n\tfi\n\n\tcd /etc/openvpn/server/easy-rsa/ || return\n\n\t# Read auth mode\n\tif [[ -f AUTH_MODE_GENERATED ]]; then\n\t\tAUTH_MODE=$(cat AUTH_MODE_GENERATED)\n\telse\n\t\tAUTH_MODE=\"pki\"\n\tfi\n\n\t# Check if client already exists\n\tlocal CLIENTEXISTS=0\n\tif [[ $AUTH_MODE == \"fingerprint\" ]]; then\n\t\t# Fingerprint mode: check server.conf peer-fingerprint block\n\t\tif clientExistsInFingerprints \"$CLIENT\"; then\n\t\t\tCLIENTEXISTS=1\n\t\tfi\n\telse\n\t\t# PKI mode: check index.txt\n\t\tif [[ -f pki/index.txt ]]; then\n\t\t\tCLIENTEXISTS=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep -E \"^V\" | grep -c -E \"/CN=$CLIENT\\$\")\n\t\tfi\n\tfi\n\n\tif [[ $CLIENTEXISTS != '0' ]]; then\n\t\tlog_error \"The specified client CN was already found, please choose another name.\"\n\t\texit 1\n\tfi\n\n\t# In fingerprint mode, clean up any revoked cert files so we can reuse the name\n\tif [[ $AUTH_MODE == \"fingerprint\" ]] && [[ -f \"pki/issued/$CLIENT.crt\" ]]; then\n\t\tlog_info \"Removing old revoked certificate files for $CLIENT...\"\n\t\tremoveCertFiles \"$CLIENT\"\n\tfi\n\n\tlog_info \"Generating client certificate...\"\n\texport EASYRSA_CERT_EXPIRE=$CLIENT_CERT_DURATION_DAYS\n\n\t# Determine easyrsa command based on auth mode\n\tlocal easyrsa_cmd cert_desc\n\tif [[ $AUTH_MODE == \"pki\" ]]; then\n\t\teasyrsa_cmd=\"build-client-full\"\n\t\tcert_desc=\"client certificate\"\n\telse\n\t\teasyrsa_cmd=\"self-sign-client\"\n\t\tcert_desc=\"self-signed client certificate\"\n\tfi\n\n\tcase $PASS in\n\t1)\n\t\trun_cmd_fatal \"Building $cert_desc\" ./easyrsa --batch \"$easyrsa_cmd\" \"$CLIENT\" nopass\n\t\t;;\n\t2)\n\t\tif [[ -z \"$PASSPHRASE\" ]]; then\n\t\t\tlog_warn \"You will be asked for the client password below\"\n\t\t\tif ! ./easyrsa --batch \"$easyrsa_cmd\" \"$CLIENT\"; then\n\t\t\t\tlog_fatal \"Building $cert_desc failed\"\n\t\t\tfi\n\t\telse\n\t\t\tlog_info \"Using provided passphrase for client certificate\"\n\t\t\texport EASYRSA_PASSPHRASE=\"$PASSPHRASE\"\n\t\t\trun_cmd_fatal \"Building $cert_desc\" ./easyrsa --batch --passin=env:EASYRSA_PASSPHRASE --passout=env:EASYRSA_PASSPHRASE \"$easyrsa_cmd\" \"$CLIENT\"\n\t\t\tunset EASYRSA_PASSPHRASE\n\t\tfi\n\t\t;;\n\tesac\n\n\t# Fingerprint mode: register client fingerprint with server\n\tif [[ $AUTH_MODE == \"fingerprint\" ]]; then\n\t\tCLIENT_FINGERPRINT=$(openssl x509 -in \"pki/issued/$CLIENT.crt\" -fingerprint -sha256 -noout | cut -d'=' -f2)\n\t\tif [[ -z $CLIENT_FINGERPRINT ]]; then\n\t\t\tlog_error \"Failed to extract client certificate fingerprint\"\n\t\t\texit 1\n\t\tfi\n\t\tlog_info \"Client fingerprint: $CLIENT_FINGERPRINT\"\n\n\t\t# Add fingerprint to server.conf's <peer-fingerprint> block\n\t\t# Create the block if this is the first client\n\t\tif ! grep -q '<peer-fingerprint>' /etc/openvpn/server/server.conf; then\n\t\t\techo \"# Client fingerprints are listed below\n<peer-fingerprint>\n# $CLIENT\n$CLIENT_FINGERPRINT\n</peer-fingerprint>\" >>/etc/openvpn/server/server.conf\n\t\telse\n\t\t\t# Insert comment and fingerprint before closing tag\n\t\t\tsed -i \"/<\\/peer-fingerprint>/i # $CLIENT\\n$CLIENT_FINGERPRINT\" /etc/openvpn/server/server.conf\n\t\tfi\n\n\t\t# Reload OpenVPN to pick up new fingerprint\n\t\tlog_info \"Reloading OpenVPN to apply new fingerprint...\"\n\t\tif systemctl is-active --quiet openvpn-server@server; then\n\t\t\tsystemctl reload openvpn-server@server 2>/dev/null || systemctl restart openvpn-server@server\n\t\tfi\n\tfi\n\n\tlog_success \"Client $CLIENT added and is valid for $CLIENT_CERT_DURATION_DAYS days.\"\n\n\t# Write the .ovpn config file with proper path and permissions\n\twriteClientConfig \"$CLIENT\"\n\n\tlog_menu \"\"\n\tlog_success \"The configuration file has been written to $GENERATED_CONFIG_PATH.\"\n\tlog_info \"Download the .ovpn file and import it in your OpenVPN client.\"\n}\n\nfunction revokeClient() {\n\tlog_header \"Revoke Client\"\n\tlog_prompt \"Select the existing client certificate you want to revoke\"\n\tselectClient\n\n\tcd /etc/openvpn/server/easy-rsa/ || return\n\n\t# Read auth mode\n\tlocal auth_mode=\"pki\"\n\tif [[ -f AUTH_MODE_GENERATED ]]; then\n\t\tauth_mode=$(cat AUTH_MODE_GENERATED)\n\tfi\n\n\tlog_info \"Revoking certificate for $CLIENT...\"\n\n\tif [[ $auth_mode == \"pki\" ]]; then\n\t\t# PKI mode: use Easy-RSA revocation and CRL\n\t\trun_cmd_fatal \"Revoking certificate\" ./easyrsa --batch revoke-issued \"$CLIENT\"\n\t\tregenerateCRL\n\t\trun_cmd \"Backing up index\" cp /etc/openvpn/server/easy-rsa/pki/index.txt{,.bk}\n\telse\n\t\t# Fingerprint mode: remove fingerprint from server.conf\n\t\t# Keep cert files so revoked clients appear in client list\n\t\tlog_info \"Removing client fingerprint from server configuration...\"\n\n\t\t# Remove comment line and fingerprint line below it from server.conf\n\t\tsed -i \"/^# $CLIENT\\$/{N;d;}\" /etc/openvpn/server/server.conf\n\n\t\t# Reload OpenVPN to apply fingerprint removal\n\t\tlog_info \"Reloading OpenVPN to apply fingerprint removal...\"\n\t\tif systemctl is-active --quiet openvpn-server@server; then\n\t\t\tsystemctl reload openvpn-server@server 2>/dev/null || systemctl restart openvpn-server@server\n\t\tfi\n\tfi\n\n\trun_cmd \"Removing client config from /home\" find /home/ -maxdepth 2 -name \"$CLIENT.ovpn\" -delete\n\trun_cmd \"Removing client config from /root\" rm -f \"/root/$CLIENT.ovpn\"\n\trun_cmd \"Removing IP assignment\" sed -i \"/^$CLIENT,.*/d\" /etc/openvpn/server/ipp.txt\n\n\t# Disconnect the client if currently connected\n\tdisconnectClient \"$CLIENT\"\n\n\tlog_success \"Certificate for client $CLIENT revoked.\"\n}\n\n# Disconnect a client via the management interface\nfunction disconnectClient() {\n\tlocal client_name=\"$1\"\n\tlocal mgmt_socket=\"/var/run/openvpn-server/server.sock\"\n\n\tif [[ ! -S \"$mgmt_socket\" ]]; then\n\t\tlog_warn \"Management socket not found. Client may still be connected until they reconnect.\"\n\t\treturn 0\n\tfi\n\n\tlog_info \"Disconnecting client $client_name...\"\n\tif echo \"kill $client_name\" | socat - UNIX-CONNECT:\"$mgmt_socket\" >/dev/null 2>&1; then\n\t\tlog_success \"Client $client_name disconnected.\"\n\telse\n\t\tlog_warn \"Could not disconnect client (they may not be connected).\"\n\tfi\n}\n\nfunction renewClient() {\n\tlocal client_cert_duration_days\n\tlocal auth_mode\n\n\tlog_header \"Renew Client Certificate\"\n\tlog_prompt \"Select the existing client certificate you want to renew\"\n\tselectClient \"true\"\n\n\t# Allow user to specify renewal duration (use CLIENT_CERT_DURATION_DAYS env var for headless mode)\n\tif [[ -z $CLIENT_CERT_DURATION_DAYS ]] || ! [[ $CLIENT_CERT_DURATION_DAYS =~ ^[0-9]+$ ]] || [[ $CLIENT_CERT_DURATION_DAYS -lt 1 ]]; then\n\t\tlog_menu \"\"\n\t\tlog_prompt \"How many days should the renewed certificate be valid for?\"\n\t\tuntil [[ $client_cert_duration_days =~ ^[0-9]+$ ]] && [[ $client_cert_duration_days -ge 1 ]]; do\n\t\t\tread -rp \"Certificate validity (days): \" -e -i $DEFAULT_CERT_VALIDITY_DURATION_DAYS client_cert_duration_days\n\t\tdone\n\telse\n\t\tclient_cert_duration_days=$CLIENT_CERT_DURATION_DAYS\n\tfi\n\n\tcd /etc/openvpn/server/easy-rsa/ || return\n\tauth_mode=$(getAuthMode)\n\tlog_info \"Renewing certificate for $CLIENT...\"\n\n\t# Backup the old certificate before renewal\n\trun_cmd \"Backing up old certificate\" cp \"/etc/openvpn/server/easy-rsa/pki/issued/$CLIENT.crt\" \"/etc/openvpn/server/easy-rsa/pki/issued/$CLIENT.crt.bak\"\n\n\texport EASYRSA_CERT_EXPIRE=$client_cert_duration_days\n\n\tif [[ $auth_mode == \"fingerprint\" ]]; then\n\t\t# Fingerprint mode: delete old cert, generate new self-signed, update fingerprint\n\t\tremoveCertFiles \"$CLIENT\"\n\t\trun_cmd_fatal \"Generating new certificate\" ./easyrsa --batch self-sign-client \"$CLIENT\" nopass\n\n\t\tlocal new_fingerprint\n\t\tnew_fingerprint=$(extractFingerprint \"pki/issued/$CLIENT.crt\")\n\t\tif [[ -z \"$new_fingerprint\" ]]; then\n\t\t\tlog_fatal \"Failed to extract new certificate fingerprint\"\n\t\tfi\n\t\tlog_info \"New fingerprint: $new_fingerprint\"\n\n\t\t# Update fingerprint in server.conf (comment line followed by fingerprint)\n\t\tif grep -q \"^# $CLIENT\\$\" /etc/openvpn/server/server.conf; then\n\t\t\tsed -i \"/^# $CLIENT\\$/{n;s/.*/$new_fingerprint/}\" /etc/openvpn/server/server.conf\n\t\telse\n\t\t\tlog_fatal \"Client fingerprint entry not found in server.conf\"\n\t\tfi\n\n\t\t# Reload OpenVPN to apply new fingerprint\n\t\tif systemctl is-active --quiet openvpn-server@server; then\n\t\t\tsystemctl reload openvpn-server@server 2>/dev/null || systemctl restart openvpn-server@server\n\t\tfi\n\telse\n\t\t# PKI mode: use easyrsa renew\n\t\trun_cmd_fatal \"Renewing certificate\" ./easyrsa --batch renew \"$CLIENT\"\n\n\t\t# Revoke the old certificate\n\t\trun_cmd_fatal \"Revoking old certificate\" ./easyrsa --batch revoke-renewed \"$CLIENT\"\n\n\t\t# Regenerate the CRL\n\t\tregenerateCRL\n\tfi\n\n\t# Write the .ovpn config file with proper path and permissions\n\twriteClientConfig \"$CLIENT\"\n\n\tlog_menu \"\"\n\tlog_success \"Certificate for client $CLIENT renewed and is valid for $client_cert_duration_days days.\"\n\tlog_info \"The new configuration file has been written to $GENERATED_CONFIG_PATH.\"\n\tlog_info \"Download the new .ovpn file and import it in your OpenVPN client.\"\n}\n\nfunction renewServer() {\n\tlocal server_name server_cert_duration_days auth_mode\n\n\tlog_header \"Renew Server Certificate\"\n\n\t# Determine auth mode\n\tauth_mode=$(getAuthMode)\n\n\t# Get the server name from the config (extract basename since path may be relative)\n\tserver_name=$(basename \"$(grep '^cert ' /etc/openvpn/server/server.conf | cut -d ' ' -f 2)\" .crt)\n\tif [[ -z \"$server_name\" ]]; then\n\t\tlog_fatal \"Could not determine server certificate name from /etc/openvpn/server/server.conf\"\n\tfi\n\n\tlog_prompt \"This will renew the server certificate: $server_name\"\n\tlog_warn \"The OpenVPN service will be restarted after renewal.\"\n\tif [[ \"$auth_mode\" == \"fingerprint\" ]]; then\n\t\tlog_warn \"All client configurations will be regenerated with the new server fingerprint.\"\n\tfi\n\tif [[ -z $CONTINUE ]]; then\n\t\tread -rp \"Do you want to continue? [y/n]: \" -e -i n CONTINUE\n\tfi\n\tif [[ $CONTINUE != \"y\" ]]; then\n\t\tlog_info \"Renewal aborted.\"\n\t\treturn\n\tfi\n\n\t# Allow user to specify renewal duration (use SERVER_CERT_DURATION_DAYS env var for headless mode)\n\tif [[ -z $SERVER_CERT_DURATION_DAYS ]] || ! [[ $SERVER_CERT_DURATION_DAYS =~ ^[0-9]+$ ]] || [[ $SERVER_CERT_DURATION_DAYS -lt 1 ]]; then\n\t\tlog_menu \"\"\n\t\tlog_prompt \"How many days should the renewed certificate be valid for?\"\n\t\tuntil [[ $server_cert_duration_days =~ ^[0-9]+$ ]] && [[ $server_cert_duration_days -ge 1 ]]; do\n\t\t\tread -rp \"Certificate validity (days): \" -e -i $DEFAULT_CERT_VALIDITY_DURATION_DAYS server_cert_duration_days\n\t\tdone\n\telse\n\t\tserver_cert_duration_days=$SERVER_CERT_DURATION_DAYS\n\tfi\n\n\tcd /etc/openvpn/server/easy-rsa/ || return\n\tlog_info \"Renewing server certificate...\"\n\n\texport EASYRSA_CERT_EXPIRE=$server_cert_duration_days\n\n\tif [[ \"$auth_mode\" == \"fingerprint\" ]]; then\n\t\t# Fingerprint mode: delete old cert, generate new self-signed, update fingerprint\n\t\trun_cmd \"Backing up old certificate\" cp \"pki/issued/$server_name.crt\" \"pki/issued/$server_name.crt.bak\"\n\t\tremoveCertFiles \"$server_name\"\n\t\trun_cmd_fatal \"Generating new server certificate\" ./easyrsa --batch self-sign-server \"$server_name\" nopass\n\n\t\tlocal new_fingerprint\n\t\tnew_fingerprint=$(extractFingerprint \"pki/issued/$server_name.crt\")\n\t\tif [[ -z \"$new_fingerprint\" ]]; then\n\t\t\tlog_fatal \"Failed to extract new server certificate fingerprint\"\n\t\tfi\n\t\techo \"$new_fingerprint\" >/etc/openvpn/server/server-fingerprint\n\t\tlog_info \"New server fingerprint: $new_fingerprint\"\n\n\t\t# Copy new cert and key, then regenerate client configs (they embed server fingerprint)\n\t\tcp \"pki/issued/$server_name.crt\" \"pki/private/$server_name.key\" /etc/openvpn/server/\n\t\tlocal client\n\t\tfor client in $(getClientsFromFingerprints); do\n\t\t\t[[ -f \"pki/issued/$client.crt\" ]] && CLIENT=\"$client\" writeClientConfig \"$client\"\n\t\tdone\n\telse\n\t\t# PKI mode: use standard easyrsa renew\n\n\t\t# Backup the old certificate before renewal\n\t\trun_cmd \"Backing up old certificate\" cp \"/etc/openvpn/server/easy-rsa/pki/issued/$server_name.crt\" \"/etc/openvpn/server/easy-rsa/pki/issued/$server_name.crt.bak\"\n\n\t\t# Renew the certificate (keeps the same private key)\n\t\texport EASYRSA_CERT_EXPIRE=$server_cert_duration_days\n\t\trun_cmd_fatal \"Renewing certificate\" ./easyrsa --batch renew \"$server_name\"\n\n\t\t# Revoke the old certificate\n\t\trun_cmd_fatal \"Revoking old certificate\" ./easyrsa --batch revoke-renewed \"$server_name\"\n\n\t\t# Regenerate the CRL\n\t\tregenerateCRL\n\n\t\t# Copy the new certificate to /etc/openvpn/server/\n\t\trun_cmd_fatal \"Copying new certificate\" cp \"/etc/openvpn/server/easy-rsa/pki/issued/$server_name.crt\" /etc/openvpn/server/\n\tfi\n\n\t# Restart OpenVPN\n\tlog_info \"Restarting OpenVPN service...\"\n\trun_cmd \"Restarting OpenVPN\" systemctl restart openvpn-server@server\n\n\tlog_success \"Server certificate renewed successfully and is valid for $server_cert_duration_days days.\"\n}\n\nfunction getDaysUntilExpiry() {\n\tlocal cert_file=\"$1\"\n\tif [[ -f \"$cert_file\" ]]; then\n\t\tlocal expiry_date\n\t\texpiry_date=$(openssl x509 -in \"$cert_file\" -noout -enddate | cut -d= -f2)\n\t\tlocal expiry_epoch\n\t\texpiry_epoch=$(date -d \"$expiry_date\" +%s 2>/dev/null || date -j -f \"%b %d %T %Y %Z\" \"$expiry_date\" +%s 2>/dev/null)\n\t\tif [[ -z \"$expiry_epoch\" ]]; then\n\t\t\techo \"?\"\n\t\t\treturn\n\t\tfi\n\t\tlocal now_epoch\n\t\tnow_epoch=$(date +%s)\n\t\techo $(((expiry_epoch - now_epoch) / 86400))\n\telse\n\t\techo \"?\"\n\tfi\n}\n\nfunction formatExpiry() {\n\tlocal days=\"$1\"\n\tif [[ \"$days\" == \"?\" ]]; then\n\t\techo \"(unknown expiry)\"\n\telif [[ $days -lt 0 ]]; then\n\t\techo \"(EXPIRED $((-days)) days ago)\"\n\telif [[ $days -eq 0 ]]; then\n\t\techo \"(expires today)\"\n\telif [[ $days -eq 1 ]]; then\n\t\techo \"(expires in 1 day)\"\n\telse\n\t\techo \"(expires in $days days)\"\n\tfi\n}\n\nfunction renewMenu() {\n\tlocal server_name server_cert server_days server_expiry renew_option\n\n\tlog_header \"Certificate Renewal\"\n\n\t# Get server certificate expiry for menu display (extract basename since path may be relative)\n\tserver_name=$(basename \"$(grep '^cert ' /etc/openvpn/server/server.conf | cut -d ' ' -f 2)\" .crt)\n\tif [[ -z \"$server_name\" ]]; then\n\t\tserver_expiry=\"(unknown expiry)\"\n\telse\n\t\tserver_cert=\"/etc/openvpn/server/easy-rsa/pki/issued/$server_name.crt\"\n\t\tserver_days=$(getDaysUntilExpiry \"$server_cert\")\n\t\tserver_expiry=$(formatExpiry \"$server_days\")\n\tfi\n\n\tlog_menu \"\"\n\tlog_prompt \"What do you want to renew?\"\n\tlog_menu \"   1) Renew a client certificate\"\n\tlog_menu \"   2) Renew the server certificate $server_expiry\"\n\tlog_menu \"   3) Back to main menu\"\n\tuntil [[ ${RENEW_OPTION:-$renew_option} =~ ^[1-3]$ ]]; do\n\t\tread -rp \"Select an option [1-3]: \" renew_option\n\tdone\n\trenew_option=\"${RENEW_OPTION:-$renew_option}\"\n\n\tcase $renew_option in\n\t1)\n\t\trenewClient\n\t\t;;\n\t2)\n\t\trenewServer\n\t\t;;\n\t3)\n\t\tmanageMenu\n\t\t;;\n\tesac\n}\n\nfunction removeUnbound() {\n\trun_cmd \"Removing OpenVPN Unbound config\" rm -f /etc/unbound/unbound.conf.d/openvpn.conf\n\n\t# Clean up include directive if conf.d directory is now empty\n\tif [[ -d /etc/unbound/unbound.conf.d ]] && [[ -z \"$(ls -A /etc/unbound/unbound.conf.d)\" ]]; then\n\t\trun_cmd \"Cleaning up Unbound include directive\" \\\n\t\t\tsed -i '/^include: \"\\/etc\\/unbound\\/unbound\\.conf\\.d\\/\\*\\.conf\"$/d' /etc/unbound/unbound.conf\n\tfi\n\n\tuntil [[ $REMOVE_UNBOUND =~ (y|n) ]]; do\n\t\tlog_info \"If you were already using Unbound before installing OpenVPN, I removed the configuration related to OpenVPN.\"\n\t\tread -rp \"Do you want to completely remove Unbound? [y/n]: \" -e REMOVE_UNBOUND\n\tdone\n\n\tif [[ $REMOVE_UNBOUND == 'y' ]]; then\n\t\tlog_info \"Removing Unbound...\"\n\t\trun_cmd \"Stopping Unbound\" systemctl stop unbound\n\n\t\tif [[ $OS =~ (debian|ubuntu) ]]; then\n\t\t\trun_cmd \"Removing Unbound\" apt-get remove --purge -y unbound\n\t\telif [[ $OS == 'arch' ]]; then\n\t\t\trun_cmd \"Removing Unbound\" pacman --noconfirm -R unbound\n\t\telif [[ $OS =~ (centos|oracle) ]]; then\n\t\t\trun_cmd \"Removing Unbound\" yum remove -y unbound\n\t\telif [[ $OS =~ (fedora|amzn2023) ]]; then\n\t\t\trun_cmd \"Removing Unbound\" dnf remove -y unbound\n\t\telif [[ $OS == 'opensuse' ]]; then\n\t\t\trun_cmd \"Removing Unbound\" zypper remove -y unbound\n\t\tfi\n\n\t\trun_cmd \"Removing Unbound config\" rm -rf /etc/unbound/\n\t\tlog_success \"Unbound removed!\"\n\telse\n\t\trun_cmd \"Restarting Unbound\" systemctl restart unbound\n\t\tlog_info \"Unbound wasn't removed.\"\n\tfi\n}\n\nfunction removeOpenVPN() {\n\tlog_header \"Remove OpenVPN\"\n\tif [[ -z $REMOVE ]]; then\n\t\tread -rp \"Do you really want to remove OpenVPN? [y/n]: \" -e -i n REMOVE\n\tfi\n\tif [[ $REMOVE == 'y' ]]; then\n\t\t# Get OpenVPN configuration\n\t\tPORT=$(grep '^port ' /etc/openvpn/server/server.conf | cut -d \" \" -f 2)\n\t\tPROTOCOL=$(grep '^proto ' /etc/openvpn/server/server.conf | cut -d \" \" -f 2)\n\t\t# Strip \"6\" suffix for firewall/SELinux commands (they expect \"udp\"/\"tcp\", not \"udp6\"/\"tcp6\")\n\t\tPROTOCOL_BASE=\"${PROTOCOL%6}\"\n\t\t# Extract IPv4 subnet (may be empty if IPv4 not enabled)\n\t\tVPN_SUBNET_IPV4=$(grep '^server ' /etc/openvpn/server/server.conf | cut -d \" \" -f 2)\n\t\t# Extract IPv6 subnet (may be empty if IPv6 not enabled)\n\t\tVPN_SUBNET_IPV6=$(grep '^server-ipv6 ' /etc/openvpn/server/server.conf | cut -d \" \" -f 2 | sed 's|/.*||')\n\n\t\t# Stop OpenVPN\n\t\tlog_info \"Stopping OpenVPN service...\"\n\t\trun_cmd \"Disabling OpenVPN service\" systemctl disable openvpn-server@server\n\t\trun_cmd \"Stopping OpenVPN service\" systemctl stop openvpn-server@server\n\t\t# Remove customised service\n\t\trun_cmd \"Removing service file\" rm -f /etc/systemd/system/openvpn-server@.service\n\n\t\t# Remove firewall rules\n\t\tlog_info \"Removing firewall rules...\"\n\t\tif systemctl is-active --quiet firewalld && firewall-cmd --list-ports | grep -q \"$PORT/$PROTOCOL_BASE\"; then\n\t\t\t# firewalld was used\n\t\t\trun_cmd \"Removing OpenVPN port from firewalld\" firewall-cmd --permanent --remove-port=\"$PORT/$PROTOCOL_BASE\"\n\t\t\trun_cmd \"Removing masquerade from firewalld\" firewall-cmd --permanent --remove-masquerade\n\t\t\t# Remove IPv4 rich rule if configured\n\t\t\tif [[ -n $VPN_SUBNET_IPV4 ]]; then\n\t\t\t\tfirewall-cmd --permanent --remove-rich-rule=\"rule family=\\\"ipv4\\\" source address=\\\"$VPN_SUBNET_IPV4/24\\\" accept\" 2>/dev/null || true\n\t\t\tfi\n\t\t\t# Remove IPv6 rich rule if configured\n\t\t\tif [[ -n $VPN_SUBNET_IPV6 ]]; then\n\t\t\t\tfirewall-cmd --permanent --remove-rich-rule=\"rule family=\\\"ipv6\\\" source address=\\\"${VPN_SUBNET_IPV6}/112\\\" accept\" 2>/dev/null || true\n\t\t\tfi\n\t\t\trun_cmd \"Reloading firewalld\" firewall-cmd --reload\n\t\telif [[ -f /etc/nftables/openvpn.nft ]]; then\n\t\t\t# nftables was used\n\t\t\t# Delete tables (suppress errors in case tables don't exist)\n\t\t\tnft delete table inet openvpn 2>/dev/null || true\n\t\t\tnft delete table ip openvpn-nat 2>/dev/null || true\n\t\t\tnft delete table ip6 openvpn-nat 2>/dev/null || true\n\t\t\trun_cmd \"Removing include from nftables.conf\" sed -i '/include.*openvpn\\.nft/d' /etc/nftables.conf\n\t\t\trun_cmd \"Removing nftables rules file\" rm -f /etc/nftables/openvpn.nft\n\t\telif [[ -f /etc/systemd/system/iptables-openvpn.service ]]; then\n\t\t\t# iptables was used\n\t\t\trun_cmd \"Stopping iptables service\" systemctl stop iptables-openvpn\n\t\t\trun_cmd \"Disabling iptables service\" systemctl disable iptables-openvpn\n\t\t\trun_cmd \"Removing iptables service file\" rm /etc/systemd/system/iptables-openvpn.service\n\t\t\trun_cmd \"Reloading systemd\" systemctl daemon-reload\n\t\t\trun_cmd \"Removing iptables add script\" rm -f /etc/iptables/add-openvpn-rules.sh\n\t\t\trun_cmd \"Removing iptables rm script\" rm -f /etc/iptables/rm-openvpn-rules.sh\n\t\tfi\n\n\t\t# SELinux\n\t\tif hash sestatus 2>/dev/null; then\n\t\t\tif sestatus | grep \"Current mode\" | grep -qs \"enforcing\"; then\n\t\t\t\tif [[ $PORT != '1194' ]]; then\n\t\t\t\t\trun_cmd \"Removing SELinux port\" semanage port -d -t openvpn_port_t -p \"$PROTOCOL_BASE\" \"$PORT\"\n\t\t\t\tfi\n\t\t\tfi\n\t\tfi\n\n\t\tlog_info \"Removing OpenVPN package...\"\n\t\tif [[ $OS =~ (debian|ubuntu) ]]; then\n\t\t\trun_cmd \"Removing OpenVPN\" apt-get remove --purge -y openvpn\n\t\t\t# Remove OpenVPN official repository and GPG key\n\t\t\tif [[ -e /etc/apt/sources.list.d/openvpn-aptrepo.list ]]; then\n\t\t\t\trun_cmd \"Removing OpenVPN repo\" rm /etc/apt/sources.list.d/openvpn-aptrepo.list\n\t\t\tfi\n\t\t\tif [[ -e /etc/apt/keyrings/openvpn-repo-public.asc ]]; then\n\t\t\t\trun_cmd \"Removing OpenVPN GPG key\" rm /etc/apt/keyrings/openvpn-repo-public.asc\n\t\t\tfi\n\t\t\trun_cmd_fatal \"Updating package lists\" apt-get update\n\t\telif [[ $OS == 'arch' ]]; then\n\t\t\trun_cmd \"Removing OpenVPN\" pacman --noconfirm -R openvpn\n\t\telif [[ $OS =~ (centos|oracle) ]]; then\n\t\t\trun_cmd \"Removing OpenVPN\" yum remove -y openvpn\n\t\t\t# Disable Copr repo if it was enabled\n\t\t\tif command -v dnf &>/dev/null; then\n\t\t\t\trun_cmd \"Disabling OpenVPN Copr repo\" dnf copr disable -y @OpenVPN/openvpn-release-2.6 2>/dev/null || true\n\t\t\telse\n\t\t\t\trun_cmd \"Disabling OpenVPN Copr repo\" yum copr disable -y @OpenVPN/openvpn-release-2.6 2>/dev/null || true\n\t\t\tfi\n\t\telif [[ $OS == 'amzn2023' ]]; then\n\t\t\trun_cmd \"Removing OpenVPN\" dnf remove -y openvpn\n\t\telif [[ $OS == 'fedora' ]]; then\n\t\t\trun_cmd \"Removing OpenVPN\" dnf remove -y openvpn\n\t\telif [[ $OS == 'opensuse' ]]; then\n\t\t\trun_cmd \"Removing OpenVPN\" zypper remove -y openvpn\n\t\tfi\n\n\t\t# Cleanup\n\t\trun_cmd \"Removing client configs from /home\" find /home/ -maxdepth 2 -name \"*.ovpn\" -delete\n\t\trun_cmd \"Removing client configs from /root\" find /root/ -maxdepth 1 -name \"*.ovpn\" -delete\n\t\trun_cmd \"Removing /etc/openvpn\" rm -rf /etc/openvpn\n\t\trun_cmd \"Removing OpenVPN docs\" rm -rf /usr/share/doc/openvpn*\n\t\trun_cmd \"Removing sysctl config\" rm -f /etc/sysctl.d/99-openvpn.conf\n\t\trun_cmd \"Removing OpenVPN logs\" rm -rf /var/log/openvpn\n\n\t\t# AppArmor local override\n\t\tif [[ -f /etc/apparmor.d/local/openvpn ]]; then\n\t\t\trun_cmd \"Removing AppArmor local override\" rm -f /etc/apparmor.d/local/openvpn\n\t\t\tif [[ -f /etc/apparmor.d/openvpn ]]; then\n\t\t\t\trun_cmd \"Reloading AppArmor profile\" apparmor_parser -r /etc/apparmor.d/openvpn 2>/dev/null || true\n\t\t\tfi\n\t\tfi\n\n\t\t# Unbound\n\t\tif [[ -e /etc/unbound/unbound.conf.d/openvpn.conf ]]; then\n\t\t\tremoveUnbound\n\t\tfi\n\t\tlog_success \"OpenVPN removed!\"\n\telse\n\t\tlog_info \"Removal aborted!\"\n\tfi\n}\n\nfunction manageMenu() {\n\tlocal menu_option\n\n\tlog_header \"OpenVPN Management\"\n\tlog_prompt \"The git repository is available at: https://github.com/angristan/openvpn-install\"\n\tlog_success \"OpenVPN is already installed.\"\n\tlog_menu \"\"\n\tlog_prompt \"What do you want to do?\"\n\tlog_menu \"   1) Add a new user\"\n\tlog_menu \"   2) List client certificates\"\n\tlog_menu \"   3) Revoke existing user\"\n\tlog_menu \"   4) Renew certificate\"\n\tlog_menu \"   5) Remove OpenVPN\"\n\tlog_menu \"   6) List connected clients\"\n\tlog_menu \"   7) Exit\"\n\tuntil [[ ${MENU_OPTION:-$menu_option} =~ ^[1-7]$ ]]; do\n\t\tread -rp \"Select an option [1-7]: \" menu_option\n\tdone\n\tmenu_option=\"${MENU_OPTION:-$menu_option}\"\n\n\tcase $menu_option in\n\t1)\n\t\tnewClient\n\t\texit 0\n\t\t;;\n\t2)\n\t\tlistClients\n\t\t;;\n\t3)\n\t\trevokeClient\n\t\t;;\n\t4)\n\t\trenewMenu\n\t\t;;\n\t5)\n\t\tremoveOpenVPN\n\t\t;;\n\t6)\n\t\tlistConnectedClients\n\t\t;;\n\t7)\n\t\texit 0\n\t\t;;\n\tesac\n}\n\n# =============================================================================\n# Main Entry Point\n# =============================================================================\nparse_args \"$@\"\n"
  },
  {
    "path": "renovate.json",
    "content": "{\n  \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n  \"extends\": [\"config:recommended\"],\n  \"ignorePaths\": [\".github/workflows/do-test.yml\"],\n  \"customManagers\": [\n    {\n      \"customType\": \"regex\",\n      \"managerFilePatterns\": [\"/^openvpn-install\\\\.sh$/\"],\n      \"matchStrings\": [\n        \"readonly\\\\s+EASYRSA_VERSION=\\\"(?<currentValue>\\\\d+\\\\.\\\\d+\\\\.\\\\d+)\\\"\"\n      ],\n      \"depNameTemplate\": \"OpenVPN/easy-rsa\",\n      \"datasourceTemplate\": \"github-releases\",\n      \"extractVersionTemplate\": \"^v(?<version>.*)$\"\n    }\n  ]\n}\n"
  },
  {
    "path": "test/Dockerfile.client",
    "content": "# checkov:skip=CKV_DOCKER_2:Test container doesn't need healthcheck\n# checkov:skip=CKV_DOCKER_3:OpenVPN client requires root for NET_ADMIN\nFROM ubuntu:24.04\n\nENV DEBIAN_FRONTEND=noninteractive\n\n# Install OpenVPN client and testing tools\n# dnsutils provides dig for DNS testing with Unbound\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    openvpn \\\n    iproute2 \\\n    iputils-ping \\\n    procps \\\n    dnsutils \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Create TUN device directory (device will be mounted at runtime)\nRUN mkdir -p /dev/net\n\n# Copy test scripts\nCOPY test/client-entrypoint.sh /entrypoint.sh\nRUN chmod +x /entrypoint.sh\n\nWORKDIR /etc/openvpn\n\nENTRYPOINT [\"/entrypoint.sh\"]\n"
  },
  {
    "path": "test/Dockerfile.server",
    "content": "# checkov:skip=CKV_DOCKER_2:Test container doesn't need healthcheck\n# checkov:skip=CKV_DOCKER_3:OpenVPN server requires root for NET_ADMIN\n# checkov:skip=CKV_DOCKER_7:Base image is parameterized, some use latest tag\nARG BASE_IMAGE=ubuntu:24.04\nFROM ${BASE_IMAGE}\n\nARG BASE_IMAGE\n# Set to \"y\" to install and enable firewalld for testing\nARG ENABLE_FIREWALLD=n\n# Set to \"y\" to install and enable nftables for testing\nARG ENABLE_NFTABLES=n\nENV DEBIAN_FRONTEND=noninteractive\nENV ENABLE_FIREWALLD=${ENABLE_FIREWALLD}\nENV ENABLE_NFTABLES=${ENABLE_NFTABLES}\n\n# Install basic dependencies based on the OS\n# dnsutils/bind-utils provides dig for DNS testing with Unbound\n# Note: socat is installed by openvpn-install.sh during OpenVPN installation\nRUN if command -v apt-get >/dev/null; then \\\n        apt-get update && apt-get install -y --no-install-recommends \\\n            iproute2 iptables curl procps systemd systemd-sysv dnsutils jq \\\n        && if [ \"$ENABLE_NFTABLES\" = \"y\" ]; then apt-get install -y --no-install-recommends nftables; fi \\\n        && rm -rf /var/lib/apt/lists/*; \\\n    elif command -v dnf >/dev/null; then \\\n        dnf install -y --allowerasing \\\n            iproute iptables curl procps-ng systemd tar gzip bind-utils jq \\\n        && if [ \"$ENABLE_FIREWALLD\" = \"y\" ]; then dnf install -y firewalld; fi \\\n        && if [ \"$ENABLE_NFTABLES\" = \"y\" ]; then dnf install -y nftables; fi \\\n        && dnf clean all; \\\n    elif command -v yum >/dev/null; then \\\n        yum install -y \\\n            iproute iptables curl procps-ng systemd tar gzip bind-utils jq \\\n        && if [ \"$ENABLE_FIREWALLD\" = \"y\" ]; then yum install -y firewalld; fi \\\n        && if [ \"$ENABLE_NFTABLES\" = \"y\" ]; then yum install -y nftables; fi \\\n        && yum clean all; \\\n    elif command -v pacman >/dev/null; then \\\n        pacman -Syu --noconfirm \\\n            iproute2 iptables curl procps-ng bind jq \\\n        && if [ \"$ENABLE_NFTABLES\" = \"y\" ]; then pacman -S --noconfirm nftables; fi \\\n        && pacman -Scc --noconfirm; \\\n    elif command -v zypper >/dev/null; then \\\n        zypper install -y \\\n            iproute2 iptables curl procps systemd tar gzip bind-utils gawk jq \\\n        && if [ \"$ENABLE_NFTABLES\" = \"y\" ]; then zypper install -y nftables; fi \\\n        && zypper clean -a; \\\n    fi\n\n# Enable firewalld if requested (must be done after systemd is available)\nRUN if [ \"$ENABLE_FIREWALLD\" = \"y\" ] && command -v firewall-cmd >/dev/null; then \\\n        systemctl enable firewalld; \\\n    fi\n\n# Enable nftables if requested (must be done after systemd is available)\n# Use empty nftables.conf - do NOT flush ruleset as it removes Docker's networking rules\nRUN if [ \"$ENABLE_NFTABLES\" = \"y\" ] && command -v nft >/dev/null; then \\\n        systemctl enable nftables; \\\n        mkdir -p /etc/nftables; \\\n        echo '#!/usr/sbin/nft -f' > /etc/nftables.conf; \\\n    fi\n\n# Create TUN device (will be mounted at runtime)\nRUN mkdir -p /dev/net\n\n# Copy the install script\nCOPY openvpn-install.sh /opt/openvpn-install.sh\nRUN chmod +x /opt/openvpn-install.sh\n\n# Copy test scripts\nCOPY test/server-entrypoint.sh /entrypoint.sh\nCOPY test/validate-output.sh /opt/test/validate-output.sh\nRUN chmod +x /entrypoint.sh /opt/test/validate-output.sh\n\n# Create systemd service for the test script\n# PassEnvironment passes Docker env vars (-e) from PID 1 to the service\nRUN printf '%s\\n' \\\n    '[Unit]' \\\n    'Description=OpenVPN Installation Test' \\\n    'After=network.target' \\\n    '' \\\n    '[Service]' \\\n    'Type=oneshot' \\\n    'Environment=HOME=/root' \\\n    'PassEnvironment=AUTH_MODE TLS_SIG TLS_KEY_FILE TLS_VERSION_MIN TLS13_CIPHERSUITES CLIENT_IPV6 VPN_SUBNET_IPV6' \\\n    'WorkingDirectory=/root' \\\n    'ExecStart=/entrypoint.sh' \\\n    'RemainAfterExit=yes' \\\n    'StandardOutput=journal+console' \\\n    'StandardError=journal+console' \\\n    '' \\\n    '[Install]' \\\n    'WantedBy=multi-user.target' \\\n    > /etc/systemd/system/openvpn-test.service \\\n    && systemctl enable openvpn-test.service\n\nWORKDIR /opt\n\nSTOPSIGNAL SIGRTMIN+3\nCMD [\"/sbin/init\"]\n"
  },
  {
    "path": "test/client-entrypoint.sh",
    "content": "#!/bin/bash\nset -e\n\necho \"=== OpenVPN Client Container ===\"\n\n# Create TUN device if it doesn't exist\nif [ ! -c /dev/net/tun ]; then\n\tmkdir -p /dev/net\n\tmknod /dev/net/tun c 10 200\n\tchmod 600 /dev/net/tun\nfi\n\necho \"TUN device ready\"\n\ntest_dns_resolution() {\n\tlocal label=\"$1\"\n\tlocal success=false\n\techo \"$label: Testing DNS resolution via Unbound ($VPN_GATEWAY)...\"\n\tfor i in $(seq 1 10); do\n\t\tDIG_OUTPUT=$(dig @\"$VPN_GATEWAY\" example.com +short +time=5 2>&1)\n\t\tif [ -n \"$DIG_OUTPUT\" ] && ! echo \"$DIG_OUTPUT\" | grep -qi \"timed out\\|SERVFAIL\\|connection refused\"; then\n\t\t\tsuccess=true\n\t\t\tbreak\n\t\tfi\n\t\techo \"DNS attempt $i failed:\"\n\t\techo \"$DIG_OUTPUT\"\n\t\tsleep 2\n\tdone\n\tif [ \"$success\" = true ]; then\n\t\techo \"PASS: DNS resolution through Unbound works\"\n\telse\n\t\techo \"FAIL: DNS resolution through Unbound failed after 10 attempts\"\n\t\tdig @\"$VPN_GATEWAY\" example.com +time=5 || true\n\t\texit 1\n\tfi\n}\n\n# Wait for client config to be available\necho \"Waiting for client config...\"\nwhile [ ! -f /shared/client.ovpn ]; do\n\tsleep 2\n\techo \"Waiting for client config...\"\ndone\n\necho \"Client config found!\"\ncat /shared/client.ovpn\n\n# Load VPN network config from server\nif [ -f /shared/vpn-config.env ]; then\n\t# shellcheck source=/dev/null\n\tsource /shared/vpn-config.env\n\techo \"VPN config loaded: VPN_SUBNET_IPV4=$VPN_SUBNET_IPV4, VPN_GATEWAY=$VPN_GATEWAY\"\n\tif [ \"${CLIENT_IPV6:-n}\" = \"y\" ]; then\n\t\t# shellcheck disable=SC2153 # Variables are sourced from vpn-config.env\n\t\techo \"IPv6 enabled: VPN_SUBNET_IPV6=$VPN_SUBNET_IPV6, VPN_GATEWAY_IPV6=$VPN_GATEWAY_IPV6\"\n\tfi\nelse\n\techo \"WARNING: vpn-config.env not found, using defaults\"\n\tVPN_SUBNET_IPV4=\"10.8.0.0\"\n\tVPN_GATEWAY=\"10.8.0.1\"\n\tCLIENT_IPV6=\"n\"\nfi\n\n# Connect to VPN\necho \"Connecting to OpenVPN server...\"\nopenvpn --config /shared/client.ovpn --daemon --log /var/log/openvpn.log\n\n# Wait for connection\necho \"Waiting for VPN connection...\"\nwhile ! ip addr show tun0 2>/dev/null | grep -q \"inet \"; do\n\tsleep 2\n\techo \"Waiting for tun0...\"\n\t# Show recent log for debugging\n\tif [ -f /var/log/openvpn.log ]; then\n\t\ttail -3 /var/log/openvpn.log\n\tfi\ndone\n\necho \"=== VPN Connected! ===\"\nip addr show tun0\n\n# Allow routing tables to stabilize before running tests\n# This prevents race conditions where tun0 is up but routing isn't ready\necho \"Waiting for routing to stabilize...\"\nsleep 5\n\n# Run connectivity tests\necho \"\"\necho \"=== Running connectivity tests ===\"\n\n# Test 1: Check tun0 interface (IPv4)\necho \"Test 1: Checking tun0 interface (IPv4)...\"\n# Extract base of subnet (e.g., \"10.9.0\" from \"10.9.0.0\")\nVPN_SUBNET_BASE=\"${VPN_SUBNET_IPV4%.*}\"\nif ip addr show tun0 | grep -q \"$VPN_SUBNET_BASE\"; then\n\techo \"PASS: tun0 interface has correct IPv4 range (${VPN_SUBNET_BASE}.x)\"\nelse\n\techo \"FAIL: tun0 interface doesn't have expected IPv4\"\n\texit 1\nfi\n\n# Test 1b: Check tun0 IPv6 address (if IPv6 enabled)\nif [ \"${CLIENT_IPV6:-n}\" = \"y\" ]; then\n\techo \"Test 1b: Checking tun0 interface (IPv6)...\"\n\t# Extract prefix of subnet (e.g., \"fd42:42:42:42\" from \"fd42:42:42:42::\")\n\tVPN_SUBNET_IPV6_PREFIX=\"${VPN_SUBNET_IPV6%::}\"\n\tif ip -6 addr show tun0 | grep -q \"$VPN_SUBNET_IPV6_PREFIX\"; then\n\t\techo \"PASS: tun0 interface has correct IPv6 range (${VPN_SUBNET_IPV6_PREFIX}::x)\"\n\telse\n\t\techo \"FAIL: tun0 interface doesn't have expected IPv6\"\n\t\tip -6 addr show tun0\n\t\texit 1\n\tfi\nfi\n\n# Test 2: Ping VPN gateway (IPv4) (retries indefinitely, relies on job timeout)\necho \"Test 2: Pinging VPN gateway (IPv4) ($VPN_GATEWAY)...\"\nwhile ! ping -c 3 -W 2 \"$VPN_GATEWAY\" >/dev/null 2>&1; do\n\techo \"Ping failed, retrying...\"\n\tsleep 3\ndone\necho \"PASS: Can ping VPN gateway (IPv4)\"\n\n# Test 2b: Ping VPN gateway (IPv6, if enabled)\nif [ \"${CLIENT_IPV6:-n}\" = \"y\" ]; then\n\techo \"Test 2b: Pinging VPN gateway (IPv6) ($VPN_GATEWAY_IPV6)...\"\n\tif ping6 -c 5 \"$VPN_GATEWAY_IPV6\"; then\n\t\techo \"PASS: Can ping VPN gateway (IPv6)\"\n\telse\n\t\techo \"FAIL: Cannot ping VPN gateway (IPv6)\"\n\t\texit 1\n\tfi\nfi\n\n# Test 3: DNS resolution through Unbound\ntest_dns_resolution \"Test 3\"\n\necho \"\"\necho \"=== Initial connectivity tests PASSED ===\"\n\n# Signal server that initial tests passed\ntouch /shared/initial-tests-passed\n\n# =====================================================\n# Post-renewal connectivity tests\n# =====================================================\necho \"\"\necho \"=== Waiting for post-renewal config ===\"\nwhile [ ! -f /shared/renewal-config-ready ]; do\n\tsleep 2\n\techo \"Waiting for renewal config...\"\ndone\n\necho \"Renewal config ready, reconnecting...\"\npkill openvpn || true\nsleep 2\n\nopenvpn --config /shared/client.ovpn --daemon --log /var/log/openvpn-renewal.log\n\necho \"Waiting for VPN connection after renewal...\"\nwhile ! ip addr show tun0 2>/dev/null | grep -q \"inet \"; do\n\tsleep 2\n\techo \"Waiting for tun0...\"\n\tif [ -f /var/log/openvpn-renewal.log ]; then\n\t\ttail -3 /var/log/openvpn-renewal.log\n\tfi\ndone\n\necho \"=== VPN Connected after renewal! ===\"\nip addr show tun0\n\necho \"Waiting for routing to stabilize...\"\nsleep 5\n\necho \"Test: Pinging VPN gateway after renewal ($VPN_GATEWAY)...\"\nwhile ! ping -c 3 -W 2 \"$VPN_GATEWAY\" >/dev/null 2>&1; do\n\techo \"Ping failed, retrying...\"\n\tsleep 3\ndone\necho \"PASS: Can ping VPN gateway after renewal\"\n\ntest_dns_resolution \"Test: Post-renewal DNS\"\n\necho \"\"\necho \"=== Post-renewal connectivity tests PASSED ===\"\ntouch /shared/renewal-tests-passed\n\n# =====================================================\n# Certificate Revocation E2E Tests\n# =====================================================\necho \"\"\necho \"=== Starting Certificate Revocation E2E Tests ===\"\n\nREVOKE_CLIENT=\"revoketest\"\n\n# Wait for revoke test client config\necho \"Waiting for revoke test client config...\"\nwhile [ ! -f /shared/revoke-client-config-ready ]; do\n\tsleep 2\n\techo \"Waiting for revoke test config...\"\ndone\n\nif [ ! -f \"/shared/$REVOKE_CLIENT.ovpn\" ]; then\n\techo \"FAIL: Revoke test client config file not found\"\n\texit 1\nfi\n\necho \"Revoke test client config found!\"\n\n# Disconnect current VPN (testclient) before connecting with revoke test client\necho \"Disconnecting current VPN connection...\"\npkill openvpn || true\nsleep 2\n\n# Connect with revoke test client\necho \"Connecting with '$REVOKE_CLIENT' certificate...\"\nopenvpn --config \"/shared/$REVOKE_CLIENT.ovpn\" --daemon --log /var/log/openvpn-revoke.log\n\n# Wait for connection\necho \"Waiting for VPN connection with revoke test client...\"\nwhile ! ip addr show tun0 2>/dev/null | grep -q \"inet \"; do\n\tsleep 2\n\techo \"Waiting for tun0...\"\n\tif [ -f /var/log/openvpn-revoke.log ]; then\n\t\ttail -3 /var/log/openvpn-revoke.log\n\tfi\ndone\n\necho \"PASS: Connected with '$REVOKE_CLIENT' certificate\"\nip addr show tun0\n\n# Verify connectivity (retries indefinitely, relies on job timeout)\nwhile ! ping -c 3 -W 2 \"$VPN_GATEWAY\" >/dev/null 2>&1; do\n\techo \"Ping failed, retrying...\"\n\tsleep 3\ndone\necho \"PASS: Can ping VPN gateway with revoke test client\"\n\n# Signal server that we're connected with revoke test client\ntouch /shared/revoke-client-connected\n\n# Wait for server to revoke and auto-disconnect us via management interface\n# We detect disconnect by checking if ping to VPN gateway fails\necho \"Waiting for server to revoke certificate and disconnect us...\"\nDISCONNECT_DETECTED=false\nfor i in $(seq 1 60); do\n\tif ! ping -c 1 -W 2 \"$VPN_GATEWAY\" >/dev/null 2>&1; then\n\t\techo \"Disconnect detected: cannot ping VPN gateway\"\n\t\tDISCONNECT_DETECTED=true\n\t\tbreak\n\tfi\n\tsleep 1\n\techo \"Still connected, waiting for revoke/disconnect ($i/60)...\"\ndone\n\nif [ \"$DISCONNECT_DETECTED\" = true ]; then\n\techo \"PASS: Client was auto-disconnected by revoke\"\n\t# Kill openvpn process to clean up\n\tpkill openvpn 2>/dev/null || true\n\tsleep 1\nelse\n\techo \"FAIL: Client was not disconnected within 60 seconds\"\n\texit 1\nfi\n\n# Signal server that we detected the disconnect\ntouch /shared/revoke-client-disconnected\n\n# Wait for server to signal us to try reconnecting\necho \"Waiting for server to signal reconnect attempt...\"\nwhile [ ! -f /shared/revoke-try-reconnect ]; do\n\tsleep 2\ndone\n\n# Try to reconnect with the now-revoked certificate (should fail)\necho \"Attempting to reconnect with revoked certificate (should fail)...\"\nrm -f /var/log/openvpn-revoke-fail.log\nopenvpn --config \"/shared/$REVOKE_CLIENT.ovpn\" --daemon --log /var/log/openvpn-revoke-fail.log\n\n# Wait and check if connection fails\n# The connection should fail due to certificate being revoked\necho \"Waiting to verify connection is rejected...\"\nCONNECT_FAILED=false\nwhile true; do\n\tsleep 2\n\n\t# Check if tun0 came up (would mean revocation didn't work)\n\tif ip addr show tun0 2>/dev/null | grep -q \"inet \"; then\n\t\techo \"FAIL: Connection succeeded with revoked certificate!\"\n\t\tcat /var/log/openvpn-revoke-fail.log || true\n\t\texit 1\n\tfi\n\n\t# Check for certificate verification failure in log\n\tif [ -f /var/log/openvpn-revoke-fail.log ]; then\n\t\tif grep -qi \"certificate verify failed\\|TLS Error\\|AUTH_FAILED\\|certificate revoked\" /var/log/openvpn-revoke-fail.log; then\n\t\t\techo \"Connection correctly rejected (certificate revoked)\"\n\t\t\tCONNECT_FAILED=true\n\t\t\tbreak\n\t\tfi\n\tfi\n\n\techo \"Checking connection status...\"\n\tif [ -f /var/log/openvpn-revoke-fail.log ]; then\n\t\ttail -3 /var/log/openvpn-revoke-fail.log\n\tfi\ndone\n\n# Kill any remaining openvpn process\npkill openvpn 2>/dev/null || true\nsleep 1\n\n# Even if we didn't see explicit error, verify tun0 is not up\nif ip addr show tun0 2>/dev/null | grep -q \"inet \"; then\n\techo \"FAIL: tun0 interface exists - revoked cert may have connected\"\n\texit 1\nfi\n\nif [ \"$CONNECT_FAILED\" = true ]; then\n\techo \"PASS: Connection with revoked certificate was correctly rejected\"\nelse\n\techo \"PASS: Connection with revoked certificate did not succeed (no tun0)\"\n\techo \"OpenVPN log:\"\n\tcat /var/log/openvpn-revoke-fail.log || true\nfi\n\n# Signal server that reconnect with revoked cert failed\ntouch /shared/revoke-reconnect-failed\n\n# =====================================================\n# Test connecting with new certificate (same name)\n# =====================================================\necho \"\"\necho \"=== Testing connection with recreated certificate ===\"\n\n# Wait for server to create new cert and signal us\necho \"Waiting for new client config with same name...\"\nwhile [ ! -f /shared/new-client-config-ready ]; do\n\tsleep 2\n\techo \"Waiting for new config...\"\ndone\n\nif [ ! -f \"/shared/$REVOKE_CLIENT-new.ovpn\" ]; then\n\techo \"FAIL: New client config file not found\"\n\texit 1\nfi\n\necho \"New client config found!\"\n\n# Connect with the new certificate\necho \"Connecting with new '$REVOKE_CLIENT' certificate...\"\nrm -f /var/log/openvpn-new.log\nopenvpn --config \"/shared/$REVOKE_CLIENT-new.ovpn\" --daemon --log /var/log/openvpn-new.log\n\n# Wait for connection\necho \"Waiting for VPN connection with new certificate...\"\nwhile ! ip addr show tun0 2>/dev/null | grep -q \"inet \"; do\n\tsleep 2\n\techo \"Waiting for tun0...\"\n\tif [ -f /var/log/openvpn-new.log ]; then\n\t\ttail -3 /var/log/openvpn-new.log\n\tfi\ndone\n\necho \"PASS: Connected with new '$REVOKE_CLIENT' certificate\"\nip addr show tun0\n\n# Verify connectivity (retries indefinitely, relies on job timeout)\nwhile ! ping -c 3 -W 2 \"$VPN_GATEWAY\" >/dev/null 2>&1; do\n\techo \"Ping failed, retrying...\"\n\tsleep 3\ndone\necho \"PASS: Can ping VPN gateway with new certificate\"\n\n# Signal server that we connected with new cert\ntouch /shared/new-client-connected\n\necho \"\"\necho \"=== Certificate Revocation E2E Tests PASSED ===\"\n\n# =====================================================\n# Test PASSPHRASE-protected client connection\n# =====================================================\necho \"\"\necho \"=== Testing PASSPHRASE-protected Client Connection ===\"\n\nPASSPHRASE_CLIENT=\"passphrasetest\"\n\n# Wait for passphrase test client config\necho \"Waiting for passphrase test client config...\"\nwhile [ ! -f /shared/passphrase-client-config-ready ]; do\n\tsleep 2\n\techo \"Waiting for passphrase test config...\"\ndone\n\nif [ ! -f \"/shared/$PASSPHRASE_CLIENT.ovpn\" ]; then\n\techo \"FAIL: Passphrase test client config file not found\"\n\texit 1\nfi\n\nif [ ! -f \"/shared/$PASSPHRASE_CLIENT.pass\" ]; then\n\techo \"FAIL: Passphrase file not found\"\n\texit 1\nfi\n\necho \"Passphrase test client config found!\"\n\n# Disconnect current VPN before connecting with passphrase client\necho \"Disconnecting current VPN connection...\"\npkill openvpn || true\nsleep 2\n\n# Connect with passphrase-protected client using --askpass\necho \"Connecting with '$PASSPHRASE_CLIENT' certificate (passphrase-protected)...\"\nopenvpn --config \"/shared/$PASSPHRASE_CLIENT.ovpn\" --askpass \"/shared/$PASSPHRASE_CLIENT.pass\" --daemon --log /var/log/openvpn-passphrase.log\n\n# Wait for connection\necho \"Waiting for VPN connection with passphrase-protected client...\"\nwhile ! ip addr show tun0 2>/dev/null | grep -q \"inet \"; do\n\tsleep 2\n\techo \"Waiting for tun0...\"\n\tif [ -f /var/log/openvpn-passphrase.log ]; then\n\t\ttail -3 /var/log/openvpn-passphrase.log\n\tfi\ndone\n\necho \"PASS: Connected with passphrase-protected '$PASSPHRASE_CLIENT' certificate\"\nip addr show tun0\n\n# Verify connectivity (retries indefinitely, relies on job timeout)\nwhile ! ping -c 3 -W 2 \"$VPN_GATEWAY\" >/dev/null 2>&1; do\n\techo \"Ping failed, retrying...\"\n\tsleep 3\ndone\necho \"PASS: Can ping VPN gateway with passphrase-protected client\"\n\n# Signal server that we connected with passphrase client\ntouch /shared/passphrase-client-connected\n\necho \"\"\necho \"=== PASSPHRASE-protected Client Tests PASSED ===\"\n\necho \"\"\necho \"==========================================\"\necho \"  ALL TESTS PASSED!\"\necho \"==========================================\"\n\n# Keep container running for debugging if needed\nexec tail -f /var/log/openvpn-new.log 2>/dev/null || tail -f /var/log/openvpn.log 2>/dev/null || sleep infinity\n"
  },
  {
    "path": "test/server-entrypoint.sh",
    "content": "#!/bin/bash\nset -e\n\necho \"=== OpenVPN Server Container ===\"\n\n# Create TUN device if it doesn't exist\nif [ ! -c /dev/net/tun ]; then\n\tmkdir -p /dev/net\n\tmknod /dev/net/tun c 10 200\n\tchmod 600 /dev/net/tun\nfi\n\necho \"TUN device ready\"\n\n# Configuration for install\nexport FORCE_COLOR=1\nVPN_SUBNET_IPV4=10.9.0.0 # Custom subnet to test configurability\n\n# Calculate VPN gateway from subnet (first usable IP)\nVPN_GATEWAY=\"${VPN_SUBNET_IPV4%.*}.1\"\nexport VPN_GATEWAY\n\n# IPv6 configuration (optional)\n# CLIENT_IPV6: y/n to enable IPv6 for VPN clients\n# VPN_SUBNET_IPV6: IPv6 subnet (ULA prefix, e.g., fd42:42:42:42::)\nCLIENT_IPV6=\"${CLIENT_IPV6:-n}\"\nVPN_SUBNET_IPV6=\"${VPN_SUBNET_IPV6:-fd42:42:42:42::}\"\n\n# Calculate IPv6 gateway from subnet\nVPN_GATEWAY_IPV6=\"${VPN_SUBNET_IPV6}1\"\nexport VPN_GATEWAY_IPV6\n\n# TLS key type configuration (default: tls-crypt-v2)\n# TLS_SIG: crypt-v2, crypt, auth\n# TLS_KEY_FILE: the expected key file name for verification\nTLS_SIG=\"${TLS_SIG:-crypt-v2}\"\nTLS_KEY_FILE=\"${TLS_KEY_FILE:-tls-crypt-v2.key}\"\n\n# TLS 1.3 configuration\n# TLS_VERSION_MIN: 1.2 or 1.3\n# TLS13_CIPHERSUITES: colon-separated list of TLS 1.3 cipher suites\nTLS_VERSION_MIN=\"${TLS_VERSION_MIN:-1.2}\"\nTLS13_CIPHERSUITES=\"${TLS13_CIPHERSUITES:-TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256}\"\n\n# Authentication mode configuration\n# AUTH_MODE: pki (default, CA-based) or fingerprint (peer-fingerprint, OpenVPN 2.6+)\nAUTH_MODE=\"${AUTH_MODE:-pki}\"\n\n# Build install command with CLI flags (using array for proper quoting)\nINSTALL_CMD=(/opt/openvpn-install.sh install)\nINSTALL_CMD+=(--endpoint openvpn-server)\nINSTALL_CMD+=(--dns unbound)\nINSTALL_CMD+=(--subnet-ipv4 \"$VPN_SUBNET_IPV4\")\nINSTALL_CMD+=(--mtu 1400)\nINSTALL_CMD+=(--client testclient)\n\n# Add IPv6 client support if enabled\nif [ \"$CLIENT_IPV6\" = \"y\" ]; then\n\tINSTALL_CMD+=(--client-ipv6)\n\tINSTALL_CMD+=(--subnet-ipv6 \"$VPN_SUBNET_IPV6\")\n\techo \"Testing with IPv6 client support enabled (subnet: $VPN_SUBNET_IPV6)\"\nfi\n\n# Add TLS signature mode if non-default\nif [ \"$TLS_SIG\" != \"crypt-v2\" ]; then\n\tINSTALL_CMD+=(--tls-sig \"$TLS_SIG\")\n\techo \"Testing TLS key type: $TLS_SIG (key file: $TLS_KEY_FILE)\"\nfi\n\n# Add TLS version if non-default\nif [ \"$TLS_VERSION_MIN\" != \"1.2\" ]; then\n\tINSTALL_CMD+=(--tls-version-min \"$TLS_VERSION_MIN\")\n\techo \"Testing TLS version min: $TLS_VERSION_MIN\"\nfi\n\n# Add TLS 1.3 ciphersuites if non-default\nif [ \"$TLS13_CIPHERSUITES\" != \"TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256\" ]; then\n\tINSTALL_CMD+=(--tls-ciphersuites \"$TLS13_CIPHERSUITES\")\n\techo \"Testing TLS 1.3 ciphersuites: $TLS13_CIPHERSUITES\"\nfi\n\n# Add auth mode if non-default\nif [ \"$AUTH_MODE\" != \"pki\" ]; then\n\tINSTALL_CMD+=(--auth-mode \"$AUTH_MODE\")\n\techo \"Testing authentication mode: $AUTH_MODE\"\nfi\n\necho \"Running OpenVPN install script...\"\necho \"Command: ${INSTALL_CMD[*]}\"\n# Run in subshell because the script calls 'exit 0' after generating client config\n# Capture output to validate logging format, while still displaying it\n# Use || true to prevent set -e from exiting on failure, then check exit code\nINSTALL_OUTPUT=\"/tmp/install-output.log\"\n(\"${INSTALL_CMD[@]}\") 2>&1 | tee \"$INSTALL_OUTPUT\"\nINSTALL_EXIT_CODE=${PIPESTATUS[0]}\n\necho \"=== Installation complete (exit code: $INSTALL_EXIT_CODE) ===\"\n\n# Validate that all output uses proper logging format (ANSI color codes)\necho \"Validating output format...\"\nif /opt/test/validate-output.sh \"$INSTALL_OUTPUT\"; then\n\techo \"PASS: All script output uses proper log formatting\"\nelse\n\techo \"FAIL: Script output contains unformatted lines\"\n\techo \"This indicates echo statements that should use log_* functions\"\n\texit 1\nfi\n\nif [ \"$INSTALL_EXIT_CODE\" -ne 0 ]; then\n\techo \"ERROR: Install script failed with exit code $INSTALL_EXIT_CODE\"\n\texit 1\nfi\n\n# Verify all expected files were created\necho \"Verifying installation...\"\nMISSING_FILES=0\n# Build list of required files based on auth mode\nREQUIRED_FILES=(\n\t/etc/openvpn/server/server.conf\n\t\"/etc/openvpn/server/$TLS_KEY_FILE\"\n\t/root/testclient.ovpn\n)\nif [ \"$AUTH_MODE\" = \"pki\" ]; then\n\t# PKI mode requires CA and CRL files\n\tREQUIRED_FILES+=(\n\t\t/etc/openvpn/server/ca.crt\n\t\t/etc/openvpn/server/ca.key\n\t\t/etc/openvpn/server/crl.pem\n\t\t/etc/openvpn/server/easy-rsa/pki/ca.crt\n\t)\nelse\n\t# Fingerprint mode requires server fingerprint file\n\tREQUIRED_FILES+=(\n\t\t/etc/openvpn/server/server-fingerprint\n\t)\nfi\n# Only check for iptables script if firewalld and nftables are not active\nif ! systemctl is-active --quiet firewalld && ! systemctl is-active --quiet nftables; then\n\tREQUIRED_FILES+=(/etc/iptables/add-openvpn-rules.sh)\nelif systemctl is-active --quiet nftables; then\n\tREQUIRED_FILES+=(/etc/nftables/openvpn.nft)\nfi\n\nfor f in \"${REQUIRED_FILES[@]}\"; do\n\tif [ ! -f \"$f\" ]; then\n\t\techo \"ERROR: Missing file: $f\"\n\t\tMISSING_FILES=$((MISSING_FILES + 1))\n\tfi\ndone\n\nif [ $MISSING_FILES -gt 0 ]; then\n\techo \"ERROR: $MISSING_FILES required files are missing\"\n\texit 1\nfi\n\necho \"All required files present\"\n\n# =====================================================\n# Verify management interface configuration\n# =====================================================\necho \"\"\necho \"=== Verifying Management Interface Configuration ===\"\n\n# Verify management socket is configured in server.conf\nif grep -q \"management /var/run/openvpn-server/server.sock unix\" /etc/openvpn/server/server.conf; then\n\techo \"PASS: Management interface configured in server.conf\"\nelse\n\techo \"FAIL: Management interface not found in server.conf\"\n\tgrep \"management\" /etc/openvpn/server/server.conf || echo \"No management directive found\"\n\texit 1\nfi\n\n# Verify management socket directory exists\nif [ -d /var/run/openvpn-server ]; then\n\techo \"PASS: Management socket directory exists\"\nelse\n\techo \"FAIL: Management socket directory /var/run/openvpn-server not found\"\n\texit 1\nfi\n\n# Verify socat is available (needed for management interface communication)\nif command -v socat >/dev/null 2>&1; then\n\techo \"PASS: socat is available\"\nelse\n\techo \"FAIL: socat is not installed (required for management interface)\"\n\texit 1\nfi\n\necho \"=== Management Interface Configuration Verified ===\"\n\n# =====================================================\n# Test duplicate client name handling\n# =====================================================\necho \"\"\necho \"=== Testing Duplicate Client Name Handling ===\"\nDUPLICATE_CLIENT=\"testclient\"\nDUPLICATE_OUTPUT=\"/tmp/duplicate-client-output.log\"\n(bash /opt/openvpn-install.sh client add \"$DUPLICATE_CLIENT\" --cert-days 3650) 2>&1 | tee \"$DUPLICATE_OUTPUT\" || true\nDUPLICATE_EXIT_CODE=${PIPESTATUS[0]}\n\nif [ \"$DUPLICATE_EXIT_CODE\" -ne 1 ]; then\n\techo \"FAIL: Expected exit code 1 for duplicate client name, got $DUPLICATE_EXIT_CODE\"\n\tcat \"$DUPLICATE_OUTPUT\"\n\texit 1\nfi\nif grep -q \"The specified client CN was already found\" \"$DUPLICATE_OUTPUT\"; then\n\techo \"PASS: Duplicate client name correctly rejected with exit code 1\"\nelse\n\techo \"FAIL: Expected error message for duplicate client name not found\"\n\tcat \"$DUPLICATE_OUTPUT\"\n\texit 1\nfi\n\n# Copy client config to shared volume for initial connectivity tests\ncp /root/testclient.ovpn /shared/client.ovpn\nsed -i 's/^remote .*/remote openvpn-server 1194/' /shared/client.ovpn\necho \"Client config copied to /shared/client.ovpn\"\n\n# Write VPN network info to shared volume for client tests\n{\n\techo \"VPN_SUBNET_IPV4=$VPN_SUBNET_IPV4\"\n\techo \"VPN_GATEWAY=$VPN_GATEWAY\"\n\techo \"CLIENT_IPV6=$CLIENT_IPV6\"\n\techo \"AUTH_MODE=$AUTH_MODE\"\n\tif [ \"$CLIENT_IPV6\" = \"y\" ]; then\n\t\techo \"VPN_SUBNET_IPV6=$VPN_SUBNET_IPV6\"\n\t\techo \"VPN_GATEWAY_IPV6=$VPN_GATEWAY_IPV6\"\n\tfi\n} >/shared/vpn-config.env\necho \"VPN config written to /shared/vpn-config.env\"\n\n# =====================================================\n# Verify systemd service file configuration\n# =====================================================\necho \"\"\necho \"=== Verifying systemd service configuration ===\"\n\n# Check that the correct service file was created\nSERVICE_FILE=\"/etc/systemd/system/openvpn-server@.service\"\nif [ -f \"$SERVICE_FILE\" ]; then\n\techo \"PASS: openvpn-server@.service exists at $SERVICE_FILE\"\nelse\n\techo \"FAIL: openvpn-server@.service not found at $SERVICE_FILE\"\n\techo \"Contents of /etc/systemd/system/:\"\n\tfind /etc/systemd/system/ -maxdepth 1 -name '*openvpn*' -ls 2>/dev/null || echo \"No openvpn service files found\"\n\texit 1\nfi\n\n# Verify the service file points to /etc/openvpn/server/ (not patched back to /etc/openvpn/)\nif grep -q \"/etc/openvpn/server\" \"$SERVICE_FILE\"; then\n\techo \"PASS: Service file uses correct path /etc/openvpn/server/\"\nelse\n\techo \"FAIL: Service file does not reference /etc/openvpn/server/\"\n\techo \"Service file contents:\"\n\tcat \"$SERVICE_FILE\"\n\texit 1\nfi\n\n# Verify the service file syntax is valid (if systemd-analyze is available)\nif command -v systemd-analyze >/dev/null 2>&1; then\n\techo \"Validating service file syntax...\"\n\tif systemd-analyze verify \"$SERVICE_FILE\" 2>&1 | tee /tmp/service-verify.log; then\n\t\techo \"PASS: Service file syntax is valid\"\n\telse\n\t\t# systemd-analyze verify may return non-zero for warnings, check for actual errors\n\t\tif grep -qi \"error\" /tmp/service-verify.log; then\n\t\t\techo \"FAIL: Service file has syntax errors\"\n\t\t\tcat /tmp/service-verify.log\n\t\t\texit 1\n\t\telse\n\t\t\techo \"PASS: Service file syntax is valid (warnings only)\"\n\t\tfi\n\tfi\nelse\n\techo \"SKIP: systemd-analyze not available, skipping syntax validation\"\nfi\n\n# Verify the old service file pattern (openvpn@.service) was NOT created\nOLD_SERVICE_FILE=\"/etc/systemd/system/openvpn@.service\"\nif [ -f \"$OLD_SERVICE_FILE\" ]; then\n\techo \"FAIL: Legacy openvpn@.service was created (should use openvpn-server@.service)\"\n\texit 1\nelse\n\techo \"PASS: Legacy openvpn@.service not present (correct)\"\nfi\n\necho \"=== systemd service configuration verified ===\"\necho \"\"\n\n# =====================================================\n# Verify MTU configuration\n# =====================================================\necho \"=== Verifying MTU configuration ===\"\n\n# Verify MTU in server config\nif grep -q \"tun-mtu 1400\" /etc/openvpn/server/server.conf; then\n\techo \"PASS: Server config has tun-mtu 1400\"\nelse\n\techo \"FAIL: Server config missing tun-mtu 1400\"\n\tgrep \"tun-mtu\" /etc/openvpn/server/server.conf || echo \"No tun-mtu directive found\"\n\texit 1\nfi\n\n# Verify MTU in client template\nif grep -q \"tun-mtu 1400\" /etc/openvpn/server/client-template.txt; then\n\techo \"PASS: Client template has tun-mtu 1400\"\nelse\n\techo \"FAIL: Client template missing tun-mtu 1400\"\n\tgrep \"tun-mtu\" /etc/openvpn/server/client-template.txt || echo \"No tun-mtu directive found\"\n\texit 1\nfi\n\necho \"=== MTU configuration verified ===\"\necho \"\"\necho \"Server config:\"\ncat /etc/openvpn/server/server.conf\n\n# =====================================================\n# Verify TLS 1.3 configuration\n# =====================================================\necho \"\"\necho \"=== Verifying TLS 1.3 Configuration ===\"\n\n# Verify tls-version-min is set correctly\nif grep -q \"tls-version-min $TLS_VERSION_MIN\" /etc/openvpn/server/server.conf; then\n\techo \"PASS: tls-version-min is set to $TLS_VERSION_MIN\"\nelse\n\techo \"FAIL: tls-version-min is not set correctly\"\n\tgrep \"tls-version-min\" /etc/openvpn/server/server.conf || echo \"tls-version-min not found\"\n\texit 1\nfi\n\n# Verify tls-ciphersuites is set\nif grep -q \"tls-ciphersuites $TLS13_CIPHERSUITES\" /etc/openvpn/server/server.conf; then\n\techo \"PASS: tls-ciphersuites is configured correctly\"\nelse\n\techo \"FAIL: tls-ciphersuites is not configured correctly\"\n\tgrep \"tls-ciphersuites\" /etc/openvpn/server/server.conf || echo \"tls-ciphersuites not found\"\n\texit 1\nfi\n\n# Verify client template also has TLS 1.3 settings\nif grep -q \"tls-version-min $TLS_VERSION_MIN\" /etc/openvpn/server/client-template.txt; then\n\techo \"PASS: Client template has correct tls-version-min\"\nelse\n\techo \"FAIL: Client template missing tls-version-min\"\n\texit 1\nfi\n\nif grep -q \"tls-ciphersuites $TLS13_CIPHERSUITES\" /etc/openvpn/server/client-template.txt; then\n\techo \"PASS: Client template has correct tls-ciphersuites\"\nelse\n\techo \"FAIL: Client template missing tls-ciphersuites\"\n\texit 1\nfi\n\necho \"=== TLS 1.3 Configuration Verified ===\"\n\n# =====================================================\n# Wait for initial client tests to complete\n# =====================================================\necho \"\"\necho \"=== Waiting for initial client connectivity tests ===\"\nwhile [ ! -f /shared/initial-tests-passed ]; do\n\tsleep 2\n\techo \"Waiting for initial tests...\"\ndone\necho \"Initial client tests passed, proceeding with renewal tests\"\n\n# =====================================================\n# Test certificate renewal functionality\n# =====================================================\necho \"\"\necho \"=== Testing Certificate Renewal ===\"\n\n# Get the original certificate serial number for comparison\nORIG_CERT_SERIAL=$(openssl x509 -in /etc/openvpn/server/easy-rsa/pki/issued/testclient.crt -noout -serial | cut -d= -f2)\necho \"Original client certificate serial: $ORIG_CERT_SERIAL\"\n\n# Test client certificate renewal using the script\necho \"Testing client certificate renewal...\"\nRENEW_OUTPUT=\"/tmp/renew-client-output.log\"\n(bash /opt/openvpn-install.sh client renew testclient --cert-days 3650) 2>&1 | tee \"$RENEW_OUTPUT\" || true\n\n# Verify renewal succeeded\nif grep -q \"Certificate for client testclient renewed\" \"$RENEW_OUTPUT\"; then\n\techo \"PASS: Client renewal completed successfully\"\nelse\n\techo \"FAIL: Client renewal did not complete\"\n\tcat \"$RENEW_OUTPUT\"\n\texit 1\nfi\n\n# Verify new certificate has different serial\nNEW_CERT_SERIAL=$(openssl x509 -in /etc/openvpn/server/easy-rsa/pki/issued/testclient.crt -noout -serial | cut -d= -f2)\necho \"New client certificate serial: $NEW_CERT_SERIAL\"\nif [ \"$ORIG_CERT_SERIAL\" != \"$NEW_CERT_SERIAL\" ]; then\n\techo \"PASS: Certificate serial changed (renewal created new cert)\"\nelse\n\techo \"FAIL: Certificate serial unchanged\"\n\texit 1\nfi\n\n# Verify renewed certificate has correct validity period\n# The default is 3650 days, so the cert should be valid for ~10 years from now\nCLIENT_CERT_NOT_AFTER=$(openssl x509 -in /etc/openvpn/server/easy-rsa/pki/issued/testclient.crt -noout -enddate | cut -d= -f2)\nCLIENT_CERT_NOT_BEFORE=$(openssl x509 -in /etc/openvpn/server/easy-rsa/pki/issued/testclient.crt -noout -startdate | cut -d= -f2)\necho \"Client certificate valid from: $CLIENT_CERT_NOT_BEFORE\"\necho \"Client certificate valid until: $CLIENT_CERT_NOT_AFTER\"\n\n# Calculate days until expiry (should be close to 3650)\nCERT_END_EPOCH=$(date -d \"$CLIENT_CERT_NOT_AFTER\" +%s 2>/dev/null || date -j -f \"%b %d %T %Y %Z\" \"$CLIENT_CERT_NOT_AFTER\" +%s 2>/dev/null)\nNOW_EPOCH=$(date +%s)\nDAYS_VALID_ACTUAL=$(((CERT_END_EPOCH - NOW_EPOCH) / 86400))\necho \"Client certificate validity: $DAYS_VALID_ACTUAL days\"\n\n# Should be between 3640 and 3650 days (allowing some tolerance for timing)\nif [ \"$DAYS_VALID_ACTUAL\" -ge 3640 ] && [ \"$DAYS_VALID_ACTUAL\" -le 3650 ]; then\n\techo \"PASS: Client certificate validity is correct (~3650 days)\"\nelse\n\techo \"FAIL: Client certificate validity is unexpected: $DAYS_VALID_ACTUAL days (expected ~3650)\"\n\texit 1\nfi\n\n# Verify new .ovpn file was generated\nif [ -f /root/testclient.ovpn ]; then\n\techo \"PASS: New .ovpn file generated\"\nelse\n\techo \"FAIL: .ovpn file not found after renewal\"\n\texit 1\nfi\n\n# Verify CRL was updated (PKI mode only)\nif [ \"$AUTH_MODE\" = \"pki\" ]; then\n\tif [ -f /etc/openvpn/server/crl.pem ]; then\n\t\techo \"PASS: CRL file exists\"\n\telse\n\t\techo \"FAIL: CRL file missing after renewal\"\n\t\texit 1\n\tfi\nfi\n\necho \"=== Client Certificate Renewal Tests PASSED ===\"\n\n# =====================================================\n# Test server certificate renewal\n# =====================================================\necho \"\"\necho \"=== Testing Server Certificate Renewal ===\"\n\n# Get server certificate name and original serial (extract basename since path may be relative)\nSERVER_NAME=$(basename \"$(grep '^cert ' /etc/openvpn/server/server.conf | cut -d ' ' -f 2)\" .crt)\nORIG_SERVER_SERIAL=$(openssl x509 -in \"/etc/openvpn/server/easy-rsa/pki/issued/$SERVER_NAME.crt\" -noout -serial | cut -d= -f2)\necho \"Server certificate: $SERVER_NAME\"\necho \"Original server certificate serial: $ORIG_SERVER_SERIAL\"\n\n# Test server certificate renewal\necho \"Testing server certificate renewal...\"\nRENEW_SERVER_OUTPUT=\"/tmp/renew-server-output.log\"\n(bash /opt/openvpn-install.sh server renew --cert-days 3650 --force) 2>&1 | tee \"$RENEW_SERVER_OUTPUT\" || true\n\n# Verify renewal succeeded\nif grep -q \"Server certificate renewed successfully\" \"$RENEW_SERVER_OUTPUT\"; then\n\techo \"PASS: Server renewal completed successfully\"\nelse\n\techo \"FAIL: Server renewal did not complete\"\n\tcat \"$RENEW_SERVER_OUTPUT\"\n\texit 1\nfi\n\n# Verify new certificate has different serial\nNEW_SERVER_SERIAL=$(openssl x509 -in \"/etc/openvpn/server/easy-rsa/pki/issued/$SERVER_NAME.crt\" -noout -serial | cut -d= -f2)\necho \"New server certificate serial: $NEW_SERVER_SERIAL\"\nif [ \"$ORIG_SERVER_SERIAL\" != \"$NEW_SERVER_SERIAL\" ]; then\n\techo \"PASS: Server certificate serial changed (renewal created new cert)\"\nelse\n\techo \"FAIL: Server certificate serial unchanged\"\n\texit 1\nfi\n\n# Verify renewed server certificate has correct validity period\nSERVER_CERT_NOT_AFTER=$(openssl x509 -in \"/etc/openvpn/server/easy-rsa/pki/issued/$SERVER_NAME.crt\" -noout -enddate | cut -d= -f2)\nSERVER_CERT_NOT_BEFORE=$(openssl x509 -in \"/etc/openvpn/server/easy-rsa/pki/issued/$SERVER_NAME.crt\" -noout -startdate | cut -d= -f2)\necho \"Server certificate valid from: $SERVER_CERT_NOT_BEFORE\"\necho \"Server certificate valid until: $SERVER_CERT_NOT_AFTER\"\n\n# Calculate days until expiry (should be close to 3650)\nSERVER_END_EPOCH=$(date -d \"$SERVER_CERT_NOT_AFTER\" +%s 2>/dev/null || date -j -f \"%b %d %T %Y %Z\" \"$SERVER_CERT_NOT_AFTER\" +%s 2>/dev/null)\nSERVER_DAYS_VALID=$(((SERVER_END_EPOCH - NOW_EPOCH) / 86400))\necho \"Server certificate validity: $SERVER_DAYS_VALID days\"\n\nif [ \"$SERVER_DAYS_VALID\" -ge 3640 ] && [ \"$SERVER_DAYS_VALID\" -le 3650 ]; then\n\techo \"PASS: Server certificate validity is correct (~3650 days)\"\nelse\n\techo \"FAIL: Server certificate validity is unexpected: $SERVER_DAYS_VALID days (expected ~3650)\"\n\texit 1\nfi\n\n# Verify the new certificate was copied to /etc/openvpn/server/\nif [ -f \"/etc/openvpn/server/$SERVER_NAME.crt\" ]; then\n\tDEPLOYED_SERIAL=$(openssl x509 -in \"/etc/openvpn/server/$SERVER_NAME.crt\" -noout -serial | cut -d= -f2)\n\tif [ \"$NEW_SERVER_SERIAL\" = \"$DEPLOYED_SERIAL\" ]; then\n\t\techo \"PASS: New server certificate deployed to /etc/openvpn/server/\"\n\telse\n\t\techo \"FAIL: Deployed certificate doesn't match renewed certificate\"\n\t\texit 1\n\tfi\nelse\n\techo \"FAIL: Server certificate not found in /etc/openvpn/server/\"\n\texit 1\nfi\n\necho \"=== Server Certificate Renewal Tests PASSED ===\"\necho \"\"\necho \"=== All Certificate Renewal Tests PASSED ===\"\necho \"\"\n\n# Wait for OpenVPN to be fully ready after server certificate renewal\n# The renewal process restarts OpenVPN, so we need to verify it's back up\necho \"Verifying OpenVPN is running after certificate renewal...\"\nfor _ in $(seq 1 30); do\n\tif pgrep -f \"openvpn.*server.conf\" >/dev/null; then\n\t\tbreak\n\tfi\n\tsleep 1\ndone\n\nif ! pgrep -f \"openvpn.*server.conf\" >/dev/null; then\n\techo \"FAIL: OpenVPN not running after server certificate renewal\"\n\tsystemctl status openvpn-server@server 2>&1 || true\n\texit 1\nfi\n\n# Wait for tun0 to be ready after restart\necho \"Waiting for tun0 to be ready after certificate renewal...\"\nfor i in $(seq 1 30); do\n\tif ip addr show tun0 2>/dev/null | grep -q \"inet $VPN_GATEWAY\"; then\n\t\techo \"OpenVPN tun0 interface ready after renewal\"\n\t\tbreak\n\tfi\n\tsleep 1\ndone\n\n# Allow routing to stabilize after renewal restart\nsleep 3\n\ncp /root/testclient.ovpn /shared/client.ovpn\nsed -i 's/^remote .*/remote openvpn-server 1194/' /shared/client.ovpn\ntouch /shared/renewal-config-ready\necho \"Updated client config with renewed certificates\"\n\n# =====================================================\n# Wait for post-renewal client connectivity tests\n# =====================================================\necho \"\"\necho \"=== Waiting for post-renewal client connectivity tests ===\"\nwhile [ ! -f /shared/renewal-tests-passed ]; do\n\tsleep 2\n\techo \"Waiting for renewal tests...\"\ndone\necho \"Post-renewal client tests passed\"\n\n# =====================================================\n# Verify Unbound DNS resolver (started by systemd via install script)\n# =====================================================\necho \"=== Verifying Unbound DNS Resolver ===\"\n\nif [ -f /etc/unbound/unbound.conf ]; then\n\t# Verify Unbound is running (started by systemctl in install script)\n\techo \"Checking Unbound service status...\"\n\tfor _ in $(seq 1 30); do\n\t\tif pgrep -x unbound >/dev/null; then\n\t\t\techo \"PASS: Unbound is running\"\n\t\t\tbreak\n\t\tfi\n\t\tsleep 1\n\tdone\n\tif ! pgrep -x unbound >/dev/null; then\n\t\techo \"FAIL: Unbound is not running\"\n\t\tsystemctl status unbound 2>&1 || true\n\t\tjournalctl -u unbound --no-pager -n 50 2>&1 || true\n\t\texit 1\n\tfi\nelse\n\techo \"FAIL: /etc/unbound/unbound.conf not found\"\n\texit 1\nfi\n\necho \"\"\necho \"=== Verifying Unbound Installation ===\"\n\n# Verify Unbound config exists in conf.d directory\nUNBOUND_OPENVPN_CONF=\"/etc/unbound/unbound.conf.d/openvpn.conf\"\nif [ -f \"$UNBOUND_OPENVPN_CONF\" ]; then\n\techo \"PASS: Found Unbound config at $UNBOUND_OPENVPN_CONF\"\nelse\n\techo \"FAIL: OpenVPN Unbound config not found at $UNBOUND_OPENVPN_CONF\"\n\techo \"Contents of /etc/unbound/:\"\n\tls -la /etc/unbound/\n\tls -la /etc/unbound/unbound.conf.d/ 2>/dev/null || true\n\texit 1\nfi\n\n# Verify Unbound listens on VPN gateway\nif grep -q \"interface: $VPN_GATEWAY\" \"$UNBOUND_OPENVPN_CONF\"; then\n\techo \"PASS: Unbound configured to listen on $VPN_GATEWAY\"\nelse\n\techo \"FAIL: Unbound not configured for $VPN_GATEWAY\"\n\tcat \"$UNBOUND_OPENVPN_CONF\"\n\texit 1\nfi\n\n# Verify OpenVPN pushes correct DNS\nif grep -q \"push \\\"dhcp-option DNS $VPN_GATEWAY\\\"\" /etc/openvpn/server/server.conf; then\n\techo \"PASS: OpenVPN configured to push Unbound DNS\"\nelse\n\techo \"FAIL: OpenVPN not configured to push Unbound DNS\"\n\tgrep \"dhcp-option DNS\" /etc/openvpn/server/server.conf || echo \"No DNS push found\"\n\texit 1\nfi\n\necho \"=== Unbound Installation Verified ===\"\necho \"\"\n\n# Verify OpenVPN server (started by systemd via install script)\necho \"Verifying OpenVPN server...\"\n\n# Verify firewall rules exist\necho \"Verifying firewall rules...\"\nif systemctl is-active --quiet firewalld; then\n\t# firewalld is active - verify masquerade is enabled\n\techo \"firewalld detected, checking masquerade...\"\n\tfor _ in $(seq 1 10); do\n\t\tif firewall-cmd --query-masquerade 2>/dev/null; then\n\t\t\techo \"PASS: firewalld masquerade is enabled\"\n\t\t\tbreak\n\t\tfi\n\t\tsleep 1\n\tdone\n\tif ! firewall-cmd --query-masquerade 2>/dev/null; then\n\t\techo \"FAIL: firewalld masquerade is not enabled\"\n\t\techo \"Current firewalld config:\"\n\t\tfirewall-cmd --list-all 2>&1 || true\n\t\texit 1\n\tfi\n\t# Verify port is open\n\tif firewall-cmd --list-ports | grep -q \"1194/udp\"; then\n\t\techo \"PASS: OpenVPN port is open in firewalld\"\n\telse\n\t\techo \"FAIL: OpenVPN port not found in firewalld\"\n\t\tfirewall-cmd --list-ports\n\t\texit 1\n\tfi\n\t# Verify VPN subnet rich rule exists (source-based rules work reliably across firewalld backends)\n\tif firewall-cmd --list-rich-rules | grep -q \"source address=\\\"$VPN_SUBNET_IPV4/24\\\"\"; then\n\t\techo \"PASS: VPN subnet rich rule is configured\"\n\telse\n\t\techo \"FAIL: VPN subnet rich rule not found in firewalld\"\n\t\techo \"Current rich rules:\"\n\t\tfirewall-cmd --list-rich-rules\n\t\texit 1\n\tfi\nelif systemctl is-active --quiet nftables; then\n\t# nftables mode - verify OpenVPN tables exist\n\techo \"nftables detected, checking OpenVPN tables...\"\n\tfor _ in $(seq 1 10); do\n\t\tif nft list table inet openvpn >/dev/null 2>&1; then\n\t\t\techo \"PASS: nftables 'inet openvpn' table exists\"\n\t\t\tbreak\n\t\tfi\n\t\tsleep 1\n\tdone\n\tif ! nft list table inet openvpn >/dev/null 2>&1; then\n\t\techo \"FAIL: nftables 'inet openvpn' table not found\"\n\t\techo \"Current nftables ruleset:\"\n\t\tnft list ruleset 2>&1 || true\n\t\texit 1\n\tfi\n\t# Verify NAT table exists\n\tif nft list table ip openvpn-nat >/dev/null 2>&1; then\n\t\techo \"PASS: nftables 'ip openvpn-nat' table exists\"\n\telse\n\t\techo \"FAIL: nftables 'ip openvpn-nat' table not found\"\n\t\tnft list ruleset 2>&1 || true\n\t\texit 1\n\tfi\n\t# Verify masquerade rule exists\n\tif nft list table ip openvpn-nat | grep -q \"masquerade\"; then\n\t\techo \"PASS: nftables masquerade rule exists\"\n\telse\n\t\techo \"FAIL: nftables masquerade rule not found\"\n\t\tnft list table ip openvpn-nat 2>&1 || true\n\t\texit 1\n\tfi\n\t# Verify include in nftables.conf\n\tif grep -q 'include.*/etc/nftables/openvpn.nft' /etc/nftables.conf; then\n\t\techo \"PASS: OpenVPN rules included in nftables.conf\"\n\telse\n\t\techo \"FAIL: OpenVPN rules not included in nftables.conf\"\n\t\tcat /etc/nftables.conf 2>&1 || true\n\t\texit 1\n\tfi\nelse\n\t# iptables mode - verify NAT rules\n\techo \"iptables mode, checking NAT rules...\"\n\tfor _ in $(seq 1 10); do\n\t\tif iptables -t nat -L POSTROUTING -n | grep -q \"$VPN_SUBNET_IPV4\"; then\n\t\t\techo \"PASS: NAT POSTROUTING rule for $VPN_SUBNET_IPV4/24 exists\"\n\t\t\tbreak\n\t\tfi\n\t\tsleep 1\n\tdone\n\tif ! iptables -t nat -L POSTROUTING -n | grep -q \"$VPN_SUBNET_IPV4\"; then\n\t\techo \"FAIL: NAT POSTROUTING rule for $VPN_SUBNET_IPV4/24 not found\"\n\t\techo \"Current NAT rules:\"\n\t\tiptables -t nat -L POSTROUTING -n -v\n\t\tsystemctl status iptables-openvpn 2>&1 || true\n\t\texit 1\n\tfi\nfi\n\n# Verify IP forwarding is enabled\nif [ \"$(cat /proc/sys/net/ipv4/ip_forward)\" != \"1\" ]; then\n\techo \"ERROR: IP forwarding is not enabled\"\n\texit 1\nfi\n\n# Wait for OpenVPN to start (started by systemctl in install script)\necho \"Waiting for OpenVPN server to start...\"\nfor _ in $(seq 1 30); do\n\tif pgrep -f \"openvpn.*server.conf\" >/dev/null; then\n\t\techo \"PASS: OpenVPN server is running\"\n\t\tbreak\n\tfi\n\tsleep 1\ndone\n\nif ! pgrep -f \"openvpn.*server.conf\" >/dev/null; then\n\techo \"FAIL: OpenVPN server is not running\"\n\tsystemctl status openvpn-server@server 2>&1 || true\n\tjournalctl -u openvpn-server@server --no-pager -n 50 2>&1 || true\n\texit 1\nfi\n\n# Wait for server tun interface to be ready with correct IP\n# This prevents race conditions where OpenVPN is running but tun0 isn't configured\necho \"Waiting for server tun0 interface to be ready...\"\nTUN_READY=false\nfor i in $(seq 1 30); do\n\tif ip addr show tun0 2>/dev/null | grep -q \"inet $VPN_GATEWAY\"; then\n\t\techo \"PASS: Server tun0 interface ready with $VPN_GATEWAY\"\n\t\tTUN_READY=true\n\t\tbreak\n\tfi\n\techo \"Waiting for tun0... ($i/30)\"\n\tsleep 1\ndone\n\nif [ \"$TUN_READY\" = false ]; then\n\techo \"FAIL: Server tun0 interface not ready after 30 seconds\"\n\tip addr show 2>&1 || true\n\texit 1\nfi\n\n# Allow routing tables to stabilize\necho \"Allowing routing to stabilize...\"\nsleep 3\n\n# =====================================================\n# Test certificate revocation functionality\n# =====================================================\necho \"\"\necho \"=== Testing Certificate Revocation ===\"\n\n# Create a new client for revocation testing\nREVOKE_CLIENT=\"revoketest\"\necho \"Creating client '$REVOKE_CLIENT' for revocation testing...\"\nREVOKE_CREATE_OUTPUT=\"/tmp/revoke-create-output.log\"\n(bash /opt/openvpn-install.sh client add \"$REVOKE_CLIENT\" --cert-days 3650) 2>&1 | tee \"$REVOKE_CREATE_OUTPUT\" || true\n\nif [ -f \"/root/$REVOKE_CLIENT.ovpn\" ]; then\n\techo \"PASS: Client '$REVOKE_CLIENT' created successfully\"\nelse\n\techo \"FAIL: Failed to create client '$REVOKE_CLIENT'\"\n\tcat \"$REVOKE_CREATE_OUTPUT\"\n\texit 1\nfi\n\n# Copy config for revocation test client\ncp \"/root/$REVOKE_CLIENT.ovpn\" \"/shared/$REVOKE_CLIENT.ovpn\"\nsed -i 's/^remote .*/remote openvpn-server 1194/' \"/shared/$REVOKE_CLIENT.ovpn\"\necho \"Copied $REVOKE_CLIENT config to /shared/\"\n\n# Signal client that revoke test config is ready\ntouch /shared/revoke-client-config-ready\n\n# Wait for client to confirm connection with revoke test client\necho \"Waiting for client to connect with '$REVOKE_CLIENT' certificate...\"\nwhile [ ! -f /shared/revoke-client-connected ]; do\n\tsleep 2\n\techo \"Waiting for revoke test connection...\"\ndone\necho \"PASS: Client connected with '$REVOKE_CLIENT' certificate\"\n\n# =====================================================\n# Test server status command\n# =====================================================\necho \"\"\necho \"=== Testing Server Status ===\"\n\n# Note: OpenVPN status file updates periodically (default: 1 min)\n# so we just verify the command works, not that a specific client is visible\n\n# Test table output\nSTATUS_OUTPUT=\"/tmp/server-status-output.log\"\n(bash /opt/openvpn-install.sh server status) 2>&1 | tee \"$STATUS_OUTPUT\" || true\n\nif grep -q \"Connected Clients\" \"$STATUS_OUTPUT\"; then\n\techo \"PASS: Server status shows header\"\nelse\n\techo \"FAIL: Server status missing header\"\n\tcat \"$STATUS_OUTPUT\"\n\texit 1\nfi\n\n# Test JSON output\nSTATUS_JSON_OUTPUT=\"/tmp/server-status-json-output.log\"\n(bash /opt/openvpn-install.sh server status --format json) 2>&1 | tee \"$STATUS_JSON_OUTPUT\" || true\n\n# Validate JSON structure (clients array exists, even if empty)\nif jq -e '.clients' \"$STATUS_JSON_OUTPUT\" >/dev/null 2>&1; then\n\techo \"PASS: Server status JSON is valid\"\nelse\n\techo \"FAIL: Server status JSON is invalid\"\n\tcat \"$STATUS_JSON_OUTPUT\"\n\texit 1\nfi\n\necho \"=== Server Status Tests PASSED ===\"\n\n# Now revoke the certificate (this should auto-disconnect the client via management interface)\necho \"Revoking certificate for '$REVOKE_CLIENT' (should auto-disconnect client)...\"\nREVOKE_OUTPUT=\"/tmp/revoke-output.log\"\n(bash /opt/openvpn-install.sh client revoke \"$REVOKE_CLIENT\" --force) 2>&1 | tee \"$REVOKE_OUTPUT\" || true\n\nif grep -q \"Certificate for client $REVOKE_CLIENT revoked\" \"$REVOKE_OUTPUT\"; then\n\techo \"PASS: Certificate for '$REVOKE_CLIENT' revoked successfully\"\nelse\n\techo \"FAIL: Failed to revoke certificate\"\n\tcat \"$REVOKE_OUTPUT\"\n\texit 1\nfi\n\n# Verify revocation was applied correctly\nif [ \"$AUTH_MODE\" = \"pki\" ]; then\n\t# PKI mode: verify certificate is marked as revoked in index.txt\n\tif tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep -q \"^R.*CN=$REVOKE_CLIENT\\$\"; then\n\t\techo \"PASS: Certificate marked as revoked in index.txt\"\n\telse\n\t\techo \"FAIL: Certificate not marked as revoked\"\n\t\tcat /etc/openvpn/server/easy-rsa/pki/index.txt\n\t\texit 1\n\tfi\nelse\n\t# Fingerprint mode: verify fingerprint was removed from server.conf\n\tif ! grep -q \"# $REVOKE_CLIENT\\$\" /etc/openvpn/server/server.conf; then\n\t\techo \"PASS: Client fingerprint removed from server.conf\"\n\telse\n\t\techo \"FAIL: Client fingerprint still present in server.conf\"\n\t\tgrep \"$REVOKE_CLIENT\" /etc/openvpn/server/server.conf || true\n\t\texit 1\n\tfi\nfi\n\n# Wait for client to confirm it was disconnected by the revoke\necho \"Waiting for client to confirm auto-disconnect...\"\nDISCONNECT_WAIT=0\nwhile [ ! -f /shared/revoke-client-disconnected ] && [ $DISCONNECT_WAIT -lt 60 ]; do\n\tsleep 2\n\tDISCONNECT_WAIT=$((DISCONNECT_WAIT + 2))\n\techo \"Waiting for disconnect confirmation... ($DISCONNECT_WAIT/60s)\"\ndone\n\nif [ -f /shared/revoke-client-disconnected ]; then\n\techo \"PASS: Client was auto-disconnected by revoke command\"\nelse\n\techo \"FAIL: Client was not disconnected within 60 seconds\"\n\texit 1\nfi\n\n# Signal client to try reconnecting (should fail)\ntouch /shared/revoke-try-reconnect\n\n# Wait for client to confirm that connection with revoked cert failed\necho \"Waiting for client to confirm revoked cert connection failure...\"\nwhile [ ! -f /shared/revoke-reconnect-failed ]; do\n\tsleep 2\n\techo \"Waiting for reconnect failure confirmation...\"\ndone\necho \"PASS: Connection with revoked certificate correctly rejected\"\n\necho \"=== Certificate Revocation Tests PASSED ===\"\n\n# =====================================================\n# Test listing client certificates\n# =====================================================\necho \"\"\necho \"=== Testing List Client Certificates ===\"\n\n# At this point we have 3 client certificates:\n# - testclient (Valid) - the renewed certificate\n# - testclient (Revoked) - the old certificate revoked during renewal\n# - revoketest (Revoked) - the revoked certificate\nLIST_OUTPUT=\"/tmp/list-clients-output.log\"\n(bash /opt/openvpn-install.sh client list) 2>&1 | tee \"$LIST_OUTPUT\" || true\n\n# Verify list output contains expected clients\nif grep -q \"testclient\" \"$LIST_OUTPUT\" && grep -q \"Valid\" \"$LIST_OUTPUT\"; then\n\techo \"PASS: List shows testclient as Valid\"\nelse\n\techo \"FAIL: List does not show testclient correctly\"\n\tcat \"$LIST_OUTPUT\"\n\texit 1\nfi\n\nif grep -q \"$REVOKE_CLIENT\" \"$LIST_OUTPUT\" && grep -q \"Revoked\" \"$LIST_OUTPUT\"; then\n\techo \"PASS: List shows $REVOKE_CLIENT as Revoked\"\nelse\n\techo \"FAIL: List does not show $REVOKE_CLIENT correctly\"\n\tcat \"$LIST_OUTPUT\"\n\texit 1\nfi\n\n# Verify certificate count (varies by auth mode)\nif [ \"$AUTH_MODE\" = \"pki\" ]; then\n\t# PKI mode: 3 certs (testclient valid, testclient revoked from renewal, revoketest revoked)\n\tif grep -q \"Found 3 client certificate(s)\" \"$LIST_OUTPUT\"; then\n\t\techo \"PASS: List shows correct certificate count\"\n\telse\n\t\techo \"FAIL: List does not show correct certificate count\"\n\t\tcat \"$LIST_OUTPUT\"\n\t\texit 1\n\tfi\nelse\n\t# Fingerprint mode: 2 certs (testclient valid, revoketest revoked)\n\t# In fingerprint mode, renewal doesn't create a separate revoked entry\n\tif grep -q \"Found [23] client certificate(s)\" \"$LIST_OUTPUT\"; then\n\t\techo \"PASS: List shows correct certificate count for fingerprint mode\"\n\telse\n\t\techo \"FAIL: List does not show correct certificate count\"\n\t\tcat \"$LIST_OUTPUT\"\n\t\texit 1\n\tfi\nfi\n\n# Test JSON output\necho \"Testing client list JSON output...\"\nLIST_JSON_OUTPUT=\"/tmp/list-clients-json-output.log\"\n(bash /opt/openvpn-install.sh client list --format json) 2>&1 | tee \"$LIST_JSON_OUTPUT\" || true\n\n# Validate JSON structure\nif jq -e '.clients' \"$LIST_JSON_OUTPUT\" >/dev/null 2>&1; then\n\techo \"PASS: Client list JSON is valid\"\nelse\n\techo \"FAIL: Client list JSON is invalid\"\n\tcat \"$LIST_JSON_OUTPUT\"\n\texit 1\nfi\n\n# Verify client count in JSON (varies by auth mode)\nJSON_CLIENT_COUNT=$(jq '.clients | length' \"$LIST_JSON_OUTPUT\")\nif [ \"$AUTH_MODE\" = \"pki\" ]; then\n\tif [ \"$JSON_CLIENT_COUNT\" -eq 3 ]; then\n\t\techo \"PASS: Client list JSON has correct count ($JSON_CLIENT_COUNT)\"\n\telse\n\t\techo \"FAIL: Client list JSON has wrong count: $JSON_CLIENT_COUNT (expected 3)\"\n\t\tcat \"$LIST_JSON_OUTPUT\"\n\t\texit 1\n\tfi\nelse\n\t# Fingerprint mode may have fewer entries\n\tif [ \"$JSON_CLIENT_COUNT\" -ge 2 ] && [ \"$JSON_CLIENT_COUNT\" -le 3 ]; then\n\t\techo \"PASS: Client list JSON has correct count for fingerprint mode ($JSON_CLIENT_COUNT)\"\n\telse\n\t\techo \"FAIL: Client list JSON has wrong count: $JSON_CLIENT_COUNT (expected 2-3)\"\n\t\tcat \"$LIST_JSON_OUTPUT\"\n\t\texit 1\n\tfi\nfi\n\n# Verify valid client in JSON\nif jq -e '.clients[] | select(.name == \"testclient\" and .status == \"valid\")' \"$LIST_JSON_OUTPUT\" >/dev/null 2>&1; then\n\techo \"PASS: Client list JSON shows testclient as valid\"\nelse\n\techo \"FAIL: Client list JSON does not show testclient correctly\"\n\tcat \"$LIST_JSON_OUTPUT\"\n\texit 1\nfi\n\n# Verify revoked client in JSON\nif jq -e \".clients[] | select(.name == \\\"$REVOKE_CLIENT\\\" and .status == \\\"revoked\\\")\" \"$LIST_JSON_OUTPUT\" >/dev/null 2>&1; then\n\techo \"PASS: Client list JSON shows $REVOKE_CLIENT as revoked\"\nelse\n\techo \"FAIL: Client list JSON does not show $REVOKE_CLIENT correctly\"\n\tcat \"$LIST_JSON_OUTPUT\"\n\texit 1\nfi\n\necho \"=== List Client Certificates Tests PASSED ===\"\n\n# =====================================================\n# Test reusing revoked client name\n# =====================================================\necho \"\"\necho \"=== Testing Reuse of Revoked Client Name ===\"\n\n# Create a new certificate with the same name as the revoked one\necho \"Creating new client with same name '$REVOKE_CLIENT'...\"\nRECREATE_OUTPUT=\"/tmp/recreate-output.log\"\n(bash /opt/openvpn-install.sh client add \"$REVOKE_CLIENT\" --cert-days 3650) 2>&1 | tee \"$RECREATE_OUTPUT\" || true\n\nif [ -f \"/root/$REVOKE_CLIENT.ovpn\" ]; then\n\techo \"PASS: New client '$REVOKE_CLIENT' created successfully (reusing revoked name)\"\nelse\n\techo \"FAIL: Failed to create client with revoked name\"\n\tcat \"$RECREATE_OUTPUT\"\n\texit 1\nfi\n\n# Verify the new certificate is valid\nif [ \"$AUTH_MODE\" = \"pki\" ]; then\n\t# PKI mode: verify in index.txt\n\tif tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep -q \"^V.*CN=$REVOKE_CLIENT\\$\"; then\n\t\techo \"PASS: New certificate is valid in index.txt\"\n\telse\n\t\techo \"FAIL: New certificate not marked as valid\"\n\t\tcat /etc/openvpn/server/easy-rsa/pki/index.txt\n\t\texit 1\n\tfi\n\n\t# Verify there's also a revoked entry (both should exist)\n\tREVOKED_COUNT=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep -c \"^R.*CN=$REVOKE_CLIENT\\$\")\n\tVALID_COUNT=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep -c \"^V.*CN=$REVOKE_CLIENT\\$\")\n\techo \"Certificates for '$REVOKE_CLIENT': $REVOKED_COUNT revoked, $VALID_COUNT valid\"\n\tif [ \"$REVOKED_COUNT\" -ge 1 ] && [ \"$VALID_COUNT\" -eq 1 ]; then\n\t\techo \"PASS: Both revoked and new valid certificate entries exist\"\n\telse\n\t\techo \"FAIL: Unexpected certificate state\"\n\t\tcat /etc/openvpn/server/easy-rsa/pki/index.txt\n\t\texit 1\n\tfi\nelse\n\t# Fingerprint mode: verify fingerprint was added back to server.conf\n\tif grep -q \"# $REVOKE_CLIENT\\$\" /etc/openvpn/server/server.conf; then\n\t\techo \"PASS: New client fingerprint added to server.conf\"\n\telse\n\t\techo \"FAIL: New client fingerprint not found in server.conf\"\n\t\tcat /etc/openvpn/server/server.conf | grep -A5 \"<peer-fingerprint>\" || true\n\t\texit 1\n\tfi\nfi\n\n# Copy the new config\ncp \"/root/$REVOKE_CLIENT.ovpn\" \"/shared/$REVOKE_CLIENT-new.ovpn\"\nsed -i 's/^remote .*/remote openvpn-server 1194/' \"/shared/$REVOKE_CLIENT-new.ovpn\"\necho \"Copied new $REVOKE_CLIENT config to /shared/\"\n\n# Signal client that new config is ready\ntouch /shared/new-client-config-ready\n\n# Wait for client to confirm successful connection with new cert\necho \"Waiting for client to connect with new '$REVOKE_CLIENT' certificate...\"\nwhile [ ! -f /shared/new-client-connected ]; do\n\tsleep 2\n\techo \"Waiting for new cert connection...\"\ndone\necho \"PASS: Client connected with new '$REVOKE_CLIENT' certificate\"\n\necho \"=== Reuse of Revoked Client Name Tests PASSED ===\"\n\n# =====================================================\n# Test PASSPHRASE support for headless client creation\n# =====================================================\necho \"\"\necho \"=== Testing PASSPHRASE Support ===\"\n\nPASSPHRASE_CLIENT=\"passphrasetest\"\nTEST_PASSPHRASE=\"TestP@ssw0rd#123\"\necho \"Creating client '$PASSPHRASE_CLIENT' with passphrase in headless mode...\"\nPASSPHRASE_OUTPUT=\"/tmp/passphrase-output.log\"\n(bash /opt/openvpn-install.sh client add \"$PASSPHRASE_CLIENT\" --password \"$TEST_PASSPHRASE\" --cert-days 3650) 2>&1 | tee \"$PASSPHRASE_OUTPUT\" || true\n\n# Verify client was created\nif [ -f \"/root/$PASSPHRASE_CLIENT.ovpn\" ]; then\n\techo \"PASS: Client '$PASSPHRASE_CLIENT' with passphrase created successfully\"\nelse\n\techo \"FAIL: Failed to create client '$PASSPHRASE_CLIENT' with passphrase\"\n\tcat \"$PASSPHRASE_OUTPUT\"\n\texit 1\nfi\n\n# Verify the passphrase is NOT leaked in the output\nif grep -q \"$TEST_PASSPHRASE\" \"$PASSPHRASE_OUTPUT\"; then\n\techo \"FAIL: Passphrase was leaked in command output!\"\n\texit 1\nelse\n\techo \"PASS: Passphrase not leaked in command output\"\nfi\n\n# Verify the log file doesn't contain the passphrase\nif [ -f /opt/openvpn-install.log ] && grep -q \"$TEST_PASSPHRASE\" /opt/openvpn-install.log; then\n\techo \"FAIL: Passphrase was leaked in log file!\"\n\texit 1\nelse\n\techo \"PASS: Passphrase not leaked in log file\"\nfi\n\n# Verify certificate was created with encryption (key should be encrypted)\nCLIENT_KEY=\"/etc/openvpn/server/easy-rsa/pki/private/$PASSPHRASE_CLIENT.key\"\nif [ -f \"$CLIENT_KEY\" ]; then\n\tif grep -q \"ENCRYPTED\" \"$CLIENT_KEY\"; then\n\t\techo \"PASS: Client key is encrypted\"\n\telse\n\t\techo \"FAIL: Client key is not encrypted\"\n\t\texit 1\n\tfi\nelse\n\techo \"FAIL: Client key not found at $CLIENT_KEY\"\n\texit 1\nfi\n\n# Copy config for passphrase client connectivity test\ncp \"/root/$PASSPHRASE_CLIENT.ovpn\" \"/shared/$PASSPHRASE_CLIENT.ovpn\"\nsed -i 's/^remote .*/remote openvpn-server 1194/' \"/shared/$PASSPHRASE_CLIENT.ovpn\"\n# Write passphrase to a file for client to use with --askpass\necho \"$TEST_PASSPHRASE\" >\"/shared/$PASSPHRASE_CLIENT.pass\"\necho \"Copied $PASSPHRASE_CLIENT config and passphrase to /shared/\"\n\n# Signal client that passphrase test config is ready\ntouch /shared/passphrase-client-config-ready\n\n# Wait for client to confirm connection with passphrase client\necho \"Waiting for client to connect with '$PASSPHRASE_CLIENT' certificate...\"\nwhile [ ! -f /shared/passphrase-client-connected ]; do\n\tsleep 2\n\techo \"Waiting for passphrase client connection...\"\ndone\necho \"PASS: Client connected with passphrase-protected certificate\"\n\necho \"=== PASSPHRASE Support Tests PASSED ===\"\n\n# =====================================================\n# Test management interface is running\n# =====================================================\necho \"\"\necho \"=== Testing Management Interface ===\"\n\nMGMT_SOCKET=\"/var/run/openvpn-server/server.sock\"\n\n# Verify management socket exists and is accessible\nif [ -S \"$MGMT_SOCKET\" ]; then\n\techo \"PASS: Management socket exists at $MGMT_SOCKET\"\nelse\n\techo \"FAIL: Management socket not found at $MGMT_SOCKET\"\n\tls -la /var/run/openvpn-server/ || true\n\texit 1\nfi\n\n# Test that we can communicate with the management interface\necho \"Testing management interface communication...\"\nMGMT_STATUS=$(echo \"status\" | socat - UNIX-CONNECT:\"$MGMT_SOCKET\" 2>&1 | head -20)\nif echo \"$MGMT_STATUS\" | grep -q \"CLIENT LIST\"; then\n\techo \"PASS: Management interface is responsive\"\n\techo \"Status output:\"\n\techo \"$MGMT_STATUS\"\nelse\n\techo \"FAIL: Management interface not responding correctly\"\n\techo \"Response: $MGMT_STATUS\"\n\texit 1\nfi\n\necho \"=== Management Interface Tests PASSED ===\"\n\necho \"\"\necho \"=== All Tests PASSED ===\"\n\n# Server tests complete - systemd keeps the container running via /sbin/init\n# OpenVPN service (openvpn-server@server) continues independently\necho \"Server tests complete. Container will remain running via systemd.\"\necho \"OpenVPN is managed by: systemctl status openvpn-server@server\"\n"
  },
  {
    "path": "test/validate-output.sh",
    "content": "#!/bin/bash\n# Validates that script output only contains properly formatted log messages\n# All output from openvpn-install.sh should use logging functions\n#\n# Usage: ./validate-output.sh <output_file>\n#        Or pipe: some_command | ./validate-output.sh\n\nset -euo pipefail\n\nINPUT_FILE=\"${1:-/dev/stdin}\"\n\n# Valid output patterns:\n# - Lines starting with ANSI escape codes (colored output)\n# - Lines starting with our log prefixes (non-TTY mode)\n# - Lines starting with > (command echo from run_cmd)\n# - Empty lines\n\n# ANSI escape code pattern\nANSI_PATTERN=$'^\\033\\\\['\n\n# Log prefix patterns (for non-TTY mode where colors are disabled)\n# These match: [INFO], [WARN], [ERROR], [OK], [DEBUG], or > (command line)\nLOG_PREFIXES='^(\\[INFO\\]|\\[WARN\\]|\\[ERROR\\]|\\[OK\\]|\\[DEBUG\\]|> )'\n\n# Count issues\nINVALID_LINES=0\nTOTAL_LINES=0\nLINE_NUM=0\n\necho \"Validating script output for unformatted lines...\"\necho \"\"\n\nwhile IFS= read -r line || [[ -n \"$line\" ]]; do\n\tLINE_NUM=$((LINE_NUM + 1))\n\n\t# Skip empty lines\n\tif [[ -z \"$line\" ]]; then\n\t\tcontinue\n\tfi\n\n\tTOTAL_LINES=$((TOTAL_LINES + 1))\n\n\t# Check if line starts with ANSI escape code (colored output from log functions)\n\tif [[ \"$line\" =~ $ANSI_PATTERN ]]; then\n\t\tcontinue\n\tfi\n\n\t# Check if line starts with our log prefixes (non-TTY mode)\n\tif [[ \"$line\" =~ $LOG_PREFIXES ]]; then\n\t\tcontinue\n\tfi\n\n\t# If we get here, the line doesn't match expected patterns - it's raw output\n\tINVALID_LINES=$((INVALID_LINES + 1))\n\t# Truncate long lines for display\n\tif [[ ${#line} -gt 100 ]]; then\n\t\tDISPLAY_LINE=\"${line:0:100}...\"\n\telse\n\t\tDISPLAY_LINE=\"$line\"\n\tfi\n\techo \"  [LEAK] Line $LINE_NUM: $DISPLAY_LINE\"\n\ndone <\"$INPUT_FILE\"\n\necho \"\"\necho \"----------------------------------------\"\necho \"Total lines checked: $TOTAL_LINES\"\necho \"Invalid lines found: $INVALID_LINES\"\n\nif [[ $INVALID_LINES -gt 0 ]]; then\n\techo \"\"\n\techo \"ERROR: Found $INVALID_LINES line(s) without proper log formatting.\"\n\techo \"\"\n\techo \"All user-visible output should use log_* functions:\"\n\techo \"  - log_info 'message'    -> [INFO] message\"\n\techo \"  - log_warn 'message'    -> [WARN] message\"\n\techo \"  - log_error 'message'   -> [ERROR] message\"\n\techo \"  - log_success 'message' -> [OK] message\"\n\techo \"  - run_cmd 'desc' cmd    -> > cmd\"\n\techo \"\"\n\techo \"Raw echo statements or command output should not leak to stdout.\"\n\texit 1\nfi\n\necho \"\"\necho \"All output is properly formatted!\"\nexit 0\n"
  }
]