diff --git a/.editorconfig b/.editorconfig index b4d9ec4..2dc2678 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,10 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true + [*.sh] indent_style = tab indent_size = 4 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 42b3ecb..cda9719 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,5 +1,2 @@ -patreon: stanislas -liberapay: stanislas ko_fi: stanislas -github: angristan custom: https://coindrop.to/stanislas diff --git a/.github/ISSUE_TEMPLATE/bug-report-or-suport-request.md b/.github/ISSUE_TEMPLATE/bug-report-or-suport-request.md deleted file mode 100644 index 93b6be9..0000000 --- a/.github/ISSUE_TEMPLATE/bug-report-or-suport-request.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: Bug report / Support request -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**⚠️ Unless you are sure you find a bug with the script, please open a [discussion](https://github.com/angristan/openvpn-install/discussions) instead of an issue!** - -**Checklist** - -- [ ] I read the [README](https://github.com/angristan/openvpn-install/blob/master/README.md) -- [ ] I read the [FAQ](https://github.com/angristan/openvpn-install/blob/master/FAQ.md) -- [ ] I searched the [issues](https://github.com/angristan/openvpn-install/issues?q=is%3Aissue+) -- [ ] I searched the [discussion](https://github.com/angristan/openvpn-install/discussions) -- [ ] My issue is about the script, and not OpenVPN itself - - - -Pease include as much details as possible in your issue: - -- Description of the issue -- How to reproduce the issue -- What did you expected should happen -- Logs -- Server/Client versions (OS, OpenVPN, etc) -- Any context or information that could help - ---- - - diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md deleted file mode 100644 index e5dd037..0000000 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Checklist** - -- [ ] I read the [README](https://github.com/angristan/openvpn-install/blob/master/README.md) -- [ ] I read the [FAQ](https://github.com/angristan/openvpn-install/blob/master/FAQ.md) -- [ ] I searched the [issues](https://github.com/angristan/openvpn-install/issues?q=is%3Aissue+) -- [ ] My issue is about the script, and not OpenVPN itself - - - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 8ac6b8c..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "monthly" diff --git a/.github/issue_template.md b/.github/issue_template.md new file mode 100644 index 0000000..2166d42 --- /dev/null +++ b/.github/issue_template.md @@ -0,0 +1,10 @@ + diff --git a/.github/linters/.markdown-lint.yml b/.github/linters/.markdown-lint.yml index 68d1fdf..2633810 100644 --- a/.github/linters/.markdown-lint.yml +++ b/.github/linters/.markdown-lint.yml @@ -1 +1,8 @@ -{ 'MD013': null, 'MD045': null, 'MD040': null, 'MD036': null } +{ + "MD013": null, + "MD045": null, + "MD040": null, + "MD036": null, + "MD041": null, + "MD060": null, +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d093642 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ + diff --git a/.github/workflows/do-test.yml b/.github/workflows/do-test.yml new file mode 100644 index 0000000..f176a04 --- /dev/null +++ b/.github/workflows/do-test.yml @@ -0,0 +1,104 @@ +# DigitalOcean E2E tests (manual trigger only) +# Primary CI testing is now done via Docker in docker-test.yml +# This workflow is kept for real-world VM testing when needed +on: + workflow_dispatch: + +name: Test + +permissions: + contents: read + +jobs: + install: + runs-on: ubuntu-latest + if: github.repository == 'angristan/openvpn-install' && github.actor == 'angristan' + strategy: + matrix: + os-image: + - debian-12-x64 + - debian-13-x64 + - ubuntu-22-04-x64 + - ubuntu-24-04-x64 + - fedora-42-x64 + # - centos-stream-9-x64 # yum oomkill + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup doctl + uses: digitalocean/action-doctl@135ac0aa0eed4437d547c6f12c364d3006b42824 # v2.5.1 + with: + token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} + + - name: Create server + run: doctl compute droplet create "openvpn-action-${GITHUB_RUN_ID}-${GITHUB_RUN_NUMBER}-${{ matrix.os-image }}" --size s-1vcpu-1gb --image "${{ matrix.os-image }}" --region lon1 --enable-ipv6 --ssh-keys be:66:76:61:a8:71:93:aa:e3:19:ba:d8:0d:d2:2d:d4 --wait + + - name: Get server ID + run: echo "value=$(doctl compute droplet list -o json | jq -r '.[] | select(.name == "'"openvpn-action-${GITHUB_RUN_ID}-${GITHUB_RUN_NUMBER}-${{ matrix.os-image }}"'").id')" >> "$GITHUB_OUTPUT" + id: server_id + + - name: Move server to dedicated project + run: doctl projects resources assign "$DIGITALOCEAN_PROJECT_ID" --resource=do:droplet:"$SERVER_ID" + env: + DIGITALOCEAN_PROJECT_ID: ${{ secrets.DIGITALOCEAN_PROJECT_ID }} + SERVER_ID: ${{ steps.server_id.outputs.value }} + + - name: Wait for server to boot + run: sleep 90 + + - name: Get server IP + run: echo "value=$(doctl compute droplet list -o json | jq -r '.[] | select(.name == "'"openvpn-action-${GITHUB_RUN_ID}-${GITHUB_RUN_NUMBER}-${{ matrix.os-image }}"'").networks.v4 | .[] | select(.type == "'"public"'").ip_address')" >> "$GITHUB_OUTPUT" + id: server_ip + + - name: Get server OS + run: echo "value=$(echo "${{ matrix.os-image }}" | cut -d '-' -f1)" >> "$GITHUB_OUTPUT" + id: server_os + + - name: Setup remote server (Debian/Ubuntu) + if: steps.server_os.outputs.value == 'debian' || steps.server_os.outputs.value == 'ubuntu' + uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0 + with: + host: ${{ steps.server_ip.outputs.value }} + username: root + key: ${{ secrets.SSH_KEY }} + script: set -x && apt-get update && apt-get -o DPkg::Lock::Timeout=120 install -y git + + - name: Setup remote server (Fedora) + if: steps.server_os.outputs.value == 'fedora' + uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0 + with: + host: ${{ steps.server_ip.outputs.value }} + username: root + key: ${{ secrets.SSH_KEY }} + script: set -x && dnf install -y git + + - name: Setup remote server (CentOS) + if: steps.server_os.outputs.value == 'centos' + uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0 + with: + host: ${{ steps.server_ip.outputs.value }} + username: root + key: ${{ secrets.SSH_KEY }} + script: set -x && yum install -y git + + - name: Download repo and checkout current commit + uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0 + with: + host: ${{ steps.server_ip.outputs.value }} + username: root + key: ${{ secrets.SSH_KEY }} + script: set -x && git clone https://github.com/angristan/openvpn-install.git && cd openvpn-install && git checkout ${{ github.sha }} + + - name: Run openvpn-install.sh in headless mode + uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0 + with: + host: ${{ steps.server_ip.outputs.value }} + username: root + key: ${{ secrets.SSH_KEY }} + script: 'set -x && AUTO_INSTALL=y bash -x ~/openvpn-install/openvpn-install.sh && ps aux | grep openvpn | grep -v grep > /dev/null 2>&1 && echo "Success: OpenVPN is running" && exit 0 || echo "Failure: OpenVPN is not running" && exit 1' + + - name: Delete server + run: doctl compute droplet delete -f "openvpn-action-${GITHUB_RUN_ID}-${GITHUB_RUN_NUMBER}-${{ matrix.os-image }}" + if: always() diff --git a/.github/workflows/docker-test.yml b/.github/workflows/docker-test.yml new file mode 100644 index 0000000..f1e83ab --- /dev/null +++ b/.github/workflows/docker-test.yml @@ -0,0 +1,275 @@ +--- +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +name: Docker Test + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + docker-test: + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: + - name: ubuntu-18.04 + image: ubuntu:18.04 + - name: ubuntu-20.04 + image: ubuntu:20.04 + - name: ubuntu-22.04 + image: ubuntu:22.04 + - name: ubuntu-24.04 + image: ubuntu:24.04 + - name: debian-11 + image: debian:11 + - name: debian-12 + image: debian:12 + - name: centos-stream-9 + image: quay.io/centos/centos:stream9 + - name: centos-stream-10 + image: quay.io/centos/centos:stream10 + - name: fedora-42 + image: fedora:42 + - name: fedora-43 + image: fedora:43 + - name: rocky-8 + image: rockylinux/rockylinux:8 + - name: rocky-9 + image: rockylinux/rockylinux:9 + - name: rocky-10 + image: rockylinux/rockylinux:10 + - name: almalinux-8 + image: almalinux:8 + - name: almalinux-9 + image: almalinux:9 + - name: almalinux-10 + image: almalinux:10 + - name: archlinux + image: archlinux:latest + - name: opensuse-leap-16.0 + image: opensuse/leap:16.0 + - name: opensuse-tumbleweed + image: opensuse/tumbleweed + - name: oraclelinux-8 + image: oraclelinux:8 + - name: oraclelinux-9 + image: oraclelinux:9 + - name: oraclelinux-10 + image: oraclelinux:10 + - name: amazonlinux-2023 + image: amazonlinux:2023 + # Default TLS settings (tls-crypt-v2) + tls: + - name: tls-crypt-v2 + sig: "1" + key_file: tls-crypt-v2.key + # Additional TLS types tested on Ubuntu 24.04 only + include: + - os: + name: ubuntu-24.04-tls-crypt + image: ubuntu:24.04 + tls: + name: tls-crypt + sig: "2" + key_file: tls-crypt.key + - os: + name: ubuntu-24.04-tls-auth + image: ubuntu:24.04 + tls: + name: tls-auth + sig: "3" + key_file: tls-auth.key + + name: ${{ matrix.os.name }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + + - name: Build server image + run: | + docker build \ + --build-arg BASE_IMAGE=${{ matrix.os.image }} \ + -t openvpn-server \ + -f test/Dockerfile.server . + + - name: Build client image + run: docker build -t openvpn-client -f test/Dockerfile.client . + + - name: Create Docker network + run: docker network create --subnet=172.28.0.0/24 vpn-test + + - name: Create shared volume + run: docker volume create shared-config + + - name: Start OpenVPN server + run: | + docker run -d \ + --name openvpn-server \ + --hostname openvpn-server \ + --privileged \ + --cgroupns=host \ + --device=/dev/net/tun:/dev/net/tun \ + --sysctl net.ipv4.ip_forward=1 \ + --network vpn-test \ + --ip 172.28.0.10 \ + -v shared-config:/shared \ + -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ + --tmpfs /run \ + --tmpfs /run/lock \ + --stop-signal SIGRTMIN+3 \ + -e TLS_SIG=${{ matrix.tls.sig }} \ + -e TLS_KEY_FILE=${{ matrix.tls.key_file }} \ + openvpn-server + + - name: Wait for server installation and startup + run: | + echo "Waiting for OpenVPN server to install and client config to be ready..." + for i in {1..90}; do + # Get service status (properly handle non-zero exit codes) + # systemctl is-active returns exit code 3 for "inactive"/"failed", so capture output without checking exit code + SERVICE_STATUS="$(docker exec openvpn-server systemctl is-active openvpn-test.service 2>/dev/null)" || true + [ -z "$SERVICE_STATUS" ] && SERVICE_STATUS="unknown" + + # Fail fast if service failed + if [ "$SERVICE_STATUS" = "failed" ]; then + echo "ERROR: openvpn-test.service failed during installation" + docker exec openvpn-server systemctl status openvpn-test.service 2>&1 || true + docker exec openvpn-server journalctl -u openvpn-test.service --no-pager -n 100 2>&1 || true + exit 1 + fi + + # Check if OpenVPN server is running and client config exists + # The service will be "activating" while waiting for client tests - that's expected + OPENVPN_RUNNING=false + CONFIG_EXISTS=false + + if docker exec openvpn-server pgrep -f "openvpn.*server.conf" > /dev/null 2>&1; then + OPENVPN_RUNNING=true + fi + + if docker exec openvpn-server test -f /shared/client.ovpn 2>/dev/null; then + CONFIG_EXISTS=true + fi + + if [ "$OPENVPN_RUNNING" = true ] && [ "$CONFIG_EXISTS" = true ]; then + echo "OpenVPN server is running and client config is ready!" + break + fi + + echo "Waiting... ($i/90) - Service: $SERVICE_STATUS, OpenVPN running: $OPENVPN_RUNNING, Config exists: $CONFIG_EXISTS" + sleep 5 + done + + # Final verification with retry (handles race condition during cert renewal restart) + OPENVPN_STARTED=false + for retry in {1..5}; do + if docker exec openvpn-server pgrep -f "openvpn.*server.conf" > /dev/null 2>&1; then + OPENVPN_STARTED=true + break + fi + echo "Waiting for OpenVPN process... (retry $retry/5)" + sleep 2 + done + + if [ "$OPENVPN_STARTED" = false ]; then + echo "ERROR: OpenVPN server failed to start" + docker exec openvpn-server systemctl status openvpn-server@server 2>&1 || true + docker exec openvpn-server journalctl -u openvpn-test.service --no-pager -n 100 2>&1 || true + exit 1 + fi + + if ! docker exec openvpn-server test -f /shared/client.ovpn 2>/dev/null; then + echo "ERROR: Client config not generated" + docker exec openvpn-server journalctl -u openvpn-test.service --no-pager -n 100 2>&1 || true + exit 1 + fi + + echo "Server ready for client connection!" + + - name: Verify client config was generated + run: | + docker run --rm -v shared-config:/shared alpine \ + ls -la /shared/ + docker run --rm -v shared-config:/shared alpine \ + cat /shared/client.ovpn + + - name: Start OpenVPN client and run tests + run: | + docker run \ + --name openvpn-client \ + --hostname openvpn-client \ + --cap-add=NET_ADMIN \ + --device=/dev/net/tun:/dev/net/tun \ + --network vpn-test \ + --ip 172.28.0.20 \ + -v shared-config:/shared \ + openvpn-client & + + # Wait for tests to complete (look for success message) + # Extended timeout for revocation e2e tests + for i in {1..180}; do + if docker logs openvpn-client 2>&1 | grep -q "ALL TESTS PASSED" + then + echo "Tests passed!" + exit 0 + fi + if docker logs openvpn-client 2>&1 | grep -q "FAIL:"; then + echo "Tests failed!" + docker logs openvpn-client + exit 1 + fi + echo "Waiting for tests... ($i/180)" + sleep 2 + done + + echo "Timeout waiting for tests" + docker logs openvpn-client + exit 1 + + - name: Show server logs + if: always() + run: docker logs openvpn-server 2>&1 || true + + - name: Show systemd journal logs + if: always() + run: | + echo "=== openvpn-test.service status ===" + docker exec openvpn-server systemctl status openvpn-test.service 2>&1 || true + echo "" + echo "=== openvpn-test.service journal ===" + docker exec openvpn-server journalctl -u openvpn-test.service --no-pager -n 100 2>&1 || true + echo "" + echo "=== openvpn-server@server.service journal ===" + docker exec openvpn-server journalctl -u openvpn-server@server.service --no-pager -n 50 2>&1 || true + + - name: Show install script log + if: always() + run: | + docker cp openvpn-server:/opt/openvpn-install.log /tmp/openvpn-install.log 2>/dev/null && \ + cat /tmp/openvpn-install.log || echo "No install log found" + + - name: Show client logs + if: always() + run: docker logs openvpn-client 2>&1 || true + + - name: Cleanup + if: always() + run: | + docker stop openvpn-server openvpn-client 2>/dev/null || true + docker rm openvpn-server openvpn-client 2>/dev/null || true + docker network rm vpn-test 2>/dev/null || true + docker volume rm shared-config 2>/dev/null || true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7ad096b..6a04a2f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,14 +1,28 @@ -on: [push, pull_request] +on: + push: + branches: [master] + pull_request: + workflow_dispatch: name: Lint +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: super-linter: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v2.4.0 + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false - name: Lint Code Base - uses: github/super-linter@v4.1.0 + uses: super-linter/super-linter@502f4fe48a81a392756e173e39a861f8c8efe056 # v8 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 1a8a001..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,100 +0,0 @@ -on: - push: - branches: - - master - -name: Test -jobs: - install: - runs-on: ubuntu-latest - if: github.repository == 'angristan/openvpn-install' && github.actor == 'angristan' - strategy: - matrix: - os-image: - - debian-9-x64 - - debian-10-x64 - - debian-11-x64 - - ubuntu-18-04-x64 - - ubuntu-20-04-x64 - - ubuntu-21-04-x64 - - ubuntu-21-10-x64 - - fedora-33-x64 - - fedora-34-x64 - - fedora-35-x64 - - centos-7-x64 - - centos-8-x64 - steps: - - uses: actions/checkout@v2.4.0 - - - name: Setup doctl - uses: digitalocean/action-doctl@v2 - with: - token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} - - - name: Create server - run: doctl compute droplet create openvpn-action-$GITHUB_RUN_ID-$GITHUB_RUN_NUMBER-${{ matrix.os-image }} --size s-1vcpu-1gb --image ${{ matrix.os-image }} --region lon1 --enable-ipv6 --ssh-keys be:66:76:61:a8:71:93:aa:e3:19:ba:d8:0d:d2:2d:d4 --wait - - - name: Get server ID - run: echo ::set-output name=value::$(doctl compute droplet list -o json | jq -r '.[] | select(.name == "'openvpn-action-$GITHUB_RUN_ID-$GITHUB_RUN_NUMBER-${{ matrix.os-image }}'").id') - id: server_id - - - name: Move server to dedicated project - run: doctl projects resources assign ${{ secrets.DIGITALOCEAN_PROJECT_ID }} --resource=do:droplet:${{ steps.server_id.outputs.value }} - - - name: Wait for server to boot - run: sleep 90 - - - name: Get server IP - run: echo ::set-output name=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') - id: server_ip - - - name: Get server OS - run: echo ::set-output name=value::$(echo ${{ matrix.os-image }} | cut -d '-' -f1) - id: server_os - - - name: Setup remote server (Debian/Ubuntu) - if: steps.server_os.outputs.value == 'debian' || steps.server_os.outputs.value == 'ubuntu' - uses: appleboy/ssh-action@v0.1.4 - with: - host: ${{ steps.server_ip.outputs.value }} - username: root - key: ${{ secrets.SSH_KEY }} - script: set -x && apt-get update && apt-get install -y git - - - name: Setup remote server (Fedora) - if: steps.server_os.outputs.value == 'fedora' - uses: appleboy/ssh-action@v0.1.4 - with: - host: ${{ steps.server_ip.outputs.value }} - username: root - key: ${{ secrets.SSH_KEY }} - script: set -x && dnf install -y git - - - name: Setup remote server (CentOS) - if: steps.server_os.outputs.value == 'centos' - uses: appleboy/ssh-action@v0.1.4 - with: - host: ${{ steps.server_ip.outputs.value }} - username: root - key: ${{ secrets.SSH_KEY }} - script: set -x && yum install -y git - - - name: Download repo and checkout current commit - uses: appleboy/ssh-action@v0.1.4 - with: - host: ${{ steps.server_ip.outputs.value }} - username: root - key: ${{ secrets.SSH_KEY }} - script: set -x && git clone https://github.com/angristan/openvpn-install.git && cd openvpn-install && git checkout ${{ github.event.pull_request.head.sha }} - - - name: Run openvpn-install.sh in headless mode - uses: appleboy/ssh-action@v0.1.4 - with: - host: ${{ steps.server_ip.outputs.value }} - username: root - key: ${{ secrets.SSH_KEY }} - script: 'set -x && AUTO_INSTALL=y bash -x ~/openvpn-install/openvpn-install.sh && ps aux | grep openvpn | grep -v grep > /dev/null 2>&1 && echo "Success: OpenVPN is running" && exit 0 || echo "Failure: OpenVPN is not running" && exit 1' - - - name: Delete server - run: doctl compute droplet delete -f openvpn-action-$GITHUB_RUN_ID-$GITHUB_RUN_NUMBER-${{ matrix.os-image }} - if: always() diff --git a/.github/workflows/update-easyrsa-hash.yml b/.github/workflows/update-easyrsa-hash.yml new file mode 100644 index 0000000..d3cff5c --- /dev/null +++ b/.github/workflows/update-easyrsa-hash.yml @@ -0,0 +1,73 @@ +name: Update Easy-RSA SHA256 + +# Note: This workflow commits and pushes changes to openvpn-install.sh. +# Uses PAT to trigger CI on the resulting commit. Infinite recursion is prevented +# by the 'renovate/' branch prefix check - CI commits don't re-trigger this workflow. +# Requires: Create a PAT with 'contents: write' scope and add as repository secret 'PAT' + +on: + pull_request: + types: [opened, synchronize] + paths: + - "openvpn-install.sh" + +permissions: + contents: read + +jobs: + update-hash: + if: startsWith(github.head_ref, 'renovate/') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.PAT }} + persist-credentials: false + + - name: Extract version and update SHA256 + run: | + VERSION=$(grep -oP 'EASYRSA_VERSION="\K[^"]+' openvpn-install.sh) + if [ -z "$VERSION" ]; then + echo "Error: Failed to extract EASYRSA_VERSION" + exit 1 + fi + echo "Easy-RSA version: $VERSION" + + CURRENT_SHA=$(grep -oP 'EASYRSA_SHA256="\K[^"]+' openvpn-install.sh) + if [ -z "$CURRENT_SHA" ]; then + echo "Error: Failed to extract EASYRSA_SHA256" + exit 1 + fi + echo "Current SHA256: $CURRENT_SHA" + + TARBALL_URL="https://github.com/OpenVPN/easy-rsa/releases/download/v${VERSION}/EasyRSA-${VERSION}.tgz" + if ! curl -fsSL "$TARBALL_URL" -o /tmp/easyrsa.tgz; then + echo "Error: Failed to download Easy-RSA tarball from $TARBALL_URL" + exit 1 + fi + NEW_SHA=$(sha256sum /tmp/easyrsa.tgz | cut -d' ' -f1) + echo "New SHA256: $NEW_SHA" + + if [ "$CURRENT_SHA" != "$NEW_SHA" ]; then + sed -i "s|EASYRSA_SHA256=\"$CURRENT_SHA\"|EASYRSA_SHA256=\"$NEW_SHA\"|" openvpn-install.sh + echo "SHA256 updated" + echo "HASH_CHANGED=true" >> "$GITHUB_ENV" + else + echo "SHA256 already correct" + fi + + - name: Commit changes + if: env.HASH_CHANGED == 'true' + run: | + if ! git diff --quiet openvpn-install.sh; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add openvpn-install.sh + git commit -m "chore: update Easy-RSA SHA256 hash" + git push + else + echo "No changes to commit" + fi diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..69f9cb1 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,8 @@ +# Test containers require root for OpenVPN NET_ADMIN capability +AVD-DS-0002 + +# Test containers don't need healthcheck +AVD-DS-0026 + +# False positive: yum clean all is present in the conditional but Trivy doesn't detect it +AVD-DS-0015 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b9510d1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +- Use gh CLI to interact with GitHub +- Test locally using the Docker setup when needed +- When doing changes, check if README/FAQ and tests needs to be updated +- Remember the script and documentation needs to be accessible to a moderately technical audience +- Keep PR description concise (no test plan) +- Don't use gh cli to post comments on the developer's behalf +- Don't amend commits and force push unless told otherwise diff --git a/FAQ.md b/FAQ.md index 825a9ba..f05bfdb 100644 --- a/FAQ.md +++ b/FAQ.md @@ -8,6 +8,14 @@ You can, of course, it's even recommended, update the `openvpn` package with you --- +**Q:** How do I renew certificates before they expire? + +**A:** Run the script again and select "Renew certificates" from the menu. You can renew either client certificates or the server certificate. The script will show you the current expiration date for each certificate and let you choose a new validity period (default: 3650 days / 10 years). + +For client renewals, a new `.ovpn` file will be generated that you need to distribute to the client. For server renewals, the OpenVPN service will need to be restarted (the script will prompt you). + +--- + **Q:** How do I check for DNS leaks? **A:** Go to [browserleaks.com](https://browserleaks.com/dns) or [ipleak.net](https://ipleak.net/) (both perform IPv4 and IPv6 check) with your browser. Your IP should not show up (test without and without the VPN). The DNS servers should be the ones you selected during the setup, not your IP address nor your ISP's DNS servers' addresses. @@ -27,7 +35,7 @@ up /etc/openvpn/update-resolv-conf down /etc/openvpn/update-resolv-conf ``` -Centos 6, 7 +CentOS 6, 7 ``` script-security 2 @@ -35,7 +43,7 @@ up /usr/share/doc/openvpn-2.4.8/contrib/pull-resolv-conf/client.up down /usr/share/doc/openvpn-2.4.8/contrib/pull-resolv-conf/client.down ``` -Centos 8, Fedora 30, 31 +CentOS 8, Fedora 30, 31 ``` script-security 2 @@ -63,7 +71,7 @@ down /usr/share/openvpn/contrib/pull-resolv-conf/client.down - AES CBC - tls-auth -If your client is <2.3.3, remove `tls-version-min 1.2` from your `/etc/openvpn/server.conf` and `.ovpn` files. +If your client is <2.3.3, remove `tls-version-min 1.2` from your `/etc/openvpn/server/server.conf` and `.ovpn` files. --- @@ -83,7 +91,7 @@ If your client is <2.3.3, remove `tls-version-min 1.2` from your `/etc/openvpn/s **A:** Iptables rules are saved at `/etc/iptables/add-openvpn-rules.sh` and `/etc/iptables/rm-openvpn-rules.sh`. They are managed by the service `/etc/systemd/system/iptables-openvpn.service` -Sysctl options are at `/etc/sysctl.d/20-openvpn.conf` +Sysctl options are at `/etc/sysctl.d/99-openvpn.conf` --- @@ -109,13 +117,13 @@ Sysctl options are at `/etc/sysctl.d/20-openvpn.conf` **Q:** How can I access computers the OpenVPN server's remote LAN? -**A:** Add a route with the subnet of the remote network to `/etc/openvpn/server.conf` and restart openvpn. Example: `push "route 192.168.1.0 255.255.255.0"` if the server's LAN is `192.168.1.0/24` +**A:** Add a route with the subnet of the remote network to `/etc/openvpn/server/server.conf` and restart OpenVPN. Example: `push "route 192.168.1.0 255.255.255.0"` if the server's LAN is `192.168.1.0/24` --- **Q:** How can I add multiple users in one go? -**A:** Here is a sample bash script to achieve this: +**A:** Here is a sample Bash script to achieve this: ```sh userlist=(user1 user2 user3) @@ -137,17 +145,39 @@ done < users.txt **Q:** How do I change the default `.ovpn` file created for future clients? -**A:** You can edit the template out of which `.ovpn` files are created by editing `/etc/openvpn/client-template.txt` +**A:** You can edit the template out of which `.ovpn` files are created by editing `/etc/openvpn/server/client-template.txt` --- **Q:** For my clients - I want to set my internal network to pass through the VPN and the rest to go through my internet? -**A:** You would need to edit the `.ovpn` file. You can edit the template out of which those files are created by editing `/etc/openvpn/client-template.txt` file and adding +**A:** You would need to edit the `.ovpn` file. You can edit the template out of which those files are created by editing `/etc/openvpn/server/client-template.txt` file and adding ```sh route-nopull route 10.0.0.0 255.0.0.0 ``` -So for example - here it would route all traffic of `10.0.0.0/8` to the vpn. And the rest through the internet. +So for example - here it would route all traffic of `10.0.0.0/8` to the VPN. And the rest through the internet. + +--- + +**Q:** I have enabled IPv6 and my VPN client gets an IPv6 address. Why do I reach the sites or other dual-stacked destinations via IPv4 only? + +**A:** This is because inside the tunnel you don't get a publicly routable IPv6 address, instead you get an ULA (Unlique Local Lan) address. Operating systems don't prefer this all the time. You can fix this in your operating system policies as it's unrelated to the VPN itself: + +Windows (commands needs to run cmd.exe as Administrator): + +``` +netsh interface ipv6 add prefixpolicy fd00::/8 3 1 +``` + +Linux: + +edit `/etc/gai.conf` and uncomment the following line and also change its value to `1`: + +``` +label fc00::/7 1 +``` + +This will not work properly unless you add you your VPN server `server.conf` one or two lines to push at least 1 (one) IPv6 DNS server. Most providers have IPv6 servers as well, add two more lines of `push "dhcp-option DNS "` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8c3e871 --- /dev/null +++ b/Makefile @@ -0,0 +1,140 @@ +.PHONY: test test-build test-up test-down test-logs test-clean + +# Run the full test suite +test: test-build test-up + @echo "Waiting for tests to complete..." + @for i in $$(seq 1 180); do \ + if docker logs openvpn-client 2>&1 | grep -q "ALL TESTS PASSED"; then \ + echo "✓ Tests passed!"; \ + $(MAKE) test-down; \ + exit 0; \ + fi; \ + if docker logs openvpn-client 2>&1 | grep -q "FAIL:"; then \ + echo "✗ Tests failed!"; \ + docker logs openvpn-client; \ + $(MAKE) test-down; \ + exit 1; \ + fi; \ + echo "Waiting... ($$i/180)"; \ + sleep 2; \ + done; \ + echo "Timeout waiting for tests"; \ + $(MAKE) test-down; \ + exit 1 + +# Build test containers +test-build: + BASE_IMAGE=$(BASE_IMAGE) docker compose build + +# Start test containers +test-up: + docker compose up -d + +# Stop and remove test containers +test-down: + docker compose down -v --remove-orphans + +# View logs +test-logs: + docker compose logs -f + +# View server logs only +test-logs-server: + docker logs -f openvpn-server + +# View client logs only +test-logs-client: + docker logs -f openvpn-client + +# Full cleanup +test-clean: test-down + docker rmi openvpn-install-openvpn-server openvpn-install-openvpn-client 2>/dev/null || true + docker volume prune -f + +# Interactive shell into server container +test-shell-server: + docker exec -it openvpn-server /bin/bash + +# Interactive shell into client container +test-shell-client: + docker exec -it openvpn-client /bin/bash + +# Test specific distributions +test-ubuntu-18.04: + $(MAKE) test BASE_IMAGE=ubuntu:18.04 + +test-ubuntu-20.04: + $(MAKE) test BASE_IMAGE=ubuntu:20.04 + +test-ubuntu-22.04: + $(MAKE) test BASE_IMAGE=ubuntu:22.04 + +test-ubuntu-24.04: + $(MAKE) test BASE_IMAGE=ubuntu:24.04 + +test-debian-11: + $(MAKE) test BASE_IMAGE=debian:11 + +test-debian-12: + $(MAKE) test BASE_IMAGE=debian:12 + +test-fedora-40: + $(MAKE) test BASE_IMAGE=fedora:40 + +test-fedora-41: + $(MAKE) test BASE_IMAGE=fedora:41 + +test-rocky-8: + $(MAKE) test BASE_IMAGE=rockylinux:8 + +test-rocky-9: + $(MAKE) test BASE_IMAGE=rockylinux:9 + +test-almalinux-8: + $(MAKE) test BASE_IMAGE=almalinux:8 + +test-almalinux-9: + $(MAKE) test BASE_IMAGE=almalinux:9 + +test-oracle-8: + $(MAKE) test BASE_IMAGE=oraclelinux:8 + +test-oracle-9: + $(MAKE) test BASE_IMAGE=oraclelinux:9 + +test-amazon-2023: + $(MAKE) test BASE_IMAGE=amazonlinux:2023 + +test-arch: + $(MAKE) test BASE_IMAGE=archlinux:latest + +test-centos-stream-9: + $(MAKE) test BASE_IMAGE=quay.io/centos/centos:stream9 + +test-opensuse-leap: + $(MAKE) test BASE_IMAGE=opensuse/leap:16.0 + +test-opensuse-tumbleweed: + $(MAKE) test BASE_IMAGE=opensuse/tumbleweed + +# Test all distributions (runs sequentially) +test-all: + $(MAKE) test-ubuntu-18.04 + $(MAKE) test-ubuntu-20.04 + $(MAKE) test-ubuntu-22.04 + $(MAKE) test-ubuntu-24.04 + $(MAKE) test-debian-11 + $(MAKE) test-debian-12 + $(MAKE) test-fedora-40 + $(MAKE) test-fedora-41 + $(MAKE) test-rocky-8 + $(MAKE) test-rocky-9 + $(MAKE) test-almalinux-8 + $(MAKE) test-almalinux-9 + $(MAKE) test-oracle-8 + $(MAKE) test-oracle-9 + $(MAKE) test-amazon-2023 + $(MAKE) test-arch + $(MAKE) test-centos-stream-9 + $(MAKE) test-opensuse-leap + $(MAKE) test-opensuse-tumbleweed diff --git a/README.md b/README.md index 04333cc..fd280c1 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,44 @@ ![Test](https://github.com/angristan/openvpn-install/workflows/Test/badge.svg) ![Lint](https://github.com/angristan/openvpn-install/workflows/Lint/badge.svg) -![visitors](https://visitor-badge.glitch.me/badge?page_id=angristan.openvpn-install) +[![Say Thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](https://saythanks.io/to/angristan) -OpenVPN installer for Debian, Ubuntu, Fedora, CentOS, Arch Linux, Oracle Linux, Rocky Linux and AlmaLinux. +OpenVPN installer for Debian, Ubuntu, Fedora, openSUSE, CentOS, Amazon Linux, Arch Linux, Oracle Linux, Rocky Linux and AlmaLinux. This script will let you setup your own secure VPN server in just a few seconds. -You can also check out [wireguard-install](https://github.com/angristan/wireguard-install), a simple installer for a simpler, safer, faster and more modern VPN protocol. +## What is this? + +This script is meant to be run on your own server, whether it's a VPS or a dedicated server, or even a computer at home. + +Once set up, you will be able to generate client configuration files for every device you want to connect. + +Each client will be able to route its internet traffic through the server, fully encrypted. + +```mermaid +graph LR + A[Phone] -->|Encrypted| VPN + B[Laptop] -->|Encrypted| VPN + C[Computer] -->|Encrypted| VPN + + VPN[OpenVPN Server] + + VPN --> I[Internet] +``` + +## Why OpenVPN? + +OpenVPN was the de facto standard for open-source VPNs when this script was created. WireGuard came later and is simpler and faster for most use cases. Check out [wireguard-install](https://github.com/angristan/wireguard-install). + +That said, OpenVPN still makes sense when you need: + +- **TCP support**: works in restrictive environments where UDP is blocked (corporate networks, airports, hotels, etc.) +- **Password-protected private keys**: WireGuard configs store the private key in plain text +- **Legacy compatibility**: clients exist for pretty much every platform, including older systems ## Usage -First, get the script and make it executable: +First, on your server, get the script and make it executable: ```bash curl -O https://raw.githubusercontent.com/angristan/openvpn-install/master/openvpn-install.sh @@ -33,13 +60,12 @@ When OpenVPN is installed, you can run the script again, and you will get the ch - Add a client - Remove a client +- Renew certificates (client or server) - Uninstall OpenVPN In your home directory, you will have `.ovpn` files. These are the client configuration files. Download them from your server and connect using your favorite OpenVPN client. -If you have any question, head to the [FAQ](#faq) first. Please read everything before opening an issue. - -**PLEASE do not send me emails or private messages asking for help.** The only place to get help is the issues. Other people may be able to help and in the future, other users may also run into the same issue as you. My time is not available for free just for you, you're not special. +If you have any question, head to the [FAQ](#faq) first. And if you need help, you can open a [discussion](https://github.com/angristan/openvpn-install/discussions). Please search existing issues and dicussions first. ### Headless install @@ -70,6 +96,9 @@ If you want to customise your installation, you can export them or specify them - `CUSTOMIZE_ENC=n` - `CLIENT=clientname` - `PASS=1` +- `MULTI_CLIENT=n` +- `CLIENT_CERT_DURATION_DAYS=3650` +- `SERVER_CERT_DURATION_DAYS=3650` If the server is behind NAT, you can specify its endpoint with the `ENDPOINT` variable. If the endpoint is the public IP address which it is behind, you can use `ENDPOINT=$(curl -4 ifconfig.co)` (the script will default to this). The endpoint can be an IPv4 or a domain. @@ -96,6 +125,8 @@ export PASS="1" ## Features - Installs and configures a ready-to-use OpenVPN server +- Certificate renewal for both client and server certificates +- Uses [official OpenVPN repositories](https://community.openvpn.net/openvpn/wiki/OpenvpnSoftwareRepos) when possible for the latest stable releases - Iptables rules and forwarding managed in a seamless way - If needed, the script can cleanly remove OpenVPN, including configuration and iptables rules - Customisable encryption settings, enhanced default settings (see [Security and Encryption](#security-and-encryption) below) @@ -109,31 +140,32 @@ export PASS="1" - Block DNS leaks on Windows 10 - Randomised server certificate name - Choice to protect clients with a password (private key encryption) +- Option to allow multiple devices to use the same client profile simultaneously (disables persistent IP addresses) - Many other little things! ## Compatibility -The script supports these OS and architectures: +The script supports these Linux distributions: -| | i386 | amd64 | armhf | arm64 | -| --------------- | ---- | ----- | ----- | ----- | -| Amazon Linux 2 | ❔ | ✅ | ❔ | ❔ | -| Arch Linux | ❔ | ✅ | ❔ | ✅ | -| CentOS 7 | ✅ | ✅ | ✅ | ✅ | -| CentOS 8 | ❌ | ✅ | ❌ | ✅ | -| Debian >= 9 | ✅ | ✅ | ✅ | ✅ | -| Fedora >= 27 | ❔ | ✅ | ❔ | ❔ | -| Ubuntu 16.04 | ✅ | ✅ | ❌ | ❌ | -| Ubuntu >= 18.04 | ✅ | ✅ | ✅ | ✅ | -| Oracle Linux 8 | ❌ | ✅ | ❌ | ❔ | -| Rocky Linux 8 | ❔ | ✅ | ❔ | ❔ | -| AlmaLinux 8 | ❌ | ✅ | ❌ | ❔ | +| | Support | +| ------------------- | ------- | +| AlmaLinux >= 8 | ✅ 🤖 | +| Amazon Linux 2023 | ✅ 🤖 | +| Arch Linux | ✅ 🤖 | +| CentOS Stream >= 8 | ✅ 🤖 | +| Debian >= 11 | ✅ 🤖 | +| Fedora >= 40 | ✅ 🤖 | +| openSUSE Leap >= 16 | ✅ 🤖 | +| openSUSE Tumbleweed | ✅ 🤖 | +| Oracle Linux >= 8 | ✅ 🤖 | +| Rocky Linux >= 8 | ✅ 🤖 | +| Ubuntu >= 18.04 | ✅ 🤖 | To be noted: -- It should work on Debian 8+ and Ubuntu 16.04+. But versions not in the table above are not officially supported. +- The script is regularly tested against the distributions marked with a 🤖 only. + - It's only tested on `amd64` architecture. - The script requires `systemd`. -- The script is regularly tested against `amd64` only. ## Fork @@ -149,10 +181,9 @@ More Q&A in [FAQ.md](FAQ.md). **A:** I recommend these: -- [Vultr](https://www.vultr.com/?ref=8537055-6G): Worldwide locations, IPv6 support, starting at \$3.50/month -- [Hetzner](https://hetzner.cloud/?ref=ywtlvZsjgeDq): Germany, IPv6, 20 TB of traffic, starting at €3/month -- [Digital Ocean](https://goo.gl/qXrNLK): Worldwide locations, IPv6 support, starting at \$5/month -- [PulseHeberg](https://goo.gl/76yqW5): France, unlimited bandwidth, starting at €3/month +- [Vultr](https://www.vultr.com/?ref=8948982-8H): Worldwide locations, IPv6 support, starting at \$5/month +- [Hetzner](https://hetzner.cloud/?ref=ywtlvZsjgeDq): Germany, Finland and USA. IPv6, 20 TB of traffic, starting at 4.5€/month +- [Digital Ocean](https://m.do.co/c/ed0ba143fe53): Worldwide locations, IPv6 support, starting at \$4/month --- @@ -162,7 +193,7 @@ More Q&A in [FAQ.md](FAQ.md). - Windows: [The official OpenVPN community client](https://openvpn.net/index.php/download/community-downloads.html). - Linux: The `openvpn` package from your distribution. There is an [official APT repository](https://community.openvpn.net/openvpn/wiki/OpenvpnSoftwareRepos) for Debian/Ubuntu based distributions. -- macOS: [Tunnelblick](https://tunnelblick.net/), [Viscosity](https://www.sparklabs.com/viscosity/). +- macOS: [Tunnelblick](https://tunnelblick.net/), [Viscosity](https://www.sparklabs.com/viscosity/), [OpenVPN for Mac](https://openvpn.net/client-connect-vpn-for-mac-os/). - Android: [OpenVPN for Android](https://play.google.com/store/apps/details?id=de.blinkt.openvpn). - iOS: [The official OpenVPN Connect client](https://itunes.apple.com/us/app/openvpn-connect/id590379981). @@ -182,38 +213,46 @@ More Q&A in [FAQ.md](FAQ.md). More Q&A in [FAQ.md](FAQ.md). -## One-stop solutions for public cloud - -Solutions that provision a ready to use OpenVPN server based on this script in one go are available for: - -- AWS using Terraform at [`openvpn-terraform-install`](https://github.com/dumrauf/openvpn-terraform-install) -- Terraform AWS module [`openvpn-ephemeral`](https://registry.terraform.io/modules/paulmarsicloud/openvpn-ephemeral/aws/latest) - ## Contributing +## Discuss changes + +Please open an issue before submitting a PR if you want to discuss a change, especially if it's a big one. + ### Code formatting -We use [shellcheck](https://github.com/koalaman/shellcheck) and [shfmt](https://github.com/mvdan/sh) to enforce bash styling guidelines and good practices. They are executed for each commit / PR with GitHub Actions, so you can check the configuration [here](https://github.com/angristan/openvpn-install/blob/master/.github/workflows/push.yml). +We use [shellcheck](https://github.com/koalaman/shellcheck) and [shfmt](https://github.com/mvdan/sh) to enforce Bash styling guidelines and good practices. They are executed for each commit / PR with GitHub Actions, so you can check the [lint workflow configuration](https://github.com/angristan/openvpn-install/blob/master/.github/workflows/lint.yml). ## Security and Encryption -OpenVPN's default settings are pretty weak regarding encryption. This script aims to improve that. +> [!NOTE] +> This script was created in 2016 when OpenVPN's defaults were quite weak. Back then, customising encryption settings was essential for a secure setup. Since then, OpenVPN has significantly improved its defaults, but the script still offers customisation options. -OpenVPN 2.4 was a great update regarding encryption. It added support for ECDSA, ECDH, AES GCM, NCP and tls-crypt. +OpenVPN 2.3 and earlier shipped with outdated defaults like Blowfish (BF-CBC), TLS 1.0, and SHA1. Each major release since has brought significant improvements: + +- **OpenVPN 2.4** (2016): Added ECDSA, ECDH, AES-GCM, NCP (cipher negotiation), and tls-crypt +- **OpenVPN 2.5** (2020): Default cipher changed from BF-CBC to AES-256-GCM:AES-128-GCM, added ChaCha20-Poly1305, tls-crypt-v2, and TLS 1.3 support +- **OpenVPN 2.6** (2023): TLS 1.2 minimum by default, compression blocked by default, `--peer-fingerprint` for PKI-less setups, and DCO kernel acceleration If you want more information about an option mentioned below, head to the [OpenVPN manual](https://community.openvpn.net/openvpn/wiki/Openvpn24ManPage). It is very complete. -Most of OpenVPN's encryption-related stuff is managed by [Easy-RSA](https://github.com/OpenVPN/easy-rsa). Defaults parameters are in the [vars.example](https://github.com/OpenVPN/easy-rsa/blob/v3.0.7/easyrsa3/vars.example) file. +Certificate and PKI management is handled by [Easy-RSA](https://github.com/OpenVPN/easy-rsa). Default parameters are in the [vars.example](https://github.com/OpenVPN/easy-rsa/blob/v3.2.2/easyrsa3/vars.example) file. ### Compression +> [!NOTE] +> OpenVPN 2.6+ defaults `--allow-compression` to `no`, which blocks even server-pushed compression. Prior versions allowed servers to push compression settings to clients. + By default, OpenVPN doesn't enable compression. This script provides support for LZ0 and LZ4 (v1/v2) algorithms, the latter being more efficient. However, it is discouraged to use compression since the [VORACLE attack](https://protonvpn.com/blog/voracle-attack/) makes use of it. ### TLS version -OpenVPN accepts TLS 1.0 by default, which is nearly [20 years old](https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_1.0). +> [!NOTE] +> OpenVPN 2.6+ defaults to TLS 1.2 minimum. Prior versions accepted TLS 1.0 by default. + +OpenVPN 2.5 and earlier accepted TLS 1.0 by default, which is nearly [20 years old](https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_1.0). With `tls-version-min 1.2` we enforce TLS 1.2, which the best protocol available currently for OpenVPN. @@ -236,7 +275,10 @@ OpenVPN uses `SHA-256` as the signature hash by default, and so does the script. ### Data channel -By default, OpenVPN uses `BF-CBC` as the data channel cipher. Blowfish is an old (1993) and weak algorithm. Even the official OpenVPN documentation admits it. +> [!NOTE] +> The default data channel cipher changed in OpenVPN 2.5. Prior versions defaulted to `BF-CBC`, while OpenVPN 2.5+ defaults to `AES-256-GCM:AES-128-GCM`. OpenVPN 2.6+ also includes `CHACHA20-POLY1305` in the default cipher list when available. + +By default, OpenVPN 2.4 and earlier used `BF-CBC` as the data channel cipher. Blowfish is an old (1993) and weak algorithm. Even the official OpenVPN documentation admits it. > The default is BF-CBC, an abbreviation for Blowfish in Cipher Block Chaining mode. > @@ -253,6 +295,8 @@ AES-256 is 40% slower than AES-128, and there isn't any real reason to use a 256 AES-GCM is an [AEAD cipher](https://en.wikipedia.org/wiki/Authenticated_encryption) which means it simultaneously provides confidentiality, integrity, and authenticity assurances on the data. +ChaCha20-Poly1305 is another AEAD cipher that provides similar security to AES-GCM. It is particularly useful on devices without hardware AES acceleration (AES-NI), such as older CPUs and many ARM-based devices, where it can be significantly faster than AES. + The script supports the following ciphers: - `AES-128-GCM` @@ -261,10 +305,11 @@ The script supports the following ciphers: - `AES-128-CBC` - `AES-192-CBC` - `AES-256-CBC` +- `CHACHA20-POLY1305` (requires OpenVPN 2.5+) And defaults to `AES-128-GCM`. -OpenVPN 2.4 added a feature called "NCP": _Negotiable Crypto Parameters_. It means you can provide a cipher suite like with HTTPS. It is set to `AES-256-GCM:AES-128-GCM` by default and overrides the `--cipher` parameter when used with an OpenVPN 2.4 client. For the sake of simplicity, the script set both the `--cipher` and `--ncp-cipher` to the cipher chosen above. +OpenVPN 2.4 added a feature called "NCP": _Negotiable Crypto Parameters_. It means you can provide a cipher suite like with HTTPS. It is set to `AES-256-GCM:AES-128-GCM` by default and overrides the `--cipher` parameter when used with an OpenVPN 2.4 client. For the sake of simplicity, the script sets `--cipher` (fallback for non-NCP clients), `--data-ciphers` (modern OpenVPN 2.5+ naming), and `--ncp-ciphers` (legacy alias for OpenVPN 2.4 compatibility) to the cipher chosen above. ### Control channel @@ -275,9 +320,11 @@ The script proposes the following options, depending on the certificate: - ECDSA: - `TLS-ECDHE-ECDSA-WITH-AES-128-GCM-SHA256` - `TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384` + - `TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256` (requires OpenVPN 2.5+) - RSA: - `TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256` - `TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384` + - `TLS-ECDHE-RSA-WITH-CHACHA20-POLY1305-SHA256` (requires OpenVPN 2.5+) It defaults to `TLS-ECDHE-*-WITH-AES-128-GCM-SHA256`. @@ -312,7 +359,7 @@ The script provides the following choices: It defaults to `SHA256`. -### `tls-auth` and `tls-crypt` +### `tls-auth`, `tls-crypt`, and `tls-crypt-v2` From the OpenVPN wiki, about `tls-auth`: @@ -334,14 +381,54 @@ So both provide an additional layer of security and mitigate DoS attacks. They a `tls-crypt` is an OpenVPN 2.4 feature that provides encryption in addition to authentication (unlike `tls-auth`). It is more privacy-friendly. -The script supports both and uses `tls-crypt` by default. +`tls-crypt-v2` is an OpenVPN 2.5 feature that builds on `tls-crypt` by using **per-client keys** instead of a shared key. Each client receives a unique key derived from a server key. This provides: + +- **Better security**: If a client key is compromised, other clients are not affected +- **Easier key management**: Client keys can be revoked individually without regenerating the server key +- **Scalability**: Better suited for large deployments with many clients + +The script supports all three options: + +- `tls-crypt-v2` (default): Per-client keys for better security +- `tls-crypt`: Shared key for all clients, compatible with OpenVPN 2.4+ +- `tls-auth`: HMAC authentication only (no encryption), compatible with older clients + +### Certificate type verification (`remote-cert-tls`) + +The server is configured with `remote-cert-tls client`, which requires connecting peers to have a certificate with the "TLS Web Client Authentication" extended key usage. This prevents a server certificate from being used to impersonate a client. + +Similarly, clients are configured with `remote-cert-tls server` to ensure they only connect to servers presenting valid server certificates. This protects against an attacker with a valid client certificate setting up a rogue server. + +### Data Channel Offload (DCO) + +[Data Channel Offload](https://openvpn.net/as-docs/openvpn-data-channel-offload.html) (DCO) is a kernel acceleration feature that significantly improves OpenVPN performance by keeping data channel encryption/decryption in kernel space, eliminating costly context switches between user and kernel space for each packet. + +DCO was merged into the Linux kernel 6.16 (April 2025). + +**Requirements:** + +- OpenVPN 2.6.0 or later +- Linux kernel 6.16+ (built-in) or `ovpn-dco` kernel module +- UDP protocol (TCP is not supported) +- AEAD cipher (`AES-128-GCM`, `AES-256-GCM`, or `CHACHA20-POLY1305`) +- Compression disabled + +The script's default settings (AES-128-GCM, UDP, no compression) are DCO-compatible. When DCO is available and the configuration is compatible, OpenVPN will automatically use it for improved performance. + +**Note:** DCO must be supported on both the server and the client for full acceleration. Client support is available in OpenVPN 2.6+ (Linux, Windows, FreeBSD) and OpenVPN Connect 3.4+ (Windows). macOS does not currently support DCO, but clients can still connect to DCO-enabled servers with partial performance benefits on the server-side. + +The script will display the DCO availability status during installation. ## Say thanks -You can [say thanks](https://saythanks.io/to/angristan%40pm.me) if you want! +You can [say thanks](https://saythanks.io/to/angristan) if you want! ## Credits & Licence Many thanks to the [contributors](https://github.com/Angristan/OpenVPN-install/graphs/contributors) and Nyr's original work. This project is under the [MIT Licence](https://raw.githubusercontent.com/Angristan/openvpn-install/master/LICENSE) + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=angristan/openvpn-install&type=Date)](https://star-history.com/#angristan/openvpn-install&Date) diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..cd69666 --- /dev/null +++ b/biome.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "formatter": { + "indentStyle": "space", + "indentWidth": 2 + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7b90969 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +--- +services: + openvpn-server: + build: + context: . + dockerfile: test/Dockerfile.server + args: + BASE_IMAGE: ${BASE_IMAGE:-ubuntu:24.04} + container_name: openvpn-server + hostname: openvpn-server + privileged: true + cgroupns: host + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + - net.ipv4.ip_forward=1 + volumes: + - shared-config:/shared + - /sys/fs/cgroup:/sys/fs/cgroup:rw + tmpfs: + - /run + - /run/lock + networks: + vpn-test: + ipv4_address: 172.28.0.10 + stop_signal: SIGRTMIN+3 + healthcheck: + test: ["CMD", "pgrep", "openvpn"] + interval: 5s + timeout: 3s + retries: 30 + + openvpn-client: + build: + context: . + dockerfile: test/Dockerfile.client + container_name: openvpn-client + hostname: openvpn-client + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + volumes: + - shared-config:/shared + networks: + vpn-test: + ipv4_address: 172.28.0.20 + depends_on: + openvpn-server: + condition: service_healthy + +volumes: + shared-config: + +networks: + vpn-test: + driver: bridge + ipam: + config: + - subnet: 172.28.0.0/24 diff --git a/openvpn-install.sh b/openvpn-install.sh index dbc255b..5332ab6 100755 --- a/openvpn-install.sh +++ b/openvpn-install.sh @@ -1,9 +1,162 @@ #!/bin/bash -# shellcheck disable=SC1091,SC2164,SC2034,SC1072,SC1073,SC1009 +# shellcheck disable=SC1091,SC2034 +# SC1091: Not following /etc/os-release (sourced dynamically) +# SC2034: Variables used indirectly or exported for subprocesses -# Secure OpenVPN server installer for Debian, Ubuntu, CentOS, Amazon Linux 2, Fedora, Oracle Linux 8, Arch Linux, Rocky Linux and AlmaLinux. +# Secure OpenVPN server installer for Debian, Ubuntu, CentOS, Amazon Linux 2023, Fedora, Oracle Linux, Arch Linux, Rocky Linux and AlmaLinux. # https://github.com/angristan/openvpn-install +# Configuration constants +readonly DEFAULT_CERT_VALIDITY_DURATION_DAYS=3650 # 10 years +readonly DEFAULT_CRL_VALIDITY_DURATION_DAYS=5475 # 15 years +readonly EASYRSA_VERSION="3.2.4" +readonly EASYRSA_SHA256="ed65e88cea892268efa71eb1161ce13af3beded6754301e1e713e36ff3613cac" + +# ============================================================================= +# Logging Configuration +# ============================================================================= +# Set VERBOSE=1 to see command output, VERBOSE=0 (default) for quiet mode +# Set LOG_FILE to customize log location (default: openvpn-install.log in current dir) +# Set LOG_FILE="" to disable file logging +VERBOSE=${VERBOSE:-0} +LOG_FILE=${LOG_FILE:-openvpn-install.log} + +# Color definitions (disabled if not a terminal, unless FORCE_COLOR=1) +if [[ -t 1 ]] || [[ $FORCE_COLOR == "1" ]]; then + readonly COLOR_RESET='\033[0m' + readonly COLOR_RED='\033[0;31m' + readonly COLOR_GREEN='\033[0;32m' + readonly COLOR_YELLOW='\033[0;33m' + readonly COLOR_BLUE='\033[0;34m' + readonly COLOR_CYAN='\033[0;36m' + readonly COLOR_DIM='\033[0;90m' + readonly COLOR_BOLD='\033[1m' +else + readonly COLOR_RESET='' + readonly COLOR_RED='' + readonly COLOR_GREEN='' + readonly COLOR_YELLOW='' + readonly COLOR_BLUE='' + readonly COLOR_CYAN='' + readonly COLOR_DIM='' + readonly COLOR_BOLD='' +fi + +# Write to log file (no colors, with timestamp) +_log_to_file() { + if [[ -n "$LOG_FILE" ]]; then + echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >>"$LOG_FILE" + fi +} + +# Logging functions +log_info() { + echo -e "${COLOR_BLUE}[INFO]${COLOR_RESET} $*" + _log_to_file "[INFO] $*" +} + +log_warn() { + echo -e "${COLOR_YELLOW}[WARN]${COLOR_RESET} $*" + _log_to_file "[WARN] $*" +} + +log_error() { + echo -e "${COLOR_RED}[ERROR]${COLOR_RESET} $*" >&2 + _log_to_file "[ERROR] $*" + if [[ -n "$LOG_FILE" ]]; then + echo -e "${COLOR_YELLOW} Check the log file for details: ${LOG_FILE}${COLOR_RESET}" >&2 + fi +} + +log_fatal() { + echo -e "${COLOR_RED}[ERROR]${COLOR_RESET} $*" >&2 + _log_to_file "[FATAL] $*" + if [[ -n "$LOG_FILE" ]]; then + echo -e "${COLOR_YELLOW} Check the log file for details: ${LOG_FILE}${COLOR_RESET}" >&2 + _log_to_file "Script exited with error" + fi + exit 1 +} + +log_success() { + echo -e "${COLOR_GREEN}[OK]${COLOR_RESET} $*" + _log_to_file "[OK] $*" +} + +log_debug() { + if [[ $VERBOSE -eq 1 ]]; then + echo -e "${COLOR_DIM}[DEBUG]${COLOR_RESET} $*" + fi + _log_to_file "[DEBUG] $*" +} + +log_prompt() { + # For user-facing prompts/questions (no prefix, just cyan) + # Skip display in auto-install mode + if [[ $AUTO_INSTALL != "y" ]]; then + echo -e "${COLOR_CYAN}$*${COLOR_RESET}" + fi + _log_to_file "[PROMPT] $*" +} + +log_header() { + # For section headers + # Skip display in auto-install mode + if [[ $AUTO_INSTALL != "y" ]]; then + echo "" + echo -e "${COLOR_BOLD}${COLOR_BLUE}=== $* ===${COLOR_RESET}" + echo "" + fi + _log_to_file "=== $* ===" +} + +log_menu() { + # For menu options - only show in interactive mode + if [[ $AUTO_INSTALL != "y" ]]; then + echo "$@" + fi +} + +# Run a command with optional output suppression +# Usage: run_cmd "description" command [args...] +run_cmd() { + local desc="$1" + shift + # Display the command being run + echo -e "${COLOR_DIM}> $*${COLOR_RESET}" + _log_to_file "[CMD] $*" + if [[ $VERBOSE -eq 1 ]]; then + if [[ -n "$LOG_FILE" ]]; then + "$@" 2>&1 | tee -a "$LOG_FILE" + else + "$@" + fi + else + if [[ -n "$LOG_FILE" ]]; then + "$@" >>"$LOG_FILE" 2>&1 + else + "$@" >/dev/null 2>&1 + fi + fi + local ret=$? + if [[ $ret -eq 0 ]]; then + log_debug "$desc completed successfully" + else + log_error "$desc failed with exit code $ret" + fi + return $ret +} + +# Run a command that must succeed, exit on failure +# Usage: run_cmd_fatal "description" command [args...] +run_cmd_fatal() { + local desc="$1" + shift + if ! run_cmd "$desc" "$@"; then + log_fatal "$desc failed" + fi +} + function isRoot() { if [ "$EUID" -ne 0 ]; then return 1 @@ -22,11 +175,9 @@ function checkOS() { source /etc/os-release if [[ $ID == "debian" || $ID == "raspbian" ]]; then - if [[ $VERSION_ID -lt 9 ]]; then - echo "⚠️ Your version of Debian is not supported." - echo "" - echo "However, if you're using Debian >= 9 or unstable/testing then you can continue, at your own risk." - echo "" + if [[ $VERSION_ID -lt 11 ]]; then + log_warn "Your version of Debian is not supported." + log_info "However, if you're using Debian >= 11 or unstable/testing, you can continue at your own risk." until [[ $CONTINUE =~ (y|n) ]]; do read -rp "Continue? [y/n]: " -e CONTINUE done @@ -37,11 +188,9 @@ function checkOS() { elif [[ $ID == "ubuntu" ]]; then OS="ubuntu" MAJOR_UBUNTU_VERSION=$(echo "$VERSION_ID" | cut -d '.' -f1) - if [[ $MAJOR_UBUNTU_VERSION -lt 16 ]]; then - echo "⚠️ Your version of Ubuntu is not supported." - echo "" - echo "However, if you're using Ubuntu >= 16.04 or beta, then you can continue, at your own risk." - echo "" + if [[ $MAJOR_UBUNTU_VERSION -lt 18 ]]; then + log_warn "Your version of Ubuntu is not supported." + log_info "However, if you're using Ubuntu >= 18.04 or beta, you can continue at your own risk." until [[ $CONTINUE =~ (y|n) ]]; do read -rp "Continue? [y/n]: " -e CONTINUE done @@ -50,182 +199,376 @@ function checkOS() { fi fi fi - elif [[ -e /etc/system-release ]]; then + elif [[ -e /etc/os-release ]]; then source /etc/os-release if [[ $ID == "fedora" || $ID_LIKE == "fedora" ]]; then OS="fedora" fi + if [[ $ID == "opensuse-tumbleweed" ]]; then + OS="opensuse" + fi + if [[ $ID == "opensuse-leap" ]]; then + OS="opensuse" + if [[ ${VERSION_ID%.*} -lt 16 ]]; then + log_info "The script only supports openSUSE Leap 16+." + log_fatal "Your version of openSUSE Leap is not supported." + fi + fi if [[ $ID == "centos" || $ID == "rocky" || $ID == "almalinux" ]]; then OS="centos" - if [[ ! $VERSION_ID =~ (7|8) ]]; then - echo "⚠️ Your version of CentOS is not supported." - echo "" - echo "The script only support CentOS 7 and CentOS 8." - echo "" - exit 1 - fi fi if [[ $ID == "ol" ]]; then OS="oracle" - if [[ ! $VERSION_ID =~ (8) ]]; then - echo "Your version of Oracle Linux is not supported." - echo "" - echo "The script only support Oracle Linux 8." - exit 1 - fi + fi + if [[ $OS =~ (centos|oracle) ]] && [[ ${VERSION_ID%.*} -lt 8 ]]; then + log_info "The script only supports CentOS Stream / Rocky Linux / AlmaLinux / Oracle Linux version 8+." + log_fatal "Your version is not supported." fi if [[ $ID == "amzn" ]]; then - OS="amzn" - if [[ $VERSION_ID != "2" ]]; then - echo "⚠️ Your version of Amazon Linux is not supported." - echo "" - echo "The script only support Amazon Linux 2." - echo "" - exit 1 + if [[ "$(echo "$PRETTY_NAME" | cut -c 1-18)" == "Amazon Linux 2023." ]] && [[ "$(echo "$PRETTY_NAME" | cut -c 19)" -ge 6 ]]; then + OS="amzn2023" + else + log_info "The script only supports Amazon Linux 2023.6+" + log_info "Amazon Linux 2 is EOL and no longer supported." + log_fatal "Your version of Amazon Linux is not supported." fi fi + if [[ $ID == "arch" ]]; then + OS="arch" + fi elif [[ -e /etc/arch-release ]]; then OS=arch else - echo "Looks like you aren't running this installer on a Debian, Ubuntu, Fedora, CentOS, Amazon Linux 2, Oracle Linux 8 or Arch Linux system" - exit 1 + log_fatal "It looks like you aren't running this installer on a Debian, Ubuntu, Fedora, openSUSE, CentOS, Amazon Linux 2023, Oracle Linux, Arch Linux, Rocky Linux or AlmaLinux system." fi } +function checkArchPendingKernelUpgrade() { + if [[ $OS != "arch" ]]; then + return 0 + fi + + # Check if running kernel's modules are available + # (detects if kernel was upgraded but system not rebooted) + # Skip this check in containers - they share host kernel but have their own /lib/modules + if [[ -f /.dockerenv ]] || grep -qE '(docker|lxc|containerd)' /proc/1/cgroup 2>/dev/null; then + log_info "Running in container, skipping kernel modules check" + else + local running_kernel + running_kernel=$(uname -r) + if [[ ! -d "/lib/modules/${running_kernel}" ]]; then + log_error "Kernel modules for running kernel ($running_kernel) not found!" + log_info "This usually means the kernel was upgraded but the system wasn't rebooted." + log_fatal "Please reboot your system and run this script again." + fi + fi + + log_info "Checking for pending kernel upgrades on Arch Linux..." + + # Sync package database to check for updates + if ! pacman -Sy &>/dev/null; then + log_warn "Failed to sync package database, skipping kernel upgrade check" + return 0 + fi + + # Check for pending linux kernel upgrades + local pending_kernels + pending_kernels=$(pacman -Qu 2>/dev/null | grep -E '^linux' || true) + + if [[ -n "$pending_kernels" ]]; then + log_warn "Linux kernel upgrade(s) pending:" + echo "$pending_kernels" | while read -r line; do + log_info " $line" + done + echo "" + log_info "This script uses 'pacman -Syu' which will upgrade your kernel." + log_info "After a kernel upgrade, the TUN module won't be available until you reboot." + echo "" + log_info "Please upgrade your system and reboot first:" + log_info " sudo pacman -Syu" + log_info " sudo reboot" + echo "" + log_fatal "Aborting. Run this script again after upgrading and rebooting." + fi + + log_success "No pending kernel upgrades" +} + function initialCheck() { + log_debug "Checking root privileges..." if ! isRoot; then - echo "Sorry, you need to run this as root" - exit 1 + log_fatal "Sorry, you need to run this script as root." fi + log_debug "Root check passed" + + log_debug "Checking TUN device availability..." if ! tunAvailable; then - echo "TUN is not available" - exit 1 + log_fatal "TUN is not available." fi + log_debug "TUN device available at /dev/net/tun" + + log_debug "Detecting operating system..." checkOS + log_info "Detected OS: $OS (${PRETTY_NAME:-unknown})" + checkArchPendingKernelUpgrade +} + +# Check if OpenVPN version is at least the specified version +# Usage: openvpnVersionAtLeast "2.5" +# Returns 0 if version is >= specified, 1 otherwise +function openvpnVersionAtLeast() { + local required_version="$1" + local installed_version + + if ! command -v openvpn &>/dev/null; then + return 1 + fi + + installed_version=$(openvpn --version 2>/dev/null | head -1 | awk '{print $2}') + if [[ -z "$installed_version" ]]; then + return 1 + fi + + # Compare versions using sort -V + if [[ "$(printf '%s\n' "$required_version" "$installed_version" | sort -V | head -n1)" == "$required_version" ]]; then + return 0 + fi + return 1 +} + +# Check if kernel version is at least the specified version +# Usage: kernelVersionAtLeast "6.16" +# Returns 0 if version is >= specified, 1 otherwise +function kernelVersionAtLeast() { + local required_version="$1" + local kernel_version + + kernel_version=$(uname -r | cut -d'-' -f1) + if [[ -z "$kernel_version" ]]; then + return 1 + fi + + if [[ "$(printf '%s\n' "$required_version" "$kernel_version" | sort -V | head -n1)" == "$required_version" ]]; then + return 0 + fi + return 1 +} + +# Check if Data Channel Offload (DCO) is available +# DCO requires: OpenVPN 2.6+, kernel support (Linux 6.16+ or ovpn-dco module) +# Returns 0 if DCO is available, 1 otherwise +function isDCOAvailable() { + # DCO requires OpenVPN 2.6+ + if ! openvpnVersionAtLeast "2.6"; then + return 1 + fi + + # DCO is built into Linux 6.16+, or available via ovpn-dco module + if kernelVersionAtLeast "6.16"; then + return 0 + elif lsmod 2>/dev/null | grep -q "^ovpn_dco" || modinfo ovpn-dco &>/dev/null; then + return 0 + fi + return 1 +} + +function installOpenVPNRepo() { + log_info "Setting up official OpenVPN repository..." + + if [[ $OS =~ (debian|ubuntu) ]]; then + run_cmd "Update package lists" apt-get update + run_cmd_fatal "Installing prerequisites" apt-get install -y ca-certificates curl + + # Create keyrings directory + run_cmd "Creating keyrings directory" mkdir -p /etc/apt/keyrings + + # Download and install GPG key + if ! run_cmd "Downloading OpenVPN GPG key" curl -fsSL https://swupdate.openvpn.net/repos/repo-public.gpg -o /etc/apt/keyrings/openvpn-repo-public.asc; then + log_fatal "Failed to download OpenVPN repository GPG key" + fi + + # Add repository - using stable release + if [[ -z "${VERSION_CODENAME}" ]]; then + log_fatal "VERSION_CODENAME is not set. Unable to configure OpenVPN repository." + fi + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/openvpn-repo-public.asc] https://build.openvpn.net/debian/openvpn/stable ${VERSION_CODENAME} main" >/etc/apt/sources.list.d/openvpn-aptrepo.list + + log_info "Updating package lists with new repository..." + run_cmd "Update package lists" apt-get update + + log_info "OpenVPN official repository configured" + + elif [[ $OS =~ (centos|oracle) ]]; then + # For RHEL-based systems, use Fedora Copr (OpenVPN 2.6 stable) + # EPEL is required for pkcs11-helper dependency + log_info "Configuring OpenVPN Copr repository for RHEL-based system..." + + # Oracle Linux uses oracle-epel-release-el* instead of epel-release + if [[ $OS == "oracle" ]]; then + EPEL_PACKAGE="oracle-epel-release-el${VERSION_ID%.*}" + else + EPEL_PACKAGE="epel-release" + fi + + if ! command -v dnf &>/dev/null; then + run_cmd_fatal "Installing EPEL repository" yum install -y "$EPEL_PACKAGE" + run_cmd_fatal "Installing yum-plugin-copr" yum install -y yum-plugin-copr + run_cmd_fatal "Enabling OpenVPN Copr repo" yum copr enable -y @OpenVPN/openvpn-release-2.6 + else + run_cmd_fatal "Installing EPEL repository" dnf install -y "$EPEL_PACKAGE" + run_cmd_fatal "Installing dnf-plugins-core" dnf install -y dnf-plugins-core + run_cmd_fatal "Enabling OpenVPN Copr repo" dnf copr enable -y @OpenVPN/openvpn-release-2.6 + fi + + log_info "OpenVPN Copr repository configured" + + elif [[ $OS == "fedora" ]]; then + # Fedora already ships with recent OpenVPN 2.6.x, no Copr needed + log_info "Fedora already has recent OpenVPN packages, using distribution version" + + else + log_info "No official OpenVPN repository available for this OS, using distribution packages" + fi } function installUnbound() { - # If Unbound isn't installed, install it + log_info "Installing Unbound DNS resolver..." + + # Install Unbound if not present if [[ ! -e /etc/unbound/unbound.conf ]]; then - if [[ $OS =~ (debian|ubuntu) ]]; then - apt-get install -y unbound - - # Configuration - echo 'interface: 10.8.0.1 -access-control: 10.8.0.1/24 allow -hide-identity: yes -hide-version: yes -use-caps-for-id: yes -prefetch: yes' >>/etc/unbound/unbound.conf - - elif [[ $OS =~ (centos|amzn|oracle) ]]; then - yum install -y unbound - - # Configuration - sed -i 's|# interface: 0.0.0.0$|interface: 10.8.0.1|' /etc/unbound/unbound.conf - sed -i 's|# access-control: 127.0.0.0/8 allow|access-control: 10.8.0.1/24 allow|' /etc/unbound/unbound.conf - sed -i 's|# hide-identity: no|hide-identity: yes|' /etc/unbound/unbound.conf - sed -i 's|# hide-version: no|hide-version: yes|' /etc/unbound/unbound.conf - sed -i 's|use-caps-for-id: no|use-caps-for-id: yes|' /etc/unbound/unbound.conf - - elif [[ $OS == "fedora" ]]; then - dnf install -y unbound - - # Configuration - sed -i 's|# interface: 0.0.0.0$|interface: 10.8.0.1|' /etc/unbound/unbound.conf - sed -i 's|# access-control: 127.0.0.0/8 allow|access-control: 10.8.0.1/24 allow|' /etc/unbound/unbound.conf - sed -i 's|# hide-identity: no|hide-identity: yes|' /etc/unbound/unbound.conf - sed -i 's|# hide-version: no|hide-version: yes|' /etc/unbound/unbound.conf - sed -i 's|# use-caps-for-id: no|use-caps-for-id: yes|' /etc/unbound/unbound.conf - + run_cmd_fatal "Installing Unbound" apt-get install -y unbound + elif [[ $OS =~ (centos|oracle) ]]; then + run_cmd_fatal "Installing Unbound" yum install -y unbound + elif [[ $OS =~ (fedora|amzn2023) ]]; then + run_cmd_fatal "Installing Unbound" dnf install -y unbound + elif [[ $OS == "opensuse" ]]; then + run_cmd_fatal "Installing Unbound" zypper install -y unbound elif [[ $OS == "arch" ]]; then - pacman -Syu --noconfirm unbound - - # Get root servers list - curl -o /etc/unbound/root.hints https://www.internic.net/domain/named.cache - - if [[ ! -f /etc/unbound/unbound.conf.old ]]; then - mv /etc/unbound/unbound.conf /etc/unbound/unbound.conf.old - fi - - echo 'server: - use-syslog: yes - do-daemonize: no - username: "unbound" - directory: "/etc/unbound" - trust-anchor-file: trusted-key.key - root-hints: root.hints - interface: 10.8.0.1 - access-control: 10.8.0.1/24 allow - port: 53 - num-threads: 2 - use-caps-for-id: yes - harden-glue: yes - hide-identity: yes - hide-version: yes - qname-minimisation: yes - prefetch: yes' >/etc/unbound/unbound.conf - fi - - # IPv6 DNS for all OS - if [[ $IPV6_SUPPORT == 'y' ]]; then - echo 'interface: fd42:42:42:42::1 -access-control: fd42:42:42:42::/112 allow' >>/etc/unbound/unbound.conf - fi - - if [[ ! $OS =~ (fedora|centos|amzn|oracle) ]]; then - # DNS Rebinding fix - echo "private-address: 10.0.0.0/8 -private-address: fd42:42:42:42::/112 -private-address: 172.16.0.0/12 -private-address: 192.168.0.0/16 -private-address: 169.254.0.0/16 -private-address: fd00::/8 -private-address: fe80::/10 -private-address: 127.0.0.0/8 -private-address: ::ffff:0:0/96" >>/etc/unbound/unbound.conf - fi - else # Unbound is already installed - echo 'include: /etc/unbound/openvpn.conf' >>/etc/unbound/unbound.conf - - # Add Unbound 'server' for the OpenVPN subnet - echo 'server: -interface: 10.8.0.1 -access-control: 10.8.0.1/24 allow -hide-identity: yes -hide-version: yes -use-caps-for-id: yes -prefetch: yes -private-address: 10.0.0.0/8 -private-address: fd42:42:42:42::/112 -private-address: 172.16.0.0/12 -private-address: 192.168.0.0/16 -private-address: 169.254.0.0/16 -private-address: fd00::/8 -private-address: fe80::/10 -private-address: 127.0.0.0/8 -private-address: ::ffff:0:0/96' >/etc/unbound/openvpn.conf - if [[ $IPV6_SUPPORT == 'y' ]]; then - echo 'interface: fd42:42:42:42::1 -access-control: fd42:42:42:42::/112 allow' >>/etc/unbound/openvpn.conf + run_cmd_fatal "Installing Unbound" pacman -Syu --noconfirm unbound fi fi - systemctl enable unbound - systemctl restart unbound + # Configure Unbound for OpenVPN (runs whether freshly installed or pre-existing) + # Create conf.d directory (works on all distros) + run_cmd "Creating Unbound config directory" mkdir -p /etc/unbound/unbound.conf.d + + # Ensure main config includes conf.d directory + # Modern Debian/Ubuntu use include-toplevel, others need include directive + if ! grep -qE "include(-toplevel)?:\s*.*/etc/unbound/unbound.conf.d" /etc/unbound/unbound.conf 2>/dev/null; then + # Add include directive for conf.d if not present + echo 'include: "/etc/unbound/unbound.conf.d/*.conf"' >>/etc/unbound/unbound.conf + fi + + # Generate OpenVPN-specific Unbound configuration + # Using consistent best-practice settings across all distros + { + echo 'server:' + echo ' # OpenVPN DNS resolver configuration' + echo ' interface: 10.8.0.1' + echo ' access-control: 10.8.0.0/24 allow' + echo '' + echo ' # Security hardening' + echo ' hide-identity: yes' + echo ' hide-version: yes' + echo ' harden-glue: yes' + echo ' harden-dnssec-stripped: yes' + echo '' + echo ' # Performance optimizations' + echo ' prefetch: yes' + echo ' use-caps-for-id: yes' + echo ' qname-minimisation: yes' + echo '' + echo ' # Allow binding before tun interface exists' + echo ' ip-freebind: yes' + echo '' + echo ' # DNS rebinding protection' + echo ' private-address: 10.0.0.0/8' + echo ' private-address: 172.16.0.0/12' + echo ' private-address: 192.168.0.0/16' + echo ' private-address: 169.254.0.0/16' + echo ' private-address: 127.0.0.0/8' + echo ' private-address: fd00::/8' + echo ' private-address: fe80::/10' + echo ' private-address: ::ffff:0:0/96' + + # IPv6 support + if [[ $IPV6_SUPPORT == 'y' ]]; then + echo '' + echo ' # IPv6 VPN support' + echo ' interface: fd42:42:42:42::1' + echo ' access-control: fd42:42:42:42::/112 allow' + echo ' private-address: fd42:42:42:42::/112' + fi + + # Disable remote-control (requires SSL certs on openSUSE) + if [[ $OS == "opensuse" ]]; then + echo '' + echo 'remote-control:' + echo ' control-enable: no' + fi + } >/etc/unbound/unbound.conf.d/openvpn.conf + + run_cmd "Enabling Unbound service" systemctl enable unbound + run_cmd "Starting Unbound service" systemctl restart unbound + + # Validate Unbound is running + for i in {1..10}; do + if pgrep -x unbound >/dev/null; then + return 0 + fi + sleep 1 + done + log_fatal "Unbound failed to start. Check 'journalctl -u unbound' for details." +} + +function resolvePublicIP() { + # IP version flags, we'll use as default the IPv4 + CURL_IP_VERSION_FLAG="-4" + DIG_IP_VERSION_FLAG="-4" + + # Behind NAT, we'll default to the publicly reachable IPv4/IPv6. + if [[ $IPV6_SUPPORT == "y" ]]; then + CURL_IP_VERSION_FLAG="" + DIG_IP_VERSION_FLAG="-6" + fi + + # If there is no public ip yet, we'll try to solve it using: https://api.seeip.org + if [[ -z $PUBLIC_IP ]]; then + PUBLIC_IP=$(curl -f -m 5 -sS --retry 2 --retry-connrefused "$CURL_IP_VERSION_FLAG" https://api.seeip.org 2>/dev/null) + fi + + # If there is no public ip yet, we'll try to solve it using: https://ifconfig.me + if [[ -z $PUBLIC_IP ]]; then + PUBLIC_IP=$(curl -f -m 5 -sS --retry 2 --retry-connrefused "$CURL_IP_VERSION_FLAG" https://ifconfig.me 2>/dev/null) + fi + + # If there is no public ip yet, we'll try to solve it using: https://api.ipify.org + if [[ -z $PUBLIC_IP ]]; then + PUBLIC_IP=$(curl -f -m 5 -sS --retry 2 --retry-connrefused "$CURL_IP_VERSION_FLAG" https://api.ipify.org 2>/dev/null) + fi + + # If there is no public ip yet, we'll try to solve it using: ns1.google.com + if [[ -z $PUBLIC_IP ]]; then + PUBLIC_IP=$(dig $DIG_IP_VERSION_FLAG TXT +short o-o.myaddr.l.google.com @ns1.google.com | tr -d '"') + fi + + if [[ -z $PUBLIC_IP ]]; then + log_fatal "Couldn't solve the public IP" + fi + + echo "$PUBLIC_IP" } function installQuestions() { - echo "Welcome to the OpenVPN installer!" - echo "The git repository is available at: https://github.com/angristan/openvpn-install" - echo "" + log_header "OpenVPN Installer" + log_prompt "The git repository is available at: https://github.com/angristan/openvpn-install" - echo "I need to ask you a few questions before starting the setup." - echo "You can leave the default options and just press enter if you are ok with them." - echo "" - echo "I need to know the IPv4 address of the network interface you want OpenVPN listening to." - echo "Unless your server is behind NAT, it should be your public IPv4 address." + log_prompt "I need to ask you a few questions before starting the setup." + log_prompt "You can leave the default options and just press enter if you are okay with them." + log_menu "" + log_prompt "I need to know the IPv4 address of the network interface you want OpenVPN listening to." + log_prompt "Unless your server is behind NAT, it should be your public IPv4 address." # Detect public IPv4 address and pre-fill for the user IP=$(ip -4 addr | sed -ne 's|^.* inet \([^/]*\)/.* scope global.*$|\1|p' | head -1) @@ -238,21 +581,23 @@ function installQuestions() { if [[ $APPROVE_IP =~ n ]]; then read -rp "IP address: " -e -i "$IP" IP fi - # If $IP is a private IP address, the server must be behind NAT + # If $IP is a private IP address, the server must be behind NAT if echo "$IP" | grep -qE '^(10\.|172\.1[6789]\.|172\.2[0-9]\.|172\.3[01]\.|192\.168)'; then - echo "" - echo "It seems this server is behind NAT. What is its public IPv4 address or hostname?" - echo "We need it for the clients to connect to the server." + log_menu "" + log_prompt "It seems this server is behind NAT. What is its public IPv4 address or hostname?" + log_prompt "We need it for the clients to connect to the server." + + if [[ -z $ENDPOINT ]]; then + DEFAULT_ENDPOINT=$(resolvePublicIP) + fi - PUBLICIP=$(curl -s https://api.ipify.org) until [[ $ENDPOINT != "" ]]; do - read -rp "Public IPv4 address or hostname: " -e -i "$PUBLICIP" ENDPOINT + read -rp "Public IPv4 address or hostname: " -e -i "$DEFAULT_ENDPOINT" ENDPOINT done fi - echo "" - echo "Checking for IPv6 connectivity..." - echo "" + log_menu "" + log_prompt "Checking for IPv6 connectivity..." # "ping6" and "ping -6" availability varies depending on the distribution if type ping6 >/dev/null 2>&1; then PING6="ping6 -c3 ipv6.google.com > /dev/null 2>&1" @@ -260,22 +605,22 @@ function installQuestions() { PING6="ping -6 -c3 ipv6.google.com > /dev/null 2>&1" fi if eval "$PING6"; then - echo "Your host appears to have IPv6 connectivity." + log_prompt "Your host appears to have IPv6 connectivity." SUGGESTION="y" else - echo "Your host does not appear to have IPv6 connectivity." + log_prompt "Your host does not appear to have IPv6 connectivity." SUGGESTION="n" fi - echo "" + log_menu "" # Ask the user if they want to enable IPv6 regardless its availability. until [[ $IPV6_SUPPORT =~ (y|n) ]]; do read -rp "Do you want to enable IPv6 support (NAT)? [y/n]: " -e -i $SUGGESTION IPV6_SUPPORT done - echo "" - echo "What port do you want OpenVPN to listen to?" - echo " 1) Default: 1194" - echo " 2) Custom" - echo " 3) Random [49152-65535]" + log_menu "" + log_prompt "What port do you want OpenVPN to listen to?" + log_menu " 1) Default: 1194" + log_menu " 2) Custom" + log_menu " 3) Random [49152-65535]" until [[ $PORT_CHOICE =~ ^[1-3]$ ]]; do read -rp "Port choice [1-3]: " -e -i 1 PORT_CHOICE done @@ -291,14 +636,14 @@ function installQuestions() { 3) # Generate random number within private ports range PORT=$(shuf -i49152-65535 -n1) - echo "Random Port: $PORT" + log_info "Random Port: $PORT" ;; esac - echo "" - echo "What protocol do you want OpenVPN to use?" - echo "UDP is faster. Unless it is not available, you shouldn't use TCP." - echo " 1) UDP" - echo " 2) TCP" + log_menu "" + log_prompt "What protocol do you want OpenVPN to use?" + log_prompt "UDP is faster. Unless it is not available, you shouldn't use TCP." + log_menu " 1) UDP" + log_menu " 2) TCP" until [[ $PROTOCOL_CHOICE =~ ^[1-2]$ ]]; do read -rp "Protocol [1-2]: " -e -i 1 PROTOCOL_CHOICE done @@ -310,30 +655,30 @@ function installQuestions() { PROTOCOL="tcp" ;; esac - echo "" - echo "What DNS resolvers do you want to use with the VPN?" - echo " 1) Current system resolvers (from /etc/resolv.conf)" - echo " 2) Self-hosted DNS Resolver (Unbound)" - echo " 3) Cloudflare (Anycast: worldwide)" - echo " 4) Quad9 (Anycast: worldwide)" - echo " 5) Quad9 uncensored (Anycast: worldwide)" - echo " 6) FDN (France)" - echo " 7) DNS.WATCH (Germany)" - echo " 8) OpenDNS (Anycast: worldwide)" - echo " 9) Google (Anycast: worldwide)" - echo " 10) Yandex Basic (Russia)" - echo " 11) AdGuard DNS (Anycast: worldwide)" - echo " 12) NextDNS (Anycast: worldwide)" - echo " 13) Custom" + log_menu "" + log_prompt "What DNS resolvers do you want to use with the VPN?" + log_menu " 1) Current system resolvers (from /etc/resolv.conf)" + log_menu " 2) Self-hosted DNS Resolver (Unbound)" + log_menu " 3) Cloudflare (Anycast: worldwide)" + log_menu " 4) Quad9 (Anycast: worldwide)" + log_menu " 5) Quad9 uncensored (Anycast: worldwide)" + log_menu " 6) FDN (France)" + log_menu " 7) DNS.WATCH (Germany)" + log_menu " 8) OpenDNS (Anycast: worldwide)" + log_menu " 9) Google (Anycast: worldwide)" + log_menu " 10) Yandex Basic (Russia)" + log_menu " 11) AdGuard DNS (Anycast: worldwide)" + log_menu " 12) NextDNS (Anycast: worldwide)" + log_menu " 13) Custom" until [[ $DNS =~ ^[0-9]+$ ]] && [ "$DNS" -ge 1 ] && [ "$DNS" -le 13 ]; do - read -rp "DNS [1-12]: " -e -i 11 DNS + read -rp "DNS [1-13]: " -e -i 11 DNS if [[ $DNS == 2 ]] && [[ -e /etc/unbound/unbound.conf ]]; then - echo "" - echo "Unbound is already installed." - echo "You can allow the script to configure it in order to use it from your OpenVPN clients" - echo "We will simply add a second server to /etc/unbound/unbound.conf for the OpenVPN subnet." - echo "No changes are made to the current configuration." - echo "" + log_menu "" + log_prompt "Unbound is already installed." + log_prompt "You can allow the script to configure it in order to use it from your OpenVPN clients" + log_prompt "We will simply add a second server to /etc/unbound/unbound.conf for the OpenVPN subnet." + log_prompt "No changes are made to the current configuration." + log_menu "" until [[ $CONTINUE =~ (y|n) ]]; do read -rp "Apply configuration changes to Unbound? [y/n]: " -e CONTINUE @@ -355,18 +700,24 @@ function installQuestions() { done fi done - echo "" - echo "Do you want to use compression? It is not recommended since the VORACLE attack makes use of it." + log_menu "" + log_prompt "Do you want to allow a single .ovpn profile to be used on multiple devices simultaneously?" + log_prompt "Note: Enabling this disables persistent IP addresses for clients." + until [[ $MULTI_CLIENT =~ (y|n) ]]; do + read -rp "Allow multiple devices per client? [y/n]: " -e -i n MULTI_CLIENT + done + log_menu "" + log_prompt "Do you want to use compression? It is not recommended since the VORACLE attack makes use of it." until [[ $COMPRESSION_ENABLED =~ (y|n) ]]; do - read -rp"Enable compression? [y/n]: " -e -i n COMPRESSION_ENABLED + read -rp "Enable compression? [y/n]: " -e -i n COMPRESSION_ENABLED done if [[ $COMPRESSION_ENABLED == "y" ]]; then - echo "Choose which compression algorithm you want to use: (they are ordered by efficiency)" - echo " 1) LZ4-v2" - echo " 2) LZ4" - echo " 3) LZ0" + log_prompt "Choose which compression algorithm you want to use: (they are ordered by efficiency)" + log_menu " 1) LZ4-v2" + log_menu " 2) LZ4" + log_menu " 3) LZ0" until [[ $COMPRESSION_CHOICE =~ ^[1-3]$ ]]; do - read -rp"Compression algorithm [1-3]: " -e -i 1 COMPRESSION_CHOICE + read -rp "Compression algorithm [1-3]: " -e -i 1 COMPRESSION_CHOICE done case $COMPRESSION_CHOICE in 1) @@ -380,12 +731,12 @@ function installQuestions() { ;; esac fi - echo "" - echo "Do you want to customize encryption settings?" - echo "Unless you know what you're doing, you should stick with the default parameters provided by the script." - echo "Note that whatever you choose, all the choices presented in the script are safe. (Unlike OpenVPN's defaults)" - echo "See https://github.com/angristan/openvpn-install#security-and-encryption to learn more." - echo "" + log_menu "" + log_prompt "Do you want to customize encryption settings?" + log_prompt "Unless you know what you're doing, you should stick with the default parameters provided by the script." + log_prompt "Note that whatever you choose, all the choices presented in the script are safe (unlike OpenVPN's defaults)." + log_prompt "See https://github.com/angristan/openvpn-install#security-and-encryption to learn more." + log_menu "" until [[ $CUSTOMIZE_ENC =~ (y|n) ]]; do read -rp "Customize encryption settings? [y/n]: " -e -i n CUSTOMIZE_ENC done @@ -398,18 +749,19 @@ function installQuestions() { DH_TYPE="1" # ECDH DH_CURVE="prime256v1" HMAC_ALG="SHA256" - TLS_SIG="1" # tls-crypt + TLS_SIG="1" # tls-crypt-v2 else - echo "" - echo "Choose which cipher you want to use for the data channel:" - echo " 1) AES-128-GCM (recommended)" - echo " 2) AES-192-GCM" - echo " 3) AES-256-GCM" - echo " 4) AES-128-CBC" - echo " 5) AES-192-CBC" - echo " 6) AES-256-CBC" - until [[ $CIPHER_CHOICE =~ ^[1-6]$ ]]; do - read -rp "Cipher [1-6]: " -e -i 1 CIPHER_CHOICE + log_menu "" + log_prompt "Choose which cipher you want to use for the data channel:" + log_menu " 1) AES-128-GCM (recommended)" + log_menu " 2) AES-192-GCM" + log_menu " 3) AES-256-GCM" + log_menu " 4) AES-128-CBC" + log_menu " 5) AES-192-CBC" + log_menu " 6) AES-256-CBC" + log_menu " 7) CHACHA20-POLY1305 (requires OpenVPN 2.5+, good for devices without AES-NI)" + until [[ $CIPHER_CHOICE =~ ^[1-7]$ ]]; do + read -rp "Cipher [1-7]: " -e -i 1 CIPHER_CHOICE done case $CIPHER_CHOICE in 1) @@ -430,23 +782,26 @@ function installQuestions() { 6) CIPHER="AES-256-CBC" ;; + 7) + CIPHER="CHACHA20-POLY1305" + ;; esac - echo "" - echo "Choose what kind of certificate you want to use:" - echo " 1) ECDSA (recommended)" - echo " 2) RSA" + log_menu "" + log_prompt "Choose what kind of certificate you want to use:" + log_menu " 1) ECDSA (recommended)" + log_menu " 2) RSA" until [[ $CERT_TYPE =~ ^[1-2]$ ]]; do - read -rp"Certificate key type [1-2]: " -e -i 1 CERT_TYPE + read -rp "Certificate key type [1-2]: " -e -i 1 CERT_TYPE done case $CERT_TYPE in 1) - echo "" - echo "Choose which curve you want to use for the certificate's key:" - echo " 1) prime256v1 (recommended)" - echo " 2) secp384r1" - echo " 3) secp521r1" + log_menu "" + log_prompt "Choose which curve you want to use for the certificate's key:" + log_menu " 1) prime256v1 (recommended)" + log_menu " 2) secp384r1" + log_menu " 3) secp521r1" until [[ $CERT_CURVE_CHOICE =~ ^[1-3]$ ]]; do - read -rp"Curve [1-3]: " -e -i 1 CERT_CURVE_CHOICE + read -rp "Curve [1-3]: " -e -i 1 CERT_CURVE_CHOICE done case $CERT_CURVE_CHOICE in 1) @@ -461,11 +816,11 @@ function installQuestions() { esac ;; 2) - echo "" - echo "Choose which size you want to use for the certificate's RSA key:" - echo " 1) 2048 bits (recommended)" - echo " 2) 3072 bits" - echo " 3) 4096 bits" + log_menu "" + log_prompt "Choose which size you want to use for the certificate's RSA key:" + log_menu " 1) 2048 bits (recommended)" + log_menu " 2) 3072 bits" + log_menu " 3) 4096 bits" until [[ $RSA_KEY_SIZE_CHOICE =~ ^[1-3]$ ]]; do read -rp "RSA key size [1-3]: " -e -i 1 RSA_KEY_SIZE_CHOICE done @@ -482,14 +837,15 @@ function installQuestions() { esac ;; esac - echo "" - echo "Choose which cipher you want to use for the control channel:" + log_menu "" + log_prompt "Choose which cipher you want to use for the control channel:" case $CERT_TYPE in 1) - echo " 1) ECDHE-ECDSA-AES-128-GCM-SHA256 (recommended)" - echo " 2) ECDHE-ECDSA-AES-256-GCM-SHA384" - until [[ $CC_CIPHER_CHOICE =~ ^[1-2]$ ]]; do - read -rp"Control channel cipher [1-2]: " -e -i 1 CC_CIPHER_CHOICE + log_menu " 1) ECDHE-ECDSA-AES-128-GCM-SHA256 (recommended)" + log_menu " 2) ECDHE-ECDSA-AES-256-GCM-SHA384" + log_menu " 3) ECDHE-ECDSA-CHACHA20-POLY1305 (requires OpenVPN 2.5+)" + until [[ $CC_CIPHER_CHOICE =~ ^[1-3]$ ]]; do + read -rp "Control channel cipher [1-3]: " -e -i 1 CC_CIPHER_CHOICE done case $CC_CIPHER_CHOICE in 1) @@ -498,13 +854,17 @@ function installQuestions() { 2) CC_CIPHER="TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384" ;; + 3) + CC_CIPHER="TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256" + ;; esac ;; 2) - echo " 1) ECDHE-RSA-AES-128-GCM-SHA256 (recommended)" - echo " 2) ECDHE-RSA-AES-256-GCM-SHA384" - until [[ $CC_CIPHER_CHOICE =~ ^[1-2]$ ]]; do - read -rp"Control channel cipher [1-2]: " -e -i 1 CC_CIPHER_CHOICE + log_menu " 1) ECDHE-RSA-AES-128-GCM-SHA256 (recommended)" + log_menu " 2) ECDHE-RSA-AES-256-GCM-SHA384" + log_menu " 3) ECDHE-RSA-CHACHA20-POLY1305 (requires OpenVPN 2.5+)" + until [[ $CC_CIPHER_CHOICE =~ ^[1-3]$ ]]; do + read -rp "Control channel cipher [1-3]: " -e -i 1 CC_CIPHER_CHOICE done case $CC_CIPHER_CHOICE in 1) @@ -513,25 +873,28 @@ function installQuestions() { 2) CC_CIPHER="TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384" ;; + 3) + CC_CIPHER="TLS-ECDHE-RSA-WITH-CHACHA20-POLY1305-SHA256" + ;; esac ;; esac - echo "" - echo "Choose what kind of Diffie-Hellman key you want to use:" - echo " 1) ECDH (recommended)" - echo " 2) DH" + log_menu "" + log_prompt "Choose what kind of Diffie-Hellman key you want to use:" + log_menu " 1) ECDH (recommended)" + log_menu " 2) DH" until [[ $DH_TYPE =~ [1-2] ]]; do - read -rp"DH key type [1-2]: " -e -i 1 DH_TYPE + read -rp "DH key type [1-2]: " -e -i 1 DH_TYPE done case $DH_TYPE in 1) - echo "" - echo "Choose which curve you want to use for the ECDH key:" - echo " 1) prime256v1 (recommended)" - echo " 2) secp384r1" - echo " 3) secp521r1" + log_menu "" + log_prompt "Choose which curve you want to use for the ECDH key:" + log_menu " 1) prime256v1 (recommended)" + log_menu " 2) secp384r1" + log_menu " 3) secp521r1" while [[ $DH_CURVE_CHOICE != "1" && $DH_CURVE_CHOICE != "2" && $DH_CURVE_CHOICE != "3" ]]; do - read -rp"Curve [1-3]: " -e -i 1 DH_CURVE_CHOICE + read -rp "Curve [1-3]: " -e -i 1 DH_CURVE_CHOICE done case $DH_CURVE_CHOICE in 1) @@ -546,11 +909,11 @@ function installQuestions() { esac ;; 2) - echo "" - echo "Choose what size of Diffie-Hellman key you want to use:" - echo " 1) 2048 bits (recommended)" - echo " 2) 3072 bits" - echo " 3) 4096 bits" + log_menu "" + log_prompt "Choose what size of Diffie-Hellman key you want to use:" + log_menu " 1) 2048 bits (recommended)" + log_menu " 2) 3072 bits" + log_menu " 3) 4096 bits" until [[ $DH_KEY_SIZE_CHOICE =~ ^[1-3]$ ]]; do read -rp "DH key size [1-3]: " -e -i 1 DH_KEY_SIZE_CHOICE done @@ -567,17 +930,17 @@ function installQuestions() { esac ;; esac - echo "" - # The "auth" options behaves differently with AEAD ciphers + log_menu "" + # The "auth" options behaves differently with AEAD ciphers (GCM, ChaCha20-Poly1305) if [[ $CIPHER =~ CBC$ ]]; then - echo "The digest algorithm authenticates data channel packets and tls-auth packets from the control channel." - elif [[ $CIPHER =~ GCM$ ]]; then - echo "The digest algorithm authenticates tls-auth packets from the control channel." + log_prompt "The digest algorithm authenticates data channel packets and tls-auth packets from the control channel." + elif [[ $CIPHER =~ GCM$ ]] || [[ $CIPHER == "CHACHA20-POLY1305" ]]; then + log_prompt "The digest algorithm authenticates tls-auth packets from the control channel." fi - echo "Which digest algorithm do you want to use for HMAC?" - echo " 1) SHA-256 (recommended)" - echo " 2) SHA-384" - echo " 3) SHA-512" + log_prompt "Which digest algorithm do you want to use for HMAC?" + log_menu " 1) SHA-256 (recommended)" + log_menu " 2) SHA-384" + log_menu " 3) SHA-512" until [[ $HMAC_ALG_CHOICE =~ ^[1-3]$ ]]; do read -rp "Digest algorithm [1-3]: " -e -i 1 HMAC_ALG_CHOICE done @@ -592,18 +955,18 @@ function installQuestions() { HMAC_ALG="SHA512" ;; esac - echo "" - echo "You can add an additional layer of security to the control channel with tls-auth and tls-crypt" - echo "tls-auth authenticates the packets, while tls-crypt authenticate and encrypt them." - echo " 1) tls-crypt (recommended)" - echo " 2) tls-auth" - until [[ $TLS_SIG =~ [1-2] ]]; do - read -rp "Control channel additional security mechanism [1-2]: " -e -i 1 TLS_SIG + log_menu "" + log_prompt "You can add an additional layer of security to the control channel." + log_menu " 1) tls-crypt-v2 (recommended): Encrypts control channel, unique key per client" + log_menu " 2) tls-crypt: Encrypts control channel, shared key for all clients" + log_menu " 3) tls-auth: Authenticates control channel, no encryption" + until [[ $TLS_SIG =~ ^[1-3]$ ]]; do + read -rp "Control channel additional security mechanism [1-3]: " -e -i 1 TLS_SIG done fi - echo "" - echo "Okay, that was all I needed. We are ready to setup your OpenVPN server now." - echo "You will be able to generate a client at the end of the installation." + log_menu "" + log_prompt "Okay, that was all I needed. We are ready to setup your OpenVPN server now." + log_prompt "You will be able to generate a client at the end of the installation." APPROVE_INSTALL=${APPROVE_INSTALL:-n} if [[ $APPROVE_INSTALL =~ n ]]; then read -n1 -r -p "Press any key to continue..." @@ -620,18 +983,33 @@ function installOpenVPN() { PROTOCOL_CHOICE=${PROTOCOL_CHOICE:-1} DNS=${DNS:-1} COMPRESSION_ENABLED=${COMPRESSION_ENABLED:-n} + MULTI_CLIENT=${MULTI_CLIENT:-n} CUSTOMIZE_ENC=${CUSTOMIZE_ENC:-n} CLIENT=${CLIENT:-client} PASS=${PASS:-1} + CLIENT_CERT_DURATION_DAYS=${CLIENT_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS} + SERVER_CERT_DURATION_DAYS=${SERVER_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS} CONTINUE=${CONTINUE:-y} - # Behind NAT, we'll default to the publicly reachable IPv4/IPv6. - if [[ $IPV6_SUPPORT == "y" ]]; then - PUBLIC_IP=$(curl --retry 5 --retry-connrefused https://ifconfig.co) - else - PUBLIC_IP=$(curl --retry 5 --retry-connrefused -4 https://ifconfig.co) + if [[ -z $ENDPOINT ]]; then + ENDPOINT=$(resolvePublicIP) fi - ENDPOINT=${ENDPOINT:-$PUBLIC_IP} + + # Log auto-install mode and parameters + log_info "=== OpenVPN Auto-Install ===" + log_info "Running in auto-install mode with the following settings:" + log_info " ENDPOINT=$ENDPOINT" + log_info " IPV6_SUPPORT=$IPV6_SUPPORT" + log_info " PORT_CHOICE=$PORT_CHOICE" + log_info " PROTOCOL_CHOICE=$PROTOCOL_CHOICE" + log_info " DNS=$DNS" + log_info " COMPRESSION_ENABLED=$COMPRESSION_ENABLED" + log_info " MULTI_CLIENT=$MULTI_CLIENT" + log_info " CUSTOMIZE_ENC=$CUSTOMIZE_ENC" + log_info " CLIENT=$CLIENT" + log_info " PASS=$PASS" + log_info " CLIENT_CERT_DURATION_DAYS=$CLIENT_CERT_DURATION_DAYS" + log_info " SERVER_CERT_DURATION_DAYS=$SERVER_CERT_DURATION_DAYS" fi # Run setup questions first, and set other variables if auto-install @@ -645,9 +1023,8 @@ function installOpenVPN() { # $NIC can not be empty for script rm-openvpn-rules.sh if [[ -z $NIC ]]; then - echo - echo "Can not detect public interface." - echo "This needs for setup MASQUERADE." + log_warn "Could not detect public interface." + log_info "This needs for setup MASQUERADE." until [[ $CONTINUE =~ (y|n) ]]; do read -rp "Continue? [y/n]: " -e CONTINUE done @@ -659,56 +1036,103 @@ function installOpenVPN() { # If OpenVPN isn't installed yet, install it. This script is more-or-less # idempotent on multiple runs, but will only install OpenVPN from upstream # the first time. - if [[ ! -e /etc/openvpn/server.conf ]]; then + if [[ ! -e /etc/openvpn/server/server.conf ]]; then + log_header "Installing OpenVPN" + + # Setup official OpenVPN repository for latest versions + installOpenVPNRepo + + log_info "Installing OpenVPN and dependencies..." if [[ $OS =~ (debian|ubuntu) ]]; then - apt-get update - apt-get -y install ca-certificates gnupg - # We add the OpenVPN repo to get the latest version. - if [[ $VERSION_ID == "16.04" ]]; then - echo "deb http://build.openvpn.net/debian/openvpn/stable xenial main" >/etc/apt/sources.list.d/openvpn.list - wget -O - https://swupdate.openvpn.net/repos/repo-public.gpg | apt-key add - - apt-get update - fi - # Ubuntu > 16.04 and Debian > 8 have OpenVPN >= 2.4 without the need of a third party repository. - apt-get install -y openvpn iptables openssl wget ca-certificates curl + run_cmd_fatal "Installing OpenVPN" apt-get install -y openvpn iptables openssl curl ca-certificates tar dnsutils elif [[ $OS == 'centos' ]]; then - yum install -y epel-release - yum install -y openvpn iptables openssl wget ca-certificates curl tar 'policycoreutils-python*' + run_cmd_fatal "Installing OpenVPN" yum install -y openvpn iptables openssl ca-certificates curl tar bind-utils 'policycoreutils-python*' elif [[ $OS == 'oracle' ]]; then - yum install -y oracle-epel-release-el8 - yum-config-manager --enable ol8_developer_EPEL - yum install -y openvpn iptables openssl wget ca-certificates curl tar policycoreutils-python-utils - elif [[ $OS == 'amzn' ]]; then - amazon-linux-extras install -y epel - yum install -y openvpn iptables openssl wget ca-certificates curl + run_cmd_fatal "Installing OpenVPN" yum install -y openvpn iptables openssl ca-certificates curl tar bind-utils policycoreutils-python-utils + elif [[ $OS == 'amzn2023' ]]; then + run_cmd_fatal "Installing OpenVPN" dnf install -y openvpn iptables openssl ca-certificates curl tar bind-utils elif [[ $OS == 'fedora' ]]; then - dnf install -y openvpn iptables openssl wget ca-certificates curl policycoreutils-python-utils + run_cmd_fatal "Installing OpenVPN" dnf install -y openvpn iptables openssl ca-certificates curl tar bind-utils policycoreutils-python-utils + elif [[ $OS == 'opensuse' ]]; then + run_cmd_fatal "Installing OpenVPN" zypper install -y openvpn iptables openssl ca-certificates curl tar bind-utils elif [[ $OS == 'arch' ]]; then - # Install required dependencies and upgrade the system - pacman --needed --noconfirm -Syu openvpn iptables openssl wget ca-certificates curl + run_cmd_fatal "Installing OpenVPN" pacman --needed --noconfirm -Syu openvpn iptables openssl ca-certificates curl tar bind fi + + # Verify ChaCha20-Poly1305 compatibility if selected + if [[ $CIPHER == "CHACHA20-POLY1305" ]] || [[ $CC_CIPHER =~ CHACHA20 ]]; then + local installed_version + installed_version=$(openvpn --version 2>/dev/null | head -1 | awk '{print $2}') + if ! openvpnVersionAtLeast "2.5"; then + log_fatal "ChaCha20-Poly1305 requires OpenVPN 2.5 or later. Installed version: $installed_version" + fi + log_info "OpenVPN version supports ChaCha20-Poly1305" + fi + + # Check Data Channel Offload (DCO) availability + if isDCOAvailable; then + # Check if configuration is DCO-compatible + if [[ $PROTOCOL == "udp" ]] && [[ $COMPRESSION_ENABLED == "n" ]] && [[ $CIPHER =~ (GCM|CHACHA20-POLY1305) ]]; then + log_info "Data Channel Offload (DCO) is available and will be used for improved performance" + else + log_info "Data Channel Offload (DCO) is available but not enabled (requires UDP, AEAD cipher, no compression)" + fi + else + log_info "Data Channel Offload (DCO) is not available (requires OpenVPN 2.6+ and kernel support)" + fi + + # Create the server directory (OpenVPN 2.4+ directory structure) + run_cmd_fatal "Creating server directory" mkdir -p /etc/openvpn/server + # An old version of easy-rsa was available by default in some openvpn packages - if [[ -d /etc/openvpn/easy-rsa/ ]]; then - rm -rf /etc/openvpn/easy-rsa/ + if [[ -d /etc/openvpn/server/easy-rsa/ ]]; then + run_cmd "Removing old Easy-RSA" rm -rf /etc/openvpn/server/easy-rsa/ fi fi - # Find out if the machine uses nogroup or nobody for the permissionless group - if grep -qs "^nogroup:" /etc/group; then - NOGROUP=nogroup + # Determine which user/group OpenVPN should run as + # - Fedora/RHEL/Amazon create 'openvpn' user with 'openvpn' group + # - Arch creates 'openvpn' user with 'network' group + # - Debian/Ubuntu/openSUSE don't create a dedicated user, use 'nobody' + # + # Also check if the systemd service file already handles user/group switching. + # If so, we shouldn't add user/group to config (would cause double privilege drop). + SYSTEMD_HANDLES_USER=false + for service_file in /usr/lib/systemd/system/openvpn-server@.service /lib/systemd/system/openvpn-server@.service; do + if [[ -f "$service_file" ]] && grep -q "^User=" "$service_file"; then + SYSTEMD_HANDLES_USER=true + break + fi + done + + if id openvpn &>/dev/null; then + OPENVPN_USER=openvpn + # Get the openvpn user's primary group (e.g., 'openvpn' on Fedora, 'network' on Arch) + OPENVPN_GROUP=$(id -gn openvpn 2>/dev/null || echo openvpn) else - NOGROUP=nobody + OPENVPN_USER=nobody + if grep -qs "^nogroup:" /etc/group; then + OPENVPN_GROUP=nogroup + else + OPENVPN_GROUP=nobody + fi fi # Install the latest version of easy-rsa from source, if not already installed. - if [[ ! -d /etc/openvpn/easy-rsa/ ]]; then - local version="3.0.7" - wget -O ~/easy-rsa.tgz https://github.com/OpenVPN/easy-rsa/releases/download/v${version}/EasyRSA-${version}.tgz - mkdir -p /etc/openvpn/easy-rsa - tar xzf ~/easy-rsa.tgz --strip-components=1 --directory /etc/openvpn/easy-rsa - rm -f ~/easy-rsa.tgz + if [[ ! -d /etc/openvpn/server/easy-rsa/ ]]; then + run_cmd_fatal "Downloading Easy-RSA v${EASYRSA_VERSION}" curl -fL --retry 5 -o ~/easy-rsa.tgz "https://github.com/OpenVPN/easy-rsa/releases/download/v${EASYRSA_VERSION}/EasyRSA-${EASYRSA_VERSION}.tgz" + log_info "Verifying Easy-RSA checksum..." + CHECKSUM_OUTPUT=$(echo "${EASYRSA_SHA256} $HOME/easy-rsa.tgz" | sha256sum -c 2>&1) || { + _log_to_file "[CHECKSUM] $CHECKSUM_OUTPUT" + run_cmd "Cleaning up failed download" rm -f ~/easy-rsa.tgz + log_fatal "SHA256 checksum verification failed for easy-rsa download!" + } + _log_to_file "[CHECKSUM] $CHECKSUM_OUTPUT" + run_cmd_fatal "Creating Easy-RSA directory" mkdir -p /etc/openvpn/server/easy-rsa + run_cmd_fatal "Extracting Easy-RSA" tar xzf ~/easy-rsa.tgz --strip-components=1 --no-same-owner --directory /etc/openvpn/server/easy-rsa + run_cmd "Cleaning up archive" rm -f ~/easy-rsa.tgz - cd /etc/openvpn/easy-rsa/ || return + cd /etc/openvpn/server/easy-rsa/ || return case $CERT_TYPE in 1) echo "set_var EASYRSA_ALGO ec" >vars @@ -725,63 +1149,85 @@ function installOpenVPN() { SERVER_NAME="server_$(head /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)" echo "$SERVER_NAME" >SERVER_NAME_GENERATED - echo "set_var EASYRSA_REQ_CN $SERVER_CN" >>vars - # Create the PKI, set up the CA, the DH params and the server certificate - ./easyrsa init-pki - ./easyrsa --batch build-ca nopass + log_info "Initializing PKI..." + run_cmd_fatal "Initializing PKI" ./easyrsa init-pki + export EASYRSA_CA_EXPIRE=$DEFAULT_CERT_VALIDITY_DURATION_DAYS + log_info "Building CA..." + run_cmd_fatal "Building CA" ./easyrsa --batch --req-cn="$SERVER_CN" build-ca nopass if [[ $DH_TYPE == "2" ]]; then # ECDH keys are generated on-the-fly so we don't need to generate them beforehand - openssl dhparam -out dh.pem $DH_KEY_SIZE + run_cmd_fatal "Generating DH parameters (this may take a while)" openssl dhparam -out dh.pem "$DH_KEY_SIZE" fi - ./easyrsa build-server-full "$SERVER_NAME" nopass - EASYRSA_CRL_DAYS=3650 ./easyrsa gen-crl + export EASYRSA_CERT_EXPIRE=${SERVER_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS} + log_info "Building server certificate..." + run_cmd_fatal "Building server certificate" ./easyrsa --batch build-server-full "$SERVER_NAME" nopass + export EASYRSA_CRL_DAYS=$DEFAULT_CRL_VALIDITY_DURATION_DAYS + run_cmd_fatal "Generating CRL" ./easyrsa gen-crl + log_info "Generating TLS key..." case $TLS_SIG in 1) - # Generate tls-crypt key - openvpn --genkey --secret /etc/openvpn/tls-crypt.key + # Generate tls-crypt-v2 server key + run_cmd_fatal "Generating tls-crypt-v2 server key" openvpn --genkey tls-crypt-v2-server /etc/openvpn/server/tls-crypt-v2.key ;; 2) + # Generate tls-crypt key + run_cmd_fatal "Generating tls-crypt key" openvpn --genkey --secret /etc/openvpn/server/tls-crypt.key + ;; + 3) # Generate tls-auth key - openvpn --genkey --secret /etc/openvpn/tls-auth.key + run_cmd_fatal "Generating tls-auth key" openvpn --genkey --secret /etc/openvpn/server/tls-auth.key ;; esac else # If easy-rsa is already installed, grab the generated SERVER_NAME # for client configs - cd /etc/openvpn/easy-rsa/ || return + cd /etc/openvpn/server/easy-rsa/ || return SERVER_NAME=$(cat SERVER_NAME_GENERATED) fi # Move all the generated files - cp pki/ca.crt pki/private/ca.key "pki/issued/$SERVER_NAME.crt" "pki/private/$SERVER_NAME.key" /etc/openvpn/easy-rsa/pki/crl.pem /etc/openvpn + log_info "Copying certificates..." + run_cmd_fatal "Copying certificates to /etc/openvpn/server" cp pki/ca.crt pki/private/ca.key "pki/issued/$SERVER_NAME.crt" "pki/private/$SERVER_NAME.key" /etc/openvpn/server/easy-rsa/pki/crl.pem /etc/openvpn/server if [[ $DH_TYPE == "2" ]]; then - cp dh.pem /etc/openvpn + run_cmd_fatal "Copying DH parameters" cp dh.pem /etc/openvpn/server fi # Make cert revocation list readable for non-root - chmod 644 /etc/openvpn/crl.pem + run_cmd "Setting CRL permissions" chmod 644 /etc/openvpn/server/crl.pem # Generate server.conf - echo "port $PORT" >/etc/openvpn/server.conf + log_info "Generating server configuration..." + echo "port $PORT" >/etc/openvpn/server/server.conf if [[ $IPV6_SUPPORT == 'n' ]]; then - echo "proto $PROTOCOL" >>/etc/openvpn/server.conf + echo "proto $PROTOCOL" >>/etc/openvpn/server/server.conf elif [[ $IPV6_SUPPORT == 'y' ]]; then - echo "proto ${PROTOCOL}6" >>/etc/openvpn/server.conf + echo "proto ${PROTOCOL}6" >>/etc/openvpn/server/server.conf fi - echo "dev tun -user nobody -group $NOGROUP -persist-key + if [[ $MULTI_CLIENT == "y" ]]; then + echo "duplicate-cn" >>/etc/openvpn/server/server.conf + fi + + echo "dev tun" >>/etc/openvpn/server/server.conf + # Only add user/group if systemd doesn't handle it (avoids double privilege drop) + if [[ $SYSTEMD_HANDLES_USER == "false" ]]; then + echo "user $OPENVPN_USER +group $OPENVPN_GROUP" >>/etc/openvpn/server/server.conf + fi + echo "persist-key persist-tun keepalive 10 120 topology subnet -server 10.8.0.0 255.255.255.0 -ifconfig-pool-persist ipp.txt" >>/etc/openvpn/server.conf +server 10.8.0.0 255.255.255.0" >>/etc/openvpn/server/server.conf + + # ifconfig-pool-persist is incompatible with duplicate-cn + if [[ $MULTI_CLIENT != "y" ]]; then + echo "ifconfig-pool-persist ipp.txt" >>/etc/openvpn/server/server.conf + fi # DNS resolvers case $DNS in @@ -797,64 +1243,64 @@ ifconfig-pool-persist ipp.txt" >>/etc/openvpn/server.conf sed -ne 's/^nameserver[[:space:]]\+\([^[:space:]]\+\).*$/\1/p' $RESOLVCONF | while read -r line; do # Copy, if it's a IPv4 |or| if IPv6 is enabled, IPv4/IPv6 does not matter if [[ $line =~ ^[0-9.]*$ ]] || [[ $IPV6_SUPPORT == 'y' ]]; then - echo "push \"dhcp-option DNS $line\"" >>/etc/openvpn/server.conf + echo "push \"dhcp-option DNS $line\"" >>/etc/openvpn/server/server.conf fi done ;; 2) # Self-hosted DNS resolver (Unbound) - echo 'push "dhcp-option DNS 10.8.0.1"' >>/etc/openvpn/server.conf + echo 'push "dhcp-option DNS 10.8.0.1"' >>/etc/openvpn/server/server.conf if [[ $IPV6_SUPPORT == 'y' ]]; then - echo 'push "dhcp-option DNS fd42:42:42:42::1"' >>/etc/openvpn/server.conf + echo 'push "dhcp-option DNS fd42:42:42:42::1"' >>/etc/openvpn/server/server.conf fi ;; 3) # Cloudflare - echo 'push "dhcp-option DNS 1.0.0.1"' >>/etc/openvpn/server.conf - echo 'push "dhcp-option DNS 1.1.1.1"' >>/etc/openvpn/server.conf + echo 'push "dhcp-option DNS 1.0.0.1"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 1.1.1.1"' >>/etc/openvpn/server/server.conf ;; 4) # Quad9 - echo 'push "dhcp-option DNS 9.9.9.9"' >>/etc/openvpn/server.conf - echo 'push "dhcp-option DNS 149.112.112.112"' >>/etc/openvpn/server.conf + echo 'push "dhcp-option DNS 9.9.9.9"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 149.112.112.112"' >>/etc/openvpn/server/server.conf ;; 5) # Quad9 uncensored - echo 'push "dhcp-option DNS 9.9.9.10"' >>/etc/openvpn/server.conf - echo 'push "dhcp-option DNS 149.112.112.10"' >>/etc/openvpn/server.conf + echo 'push "dhcp-option DNS 9.9.9.10"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 149.112.112.10"' >>/etc/openvpn/server/server.conf ;; 6) # FDN - echo 'push "dhcp-option DNS 80.67.169.40"' >>/etc/openvpn/server.conf - echo 'push "dhcp-option DNS 80.67.169.12"' >>/etc/openvpn/server.conf + echo 'push "dhcp-option DNS 80.67.169.40"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 80.67.169.12"' >>/etc/openvpn/server/server.conf ;; 7) # DNS.WATCH - echo 'push "dhcp-option DNS 84.200.69.80"' >>/etc/openvpn/server.conf - echo 'push "dhcp-option DNS 84.200.70.40"' >>/etc/openvpn/server.conf + echo 'push "dhcp-option DNS 84.200.69.80"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 84.200.70.40"' >>/etc/openvpn/server/server.conf ;; 8) # OpenDNS - echo 'push "dhcp-option DNS 208.67.222.222"' >>/etc/openvpn/server.conf - echo 'push "dhcp-option DNS 208.67.220.220"' >>/etc/openvpn/server.conf + echo 'push "dhcp-option DNS 208.67.222.222"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 208.67.220.220"' >>/etc/openvpn/server/server.conf ;; 9) # Google - echo 'push "dhcp-option DNS 8.8.8.8"' >>/etc/openvpn/server.conf - echo 'push "dhcp-option DNS 8.8.4.4"' >>/etc/openvpn/server.conf + echo 'push "dhcp-option DNS 8.8.8.8"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 8.8.4.4"' >>/etc/openvpn/server/server.conf ;; 10) # Yandex Basic - echo 'push "dhcp-option DNS 77.88.8.8"' >>/etc/openvpn/server.conf - echo 'push "dhcp-option DNS 77.88.8.1"' >>/etc/openvpn/server.conf + echo 'push "dhcp-option DNS 77.88.8.8"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 77.88.8.1"' >>/etc/openvpn/server/server.conf ;; 11) # AdGuard DNS - echo 'push "dhcp-option DNS 94.140.14.14"' >>/etc/openvpn/server.conf - echo 'push "dhcp-option DNS 94.140.15.15"' >>/etc/openvpn/server.conf + echo 'push "dhcp-option DNS 94.140.14.14"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 94.140.15.15"' >>/etc/openvpn/server/server.conf ;; 12) # NextDNS - echo 'push "dhcp-option DNS 45.90.28.167"' >>/etc/openvpn/server.conf - echo 'push "dhcp-option DNS 45.90.30.167"' >>/etc/openvpn/server.conf + echo 'push "dhcp-option DNS 45.90.28.167"' >>/etc/openvpn/server/server.conf + echo 'push "dhcp-option DNS 45.90.30.167"' >>/etc/openvpn/server/server.conf ;; 13) # Custom DNS - echo "push \"dhcp-option DNS $DNS1\"" >>/etc/openvpn/server.conf + echo "push \"dhcp-option DNS $DNS1\"" >>/etc/openvpn/server/server.conf if [[ $DNS2 != "" ]]; then - echo "push \"dhcp-option DNS $DNS2\"" >>/etc/openvpn/server.conf + echo "push \"dhcp-option DNS $DNS2\"" >>/etc/openvpn/server/server.conf fi ;; esac - echo 'push "redirect-gateway def1 bypass-dhcp"' >>/etc/openvpn/server.conf + echo 'push "redirect-gateway def1 bypass-dhcp"' >>/etc/openvpn/server/server.conf # IPv6 network settings if needed if [[ $IPV6_SUPPORT == 'y' ]]; then @@ -862,26 +1308,29 @@ ifconfig-pool-persist ipp.txt" >>/etc/openvpn/server.conf tun-ipv6 push tun-ipv6 push "route-ipv6 2000::/3" -push "redirect-gateway ipv6"' >>/etc/openvpn/server.conf +push "redirect-gateway ipv6"' >>/etc/openvpn/server/server.conf fi if [[ $COMPRESSION_ENABLED == "y" ]]; then - echo "compress $COMPRESSION_ALG" >>/etc/openvpn/server.conf + echo "compress $COMPRESSION_ALG" >>/etc/openvpn/server/server.conf fi if [[ $DH_TYPE == "1" ]]; then - echo "dh none" >>/etc/openvpn/server.conf - echo "ecdh-curve $DH_CURVE" >>/etc/openvpn/server.conf + echo "dh none" >>/etc/openvpn/server/server.conf + echo "ecdh-curve $DH_CURVE" >>/etc/openvpn/server/server.conf elif [[ $DH_TYPE == "2" ]]; then - echo "dh dh.pem" >>/etc/openvpn/server.conf + echo "dh dh.pem" >>/etc/openvpn/server/server.conf fi case $TLS_SIG in 1) - echo "tls-crypt tls-crypt.key" >>/etc/openvpn/server.conf + echo "tls-crypt-v2 tls-crypt-v2.key" >>/etc/openvpn/server/server.conf ;; 2) - echo "tls-auth tls-auth.key 0" >>/etc/openvpn/server.conf + echo "tls-crypt tls-crypt.key" >>/etc/openvpn/server/server.conf + ;; + 3) + echo "tls-auth tls-auth.key 0" >>/etc/openvpn/server/server.conf ;; esac @@ -891,74 +1340,92 @@ cert $SERVER_NAME.crt key $SERVER_NAME.key auth $HMAC_ALG cipher $CIPHER +ignore-unknown-option data-ciphers +data-ciphers $CIPHER ncp-ciphers $CIPHER tls-server tls-version-min 1.2 +remote-cert-tls client tls-cipher $CC_CIPHER -client-config-dir /etc/openvpn/ccd +client-config-dir ccd status /var/log/openvpn/status.log -verb 3" >>/etc/openvpn/server.conf +verb 3" >>/etc/openvpn/server/server.conf # Create client-config-dir dir - mkdir -p /etc/openvpn/ccd + run_cmd_fatal "Creating client config directory" mkdir -p /etc/openvpn/server/ccd # Create log dir - mkdir -p /var/log/openvpn + run_cmd_fatal "Creating log directory" mkdir -p /var/log/openvpn + + # On distros that use a dedicated OpenVPN user (not "nobody"), e.g., Fedora, RHEL, Arch, + # set ownership so OpenVPN can read config/certs and write to log directory + if [[ $OPENVPN_USER != "nobody" ]]; then + log_info "Setting ownership for OpenVPN user..." + chown -R "$OPENVPN_USER:$OPENVPN_GROUP" /etc/openvpn/server + chown "$OPENVPN_USER:$OPENVPN_GROUP" /var/log/openvpn + fi # Enable routing + log_info "Enabling IP forwarding..." + run_cmd_fatal "Creating sysctl.d directory" mkdir -p /etc/sysctl.d echo 'net.ipv4.ip_forward=1' >/etc/sysctl.d/99-openvpn.conf if [[ $IPV6_SUPPORT == 'y' ]]; then echo 'net.ipv6.conf.all.forwarding=1' >>/etc/sysctl.d/99-openvpn.conf fi # Apply sysctl rules - sysctl --system + run_cmd "Applying sysctl rules" sysctl --system # If SELinux is enabled and a custom port was selected, we need this if hash sestatus 2>/dev/null; then if sestatus | grep "Current mode" | grep -qs "enforcing"; then if [[ $PORT != '1194' ]]; then - semanage port -a -t openvpn_port_t -p "$PROTOCOL" "$PORT" + run_cmd "Configuring SELinux port" semanage port -a -t openvpn_port_t -p "$PROTOCOL" "$PORT" fi fi fi # Finally, restart and enable OpenVPN - if [[ $OS == 'arch' || $OS == 'fedora' || $OS == 'centos' || $OS == 'oracle' ]]; then - # Don't modify package-provided service - cp /usr/lib/systemd/system/openvpn-server@.service /etc/systemd/system/openvpn-server@.service + # OpenVPN 2.4+ uses openvpn-server@.service with config in /etc/openvpn/server/ + log_info "Configuring OpenVPN service..." - # Workaround to fix OpenVPN service on OpenVZ - sed -i 's|LimitNPROC|#LimitNPROC|' /etc/systemd/system/openvpn-server@.service - # Another workaround to keep using /etc/openvpn/ - sed -i 's|/etc/openvpn/server|/etc/openvpn|' /etc/systemd/system/openvpn-server@.service - - systemctl daemon-reload - systemctl enable openvpn-server@server - systemctl restart openvpn-server@server - elif [[ $OS == "ubuntu" ]] && [[ $VERSION_ID == "16.04" ]]; then - # On Ubuntu 16.04, we use the package from the OpenVPN repo - # This package uses a sysvinit service - systemctl enable openvpn - systemctl start openvpn + # Find the service file (location and name vary by distro) + # Modern distros: openvpn-server@.service in /usr/lib/systemd/system/ or /lib/systemd/system/ + # openSUSE: openvpn@.service (old-style) that we need to adapt + if [[ -f /usr/lib/systemd/system/openvpn-server@.service ]]; then + SERVICE_SOURCE="/usr/lib/systemd/system/openvpn-server@.service" + elif [[ -f /lib/systemd/system/openvpn-server@.service ]]; then + SERVICE_SOURCE="/lib/systemd/system/openvpn-server@.service" + elif [[ -f /usr/lib/systemd/system/openvpn@.service ]]; then + # openSUSE uses old-style service, we'll create our own openvpn-server@.service + SERVICE_SOURCE="/usr/lib/systemd/system/openvpn@.service" + elif [[ -f /lib/systemd/system/openvpn@.service ]]; then + SERVICE_SOURCE="/lib/systemd/system/openvpn@.service" else - # Don't modify package-provided service - cp /lib/systemd/system/openvpn\@.service /etc/systemd/system/openvpn\@.service - - # Workaround to fix OpenVPN service on OpenVZ - sed -i 's|LimitNPROC|#LimitNPROC|' /etc/systemd/system/openvpn\@.service - # Another workaround to keep using /etc/openvpn/ - sed -i 's|/etc/openvpn/server|/etc/openvpn|' /etc/systemd/system/openvpn\@.service - - systemctl daemon-reload - systemctl enable openvpn@server - systemctl restart openvpn@server + log_fatal "Could not find openvpn-server@.service or openvpn@.service file" fi + # Don't modify package-provided service, copy to /etc/systemd/system/ + run_cmd_fatal "Copying OpenVPN service file" cp "$SERVICE_SOURCE" /etc/systemd/system/openvpn-server@.service + + # Workaround to fix OpenVPN service on OpenVZ + run_cmd "Patching service file (LimitNPROC)" sed -i 's|LimitNPROC|#LimitNPROC|' /etc/systemd/system/openvpn-server@.service + + # Ensure the service uses /etc/openvpn/server/ as working directory + # This is needed for openSUSE which uses old-style paths by default + if grep -q "cd /etc/openvpn/" /etc/systemd/system/openvpn-server@.service; then + run_cmd "Patching service file (paths)" sed -i 's|/etc/openvpn/|/etc/openvpn/server/|g' /etc/systemd/system/openvpn-server@.service + fi + + run_cmd "Reloading systemd" systemctl daemon-reload + run_cmd "Enabling OpenVPN service" systemctl enable openvpn-server@server + run_cmd "Starting OpenVPN service" systemctl restart openvpn-server@server + if [[ $DNS == 2 ]]; then installUnbound fi # Add iptables rules in two scripts - mkdir -p /etc/iptables + log_info "Configuring firewall rules..." + run_cmd_fatal "Creating iptables directory" mkdir -p /etc/iptables # Script to add rules echo "#!/bin/sh @@ -992,8 +1459,8 @@ ip6tables -D FORWARD -i tun0 -o $NIC -j ACCEPT ip6tables -D INPUT -i $NIC -p $PROTOCOL --dport $PORT -j ACCEPT" >>/etc/iptables/rm-openvpn-rules.sh fi - chmod +x /etc/iptables/add-openvpn-rules.sh - chmod +x /etc/iptables/rm-openvpn-rules.sh + run_cmd "Making add-openvpn-rules.sh executable" chmod +x /etc/iptables/add-openvpn-rules.sh + run_cmd "Making rm-openvpn-rules.sh executable" chmod +x /etc/iptables/rm-openvpn-rules.sh # Handle the rules via a systemd script echo "[Unit] @@ -1011,9 +1478,9 @@ RemainAfterExit=yes WantedBy=multi-user.target" >/etc/systemd/system/iptables-openvpn.service # Enable service and apply rules - systemctl daemon-reload - systemctl enable iptables-openvpn - systemctl start iptables-openvpn + run_cmd "Reloading systemd" systemctl daemon-reload + run_cmd "Enabling iptables service" systemctl enable iptables-openvpn + run_cmd "Starting iptables service" systemctl start iptables-openvpn # If the server is behind a NAT, use the correct IP address for the clients to connect to if [[ $ENDPOINT != "" ]]; then @@ -1021,12 +1488,13 @@ WantedBy=multi-user.target" >/etc/systemd/system/iptables-openvpn.service fi # client-template.txt is created so we have a template to add further users later - echo "client" >/etc/openvpn/client-template.txt + log_info "Creating client template..." + echo "client" >/etc/openvpn/server/client-template.txt if [[ $PROTOCOL == 'udp' ]]; then - echo "proto udp" >>/etc/openvpn/client-template.txt - echo "explicit-exit-notify" >>/etc/openvpn/client-template.txt + echo "proto udp" >>/etc/openvpn/server/client-template.txt + echo "explicit-exit-notify" >>/etc/openvpn/server/client-template.txt elif [[ $PROTOCOL == 'tcp' ]]; then - echo "proto tcp-client" >>/etc/openvpn/client-template.txt + echo "proto tcp-client" >>/etc/openvpn/server/client-template.txt fi echo "remote $IP $PORT dev tun @@ -1039,297 +1507,594 @@ verify-x509-name $SERVER_NAME name auth $HMAC_ALG auth-nocache cipher $CIPHER +ignore-unknown-option data-ciphers +data-ciphers $CIPHER +ncp-ciphers $CIPHER tls-client tls-version-min 1.2 tls-cipher $CC_CIPHER ignore-unknown-option block-outside-dns setenv opt block-outside-dns # Prevent Windows 10 DNS leak -verb 3" >>/etc/openvpn/client-template.txt +verb 3" >>/etc/openvpn/server/client-template.txt if [[ $COMPRESSION_ENABLED == "y" ]]; then - echo "compress $COMPRESSION_ALG" >>/etc/openvpn/client-template.txt + echo "compress $COMPRESSION_ALG" >>/etc/openvpn/server/client-template.txt fi # Generate the custom client.ovpn + log_info "Generating first client certificate..." newClient - echo "If you want to add more clients, you simply need to run this script another time!" + log_success "If you want to add more clients, you simply need to run this script another time!" +} + +# Helper function to get the home directory for storing client configs +function getHomeDir() { + local client="$1" + if [ -e "/home/${client}" ]; then + echo "/home/${client}" + elif [ "${SUDO_USER}" ]; then + if [ "${SUDO_USER}" == "root" ]; then + echo "/root" + else + echo "/home/${SUDO_USER}" + fi + else + echo "/root" + fi +} + +# Helper function to get the owner of a client config file (if client matches a system user) +function getClientOwner() { + local client="$1" + if [ -e "/home/${client}" ]; then + echo "${client}" + elif [ "${SUDO_USER}" ] && [ "${SUDO_USER}" != "root" ]; then + echo "${SUDO_USER}" + fi +} + +# Helper function to set proper ownership and permissions on client config file +function setClientConfigPermissions() { + local filepath="$1" + local owner="$2" + + if [[ -n "$owner" ]]; then + local owner_group + owner_group=$(id -gn "$owner") + chmod go-rw "$filepath" + chown "$owner:$owner_group" "$filepath" + fi +} + +# Helper function to regenerate the CRL after certificate changes +function regenerateCRL() { + export EASYRSA_CRL_DAYS=$DEFAULT_CRL_VALIDITY_DURATION_DAYS + run_cmd_fatal "Regenerating CRL" ./easyrsa gen-crl + run_cmd "Removing old CRL" rm -f /etc/openvpn/server/crl.pem + run_cmd_fatal "Copying new CRL" cp /etc/openvpn/server/easy-rsa/pki/crl.pem /etc/openvpn/server/crl.pem + run_cmd "Setting CRL permissions" chmod 644 /etc/openvpn/server/crl.pem +} + +# Helper function to generate .ovpn client config file +# Usage: generateClientConfig +function generateClientConfig() { + local client="$1" + local filepath="$2" + + # Determine if we use tls-crypt-v2, tls-crypt, or tls-auth + local tls_sig="" + if grep -qs "^tls-crypt-v2" /etc/openvpn/server/server.conf; then + tls_sig="1" + elif grep -qs "^tls-crypt" /etc/openvpn/server/server.conf; then + tls_sig="2" + elif grep -qs "^tls-auth" /etc/openvpn/server/server.conf; then + tls_sig="3" + fi + + # Generate the custom client.ovpn + run_cmd "Creating client config" cp /etc/openvpn/server/client-template.txt "$filepath" + { + echo "" + cat "/etc/openvpn/server/easy-rsa/pki/ca.crt" + echo "" + + echo "" + awk '/BEGIN/,/END CERTIFICATE/' "/etc/openvpn/server/easy-rsa/pki/issued/$client.crt" + echo "" + + echo "" + cat "/etc/openvpn/server/easy-rsa/pki/private/$client.key" + echo "" + + case $tls_sig in + 1) + # Generate per-client tls-crypt-v2 key using secure temp file + tls_crypt_v2_tmpfile=$(mktemp) + if ! openvpn --tls-crypt-v2 /etc/openvpn/server/tls-crypt-v2.key \ + --genkey tls-crypt-v2-client "$tls_crypt_v2_tmpfile"; then + rm -f "$tls_crypt_v2_tmpfile" + log_error "Failed to generate tls-crypt-v2 client key" + exit 1 + fi + echo "" + cat "$tls_crypt_v2_tmpfile" + echo "" + rm -f "$tls_crypt_v2_tmpfile" + ;; + 2) + echo "" + cat /etc/openvpn/server/tls-crypt.key + echo "" + ;; + 3) + echo "key-direction 1" + echo "" + cat /etc/openvpn/server/tls-auth.key + echo "" + ;; + esac + } >>"$filepath" +} + +# Helper function to list valid clients and select one +# Arguments: show_expiry (optional, "true" to show expiry info) +# Sets global variables: +# CLIENT - the selected client name +# CLIENTNUMBER - the selected client number (1-based index) +# NUMBEROFCLIENTS - total count of valid clients +function selectClient() { + local show_expiry="${1:-false}" + local client_number + + NUMBEROFCLIENTS=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep -c "^V") + if [[ $NUMBEROFCLIENTS == '0' ]]; then + log_fatal "You have no existing clients!" + fi + + if [[ $show_expiry == "true" ]]; then + local i=1 + while read -r client; do + local client_cert="/etc/openvpn/server/easy-rsa/pki/issued/$client.crt" + local days + days=$(getDaysUntilExpiry "$client_cert") + local expiry + expiry=$(formatExpiry "$days") + echo " $i) $client $expiry" + ((i++)) + done < <(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep "^V" | cut -d '=' -f 2) + else + tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep "^V" | cut -d '=' -f 2 | nl -s ') ' + fi + + until [[ ${CLIENTNUMBER:-$client_number} -ge 1 && ${CLIENTNUMBER:-$client_number} -le $NUMBEROFCLIENTS ]]; do + if [[ $NUMBEROFCLIENTS == '1' ]]; then + read -rp "Select one client [1]: " client_number + else + read -rp "Select one client [1-$NUMBEROFCLIENTS]: " client_number + fi + done + CLIENTNUMBER="${CLIENTNUMBER:-$client_number}" + CLIENT=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep "^V" | cut -d '=' -f 2 | sed -n "$CLIENTNUMBER"p) } function newClient() { - echo "" - echo "Tell me a name for the client." - echo "The name must consist of alphanumeric character. It may also include an underscore or a dash." + log_header "New Client Setup" + log_prompt "Tell me a name for the client." + log_prompt "The name must consist of alphanumeric character. It may also include an underscore or a dash." until [[ $CLIENT =~ ^[a-zA-Z0-9_-]+$ ]]; do read -rp "Client name: " -e CLIENT done - echo "" - echo "Do you want to protect the configuration file with a password?" - echo "(e.g. encrypt the private key with a password)" - echo " 1) Add a passwordless client" - echo " 2) Use a password for the client" + if [[ -z $CLIENT_CERT_DURATION_DAYS ]] || ! [[ $CLIENT_CERT_DURATION_DAYS =~ ^[0-9]+$ ]] || [[ $CLIENT_CERT_DURATION_DAYS -lt 1 ]]; then + log_menu "" + log_prompt "How many days should the client certificate be valid for?" + until [[ $CLIENT_CERT_DURATION_DAYS =~ ^[0-9]+$ ]] && [[ $CLIENT_CERT_DURATION_DAYS -ge 1 ]]; do + read -rp "Certificate validity (days): " -e -i $DEFAULT_CERT_VALIDITY_DURATION_DAYS CLIENT_CERT_DURATION_DAYS + done + fi + + log_menu "" + log_prompt "Do you want to protect the configuration file with a password?" + log_prompt "(e.g. encrypt the private key with a password)" + log_menu " 1) Add a passwordless client" + log_menu " 2) Use a password for the client" until [[ $PASS =~ ^[1-2]$ ]]; do read -rp "Select an option [1-2]: " -e -i 1 PASS done - CLIENTEXISTS=$(tail -n +2 /etc/openvpn/easy-rsa/pki/index.txt | grep -c -E "/CN=$CLIENT\$") - if [[ $CLIENTEXISTS == '1' ]]; then - echo "" - echo "The specified client CN was already found in easy-rsa, please choose another name." + CLIENTEXISTS=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep -E "^V" | grep -c -E "/CN=$CLIENT\$") + if [[ $CLIENTEXISTS != '0' ]]; then + log_error "The specified client CN was already found in easy-rsa, please choose another name." exit else - cd /etc/openvpn/easy-rsa/ || return + cd /etc/openvpn/server/easy-rsa/ || return + log_info "Generating client certificate..." + export EASYRSA_CERT_EXPIRE=$CLIENT_CERT_DURATION_DAYS case $PASS in 1) - ./easyrsa build-client-full "$CLIENT" nopass + run_cmd_fatal "Building client certificate" ./easyrsa --batch build-client-full "$CLIENT" nopass ;; 2) - echo "⚠️ You will be asked for the client password below ⚠️" - ./easyrsa build-client-full "$CLIENT" - ;; - esac - echo "Client $CLIENT added." - fi - - if [[ -z "$CLIENT_FILEPATH" ]]; then - # Home directory of the user, where the client configuration will be written - if [ -e "/home/${CLIENT}" ]; then - # if $1 is a user name - homeDir="/home/${CLIENT}" - CLIENT_OWNER="$CLIENT" - elif [ "${SUDO_USER}" ]; then - # if not, use SUDO_USER - if [ "${SUDO_USER}" == "root" ]; then - # If running sudo as root - homeDir="/root" - else - homeDir="/home/${SUDO_USER}" + log_warn "You will be asked for the client password below" + # Run directly (not via run_cmd) so password prompt is visible to user + if ! ./easyrsa --batch build-client-full "$CLIENT"; then + log_fatal "Building client certificate failed" fi - CLIENT_OWNER="$SUDO_USER" - else - # if not SUDO_USER, use /root - homeDir="/root" - fi - - CLIENT_FILEPATH="$homeDir/$CLIENT.ovpn" - fi - - # Determine if we use tls-auth or tls-crypt - if grep -qs "^tls-crypt" /etc/openvpn/server.conf; then - TLS_SIG="1" - elif grep -qs "^tls-auth" /etc/openvpn/server.conf; then - TLS_SIG="2" - fi - - # Generates the custom client.ovpn - cp /etc/openvpn/client-template.txt "$CLIENT_FILEPATH" - { - echo "" - cat "/etc/openvpn/easy-rsa/pki/ca.crt" - echo "" - - echo "" - awk '/BEGIN/,/END/' "/etc/openvpn/easy-rsa/pki/issued/$CLIENT.crt" - echo "" - - echo "" - cat "/etc/openvpn/easy-rsa/pki/private/$CLIENT.key" - echo "" - - case $TLS_SIG in - 1) - echo "" - cat /etc/openvpn/tls-crypt.key - echo "" - ;; - 2) - echo "key-direction 1" - echo "" - cat /etc/openvpn/tls-auth.key - echo "" ;; esac - } >>"$CLIENT_FILEPATH" - - if [[ -n "$CLIENT_OWNER" ]]; then - echo "Setting owner permission for $CLIENT_FILEPATH" - CLIENT_OWNER_GROUP=$(id -gn "$CLIENT_OWNER") - - chmod go-rw "$CLIENT_FILEPATH" - chown "$CLIENT_OWNER:$CLIENT_OWNER_GROUP" "$CLIENT_FILEPATH" + log_success "Client $CLIENT added and is valid for $CLIENT_CERT_DURATION_DAYS days." fi - echo "" - echo "The configuration file has been written to $CLIENT_FILEPATH." - echo "Download the .ovpn file and import it in your OpenVPN client." + # Determine output file path + local clientFilePath + if [[ -n "$CLIENT_FILEPATH" ]]; then + clientFilePath="$CLIENT_FILEPATH" + else + local homeDir + homeDir=$(getHomeDir "$CLIENT") + clientFilePath="$homeDir/$CLIENT.ovpn" + fi + + # Generate the .ovpn config file + generateClientConfig "$CLIENT" "$clientFilePath" + + # Set proper ownership and permissions if client matches a system user + local clientOwner + clientOwner=$(getClientOwner "$CLIENT") + setClientConfigPermissions "$clientFilePath" "$clientOwner" + + log_menu "" + log_success "The configuration file has been written to $clientFilePath." + log_info "Download the .ovpn file and import it in your OpenVPN client." exit 0 } function revokeClient() { - NUMBEROFCLIENTS=$(tail -n +2 /etc/openvpn/easy-rsa/pki/index.txt | grep -c "^V") - if [[ $NUMBEROFCLIENTS == '0' ]]; then - echo "" - echo "You have no existing clients!" - exit 1 + log_header "Revoke Client" + log_prompt "Select the existing client certificate you want to revoke" + selectClient + + cd /etc/openvpn/server/easy-rsa/ || return + log_info "Revoking certificate for $CLIENT..." + run_cmd_fatal "Revoking certificate" ./easyrsa --batch revoke-issued "$CLIENT" + regenerateCRL + run_cmd "Removing client config from /home" find /home/ -maxdepth 2 -name "$CLIENT.ovpn" -delete + run_cmd "Removing client config from /root" rm -f "/root/$CLIENT.ovpn" + run_cmd "Removing IP assignment" sed -i "/^$CLIENT,.*/d" /etc/openvpn/server/ipp.txt + run_cmd "Backing up index" cp /etc/openvpn/server/easy-rsa/pki/index.txt{,.bk} + + log_success "Certificate for client $CLIENT revoked." +} + +function renewClient() { + local client_cert_duration_days + + log_header "Renew Client Certificate" + log_prompt "Select the existing client certificate you want to renew" + selectClient "true" + + # Allow user to specify renewal duration (use CLIENT_CERT_DURATION_DAYS env var for headless mode) + if [[ -z $CLIENT_CERT_DURATION_DAYS ]] || ! [[ $CLIENT_CERT_DURATION_DAYS =~ ^[0-9]+$ ]] || [[ $CLIENT_CERT_DURATION_DAYS -lt 1 ]]; then + log_menu "" + log_prompt "How many days should the renewed certificate be valid for?" + until [[ $client_cert_duration_days =~ ^[0-9]+$ ]] && [[ $client_cert_duration_days -ge 1 ]]; do + read -rp "Certificate validity (days): " -e -i $DEFAULT_CERT_VALIDITY_DURATION_DAYS client_cert_duration_days + done + else + client_cert_duration_days=$CLIENT_CERT_DURATION_DAYS fi - echo "" - echo "Select the existing client certificate you want to revoke" - tail -n +2 /etc/openvpn/easy-rsa/pki/index.txt | grep "^V" | cut -d '=' -f 2 | nl -s ') ' - until [[ $CLIENTNUMBER -ge 1 && $CLIENTNUMBER -le $NUMBEROFCLIENTS ]]; do - if [[ $CLIENTNUMBER == '1' ]]; then - read -rp "Select one client [1]: " CLIENTNUMBER - else - read -rp "Select one client [1-$NUMBEROFCLIENTS]: " CLIENTNUMBER - fi - done - CLIENT=$(tail -n +2 /etc/openvpn/easy-rsa/pki/index.txt | grep "^V" | cut -d '=' -f 2 | sed -n "$CLIENTNUMBER"p) - cd /etc/openvpn/easy-rsa/ || return - ./easyrsa --batch revoke "$CLIENT" - EASYRSA_CRL_DAYS=3650 ./easyrsa gen-crl - rm -f /etc/openvpn/crl.pem - cp /etc/openvpn/easy-rsa/pki/crl.pem /etc/openvpn/crl.pem - chmod 644 /etc/openvpn/crl.pem - find /home/ -maxdepth 2 -name "$CLIENT.ovpn" -delete - rm -f "/root/$CLIENT.ovpn" - sed -i "/^$CLIENT,.*/d" /etc/openvpn/ipp.txt - cp /etc/openvpn/easy-rsa/pki/index.txt{,.bk} + cd /etc/openvpn/server/easy-rsa/ || return + log_info "Renewing certificate for $CLIENT..." - echo "" - echo "Certificate for client $CLIENT revoked." + # Backup the old certificate before renewal + run_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" + + # Renew the certificate (keeps the same private key) + export EASYRSA_CERT_EXPIRE=$client_cert_duration_days + run_cmd_fatal "Renewing certificate" ./easyrsa --batch renew "$CLIENT" + + # Revoke the old certificate + run_cmd_fatal "Revoking old certificate" ./easyrsa --batch revoke-renewed "$CLIENT" + + # Regenerate the CRL + regenerateCRL + + # Determine output file path + local clientFilePath + if [[ -n "$CLIENT_FILEPATH" ]]; then + clientFilePath="$CLIENT_FILEPATH" + else + local homeDir + homeDir=$(getHomeDir "$CLIENT") + clientFilePath="$homeDir/$CLIENT.ovpn" + fi + + # Regenerate the .ovpn file with the new certificate + generateClientConfig "$CLIENT" "$clientFilePath" + + # Set proper ownership and permissions if client matches a system user + local clientOwner + clientOwner=$(getClientOwner "$CLIENT") + setClientConfigPermissions "$clientFilePath" "$clientOwner" + + log_menu "" + log_success "Certificate for client $CLIENT renewed and is valid for $client_cert_duration_days days." + log_info "The new configuration file has been written to $clientFilePath." + log_info "Download the new .ovpn file and import it in your OpenVPN client." +} + +function renewServer() { + local server_name server_cert_duration_days + + log_header "Renew Server Certificate" + + # Get the server name from the config (extract basename since path may be relative) + server_name=$(basename "$(grep '^cert ' /etc/openvpn/server/server.conf | cut -d ' ' -f 2)" .crt) + if [[ -z "$server_name" ]]; then + log_fatal "Could not determine server certificate name from /etc/openvpn/server/server.conf" + fi + + log_prompt "This will renew the server certificate: $server_name" + log_warn "The OpenVPN service will be restarted after renewal." + if [[ -z $CONTINUE ]]; then + read -rp "Do you want to continue? [y/n]: " -e -i n CONTINUE + fi + if [[ $CONTINUE != "y" ]]; then + log_info "Renewal aborted." + return + fi + + # Allow user to specify renewal duration (use SERVER_CERT_DURATION_DAYS env var for headless mode) + if [[ -z $SERVER_CERT_DURATION_DAYS ]] || ! [[ $SERVER_CERT_DURATION_DAYS =~ ^[0-9]+$ ]] || [[ $SERVER_CERT_DURATION_DAYS -lt 1 ]]; then + log_menu "" + log_prompt "How many days should the renewed certificate be valid for?" + until [[ $server_cert_duration_days =~ ^[0-9]+$ ]] && [[ $server_cert_duration_days -ge 1 ]]; do + read -rp "Certificate validity (days): " -e -i $DEFAULT_CERT_VALIDITY_DURATION_DAYS server_cert_duration_days + done + else + server_cert_duration_days=$SERVER_CERT_DURATION_DAYS + fi + + cd /etc/openvpn/server/easy-rsa/ || return + log_info "Renewing server certificate..." + + # Backup the old certificate before renewal + run_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" + + # Renew the certificate (keeps the same private key) + export EASYRSA_CERT_EXPIRE=$server_cert_duration_days + run_cmd_fatal "Renewing certificate" ./easyrsa --batch renew "$server_name" + + # Revoke the old certificate + run_cmd_fatal "Revoking old certificate" ./easyrsa --batch revoke-renewed "$server_name" + + # Regenerate the CRL + regenerateCRL + + # Copy the new certificate to /etc/openvpn/server/ + run_cmd_fatal "Copying new certificate" cp "/etc/openvpn/server/easy-rsa/pki/issued/$server_name.crt" /etc/openvpn/server/ + + # Restart OpenVPN + log_info "Restarting OpenVPN service..." + run_cmd "Restarting OpenVPN" systemctl restart openvpn-server@server + + log_success "Server certificate renewed successfully and is valid for $server_cert_duration_days days." +} + +function getDaysUntilExpiry() { + local cert_file="$1" + if [[ -f "$cert_file" ]]; then + local expiry_date + expiry_date=$(openssl x509 -in "$cert_file" -noout -enddate | cut -d= -f2) + local expiry_epoch + expiry_epoch=$(date -d "$expiry_date" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$expiry_date" +%s 2>/dev/null) + if [[ -z "$expiry_epoch" ]]; then + echo "?" + return + fi + local now_epoch + now_epoch=$(date +%s) + echo $(((expiry_epoch - now_epoch) / 86400)) + else + echo "?" + fi +} + +function formatExpiry() { + local days="$1" + if [[ "$days" == "?" ]]; then + echo "(unknown expiry)" + elif [[ $days -lt 0 ]]; then + echo "(EXPIRED $((-days)) days ago)" + elif [[ $days -eq 0 ]]; then + echo "(expires today)" + elif [[ $days -eq 1 ]]; then + echo "(expires in 1 day)" + else + echo "(expires in $days days)" + fi +} + +function renewMenu() { + local server_name server_cert server_days server_expiry renew_option + + log_header "Certificate Renewal" + + # Get server certificate expiry for menu display (extract basename since path may be relative) + server_name=$(basename "$(grep '^cert ' /etc/openvpn/server/server.conf | cut -d ' ' -f 2)" .crt) + if [[ -z "$server_name" ]]; then + server_expiry="(unknown expiry)" + else + server_cert="/etc/openvpn/server/easy-rsa/pki/issued/$server_name.crt" + server_days=$(getDaysUntilExpiry "$server_cert") + server_expiry=$(formatExpiry "$server_days") + fi + + log_menu "" + log_prompt "What do you want to renew?" + log_menu " 1) Renew a client certificate" + log_menu " 2) Renew the server certificate $server_expiry" + log_menu " 3) Back to main menu" + until [[ ${RENEW_OPTION:-$renew_option} =~ ^[1-3]$ ]]; do + read -rp "Select an option [1-3]: " renew_option + done + renew_option="${RENEW_OPTION:-$renew_option}" + + case $renew_option in + 1) + renewClient + ;; + 2) + renewServer + ;; + 3) + manageMenu + ;; + esac } function removeUnbound() { - # Remove OpenVPN-related config - sed -i '/include: \/etc\/unbound\/openvpn.conf/d' /etc/unbound/unbound.conf - rm /etc/unbound/openvpn.conf + run_cmd "Removing OpenVPN Unbound config" rm -f /etc/unbound/unbound.conf.d/openvpn.conf + + # Clean up include directive if conf.d directory is now empty + if [[ -d /etc/unbound/unbound.conf.d ]] && [[ -z "$(ls -A /etc/unbound/unbound.conf.d)" ]]; then + run_cmd "Cleaning up Unbound include directive" \ + sed -i '/^include: "\/etc\/unbound\/unbound\.conf\.d\/\*\.conf"$/d' /etc/unbound/unbound.conf + fi until [[ $REMOVE_UNBOUND =~ (y|n) ]]; do - echo "" - echo "If you were already using Unbound before installing OpenVPN, I removed the configuration related to OpenVPN." + log_info "If you were already using Unbound before installing OpenVPN, I removed the configuration related to OpenVPN." read -rp "Do you want to completely remove Unbound? [y/n]: " -e REMOVE_UNBOUND done if [[ $REMOVE_UNBOUND == 'y' ]]; then - # Stop Unbound - systemctl stop unbound + log_info "Removing Unbound..." + run_cmd "Stopping Unbound" systemctl stop unbound if [[ $OS =~ (debian|ubuntu) ]]; then - apt-get remove --purge -y unbound + run_cmd "Removing Unbound" apt-get remove --purge -y unbound elif [[ $OS == 'arch' ]]; then - pacman --noconfirm -R unbound - elif [[ $OS =~ (centos|amzn|oracle) ]]; then - yum remove -y unbound - elif [[ $OS == 'fedora' ]]; then - dnf remove -y unbound + run_cmd "Removing Unbound" pacman --noconfirm -R unbound + elif [[ $OS =~ (centos|oracle) ]]; then + run_cmd "Removing Unbound" yum remove -y unbound + elif [[ $OS =~ (fedora|amzn2023) ]]; then + run_cmd "Removing Unbound" dnf remove -y unbound + elif [[ $OS == 'opensuse' ]]; then + run_cmd "Removing Unbound" zypper remove -y unbound fi - rm -rf /etc/unbound/ - - echo "" - echo "Unbound removed!" + run_cmd "Removing Unbound config" rm -rf /etc/unbound/ + log_success "Unbound removed!" else - systemctl restart unbound - echo "" - echo "Unbound wasn't removed." + run_cmd "Restarting Unbound" systemctl restart unbound + log_info "Unbound wasn't removed." fi } function removeOpenVPN() { - echo "" + log_header "Remove OpenVPN" read -rp "Do you really want to remove OpenVPN? [y/n]: " -e -i n REMOVE if [[ $REMOVE == 'y' ]]; then # Get OpenVPN port from the configuration - PORT=$(grep '^port ' /etc/openvpn/server.conf | cut -d " " -f 2) - PROTOCOL=$(grep '^proto ' /etc/openvpn/server.conf | cut -d " " -f 2) + PORT=$(grep '^port ' /etc/openvpn/server/server.conf | cut -d " " -f 2) + PROTOCOL=$(grep '^proto ' /etc/openvpn/server/server.conf | cut -d " " -f 2) # Stop OpenVPN - if [[ $OS =~ (fedora|arch|centos|oracle) ]]; then - systemctl disable openvpn-server@server - systemctl stop openvpn-server@server - # Remove customised service - rm /etc/systemd/system/openvpn-server@.service - elif [[ $OS == "ubuntu" ]] && [[ $VERSION_ID == "16.04" ]]; then - systemctl disable openvpn - systemctl stop openvpn - else - systemctl disable openvpn@server - systemctl stop openvpn@server - # Remove customised service - rm /etc/systemd/system/openvpn\@.service - fi + log_info "Stopping OpenVPN service..." + run_cmd "Disabling OpenVPN service" systemctl disable openvpn-server@server + run_cmd "Stopping OpenVPN service" systemctl stop openvpn-server@server + # Remove customised service + run_cmd "Removing service file" rm -f /etc/systemd/system/openvpn-server@.service # Remove the iptables rules related to the script - systemctl stop iptables-openvpn + log_info "Removing iptables rules..." + run_cmd "Stopping iptables service" systemctl stop iptables-openvpn # Cleanup - systemctl disable iptables-openvpn - rm /etc/systemd/system/iptables-openvpn.service - systemctl daemon-reload - rm /etc/iptables/add-openvpn-rules.sh - rm /etc/iptables/rm-openvpn-rules.sh + run_cmd "Disabling iptables service" systemctl disable iptables-openvpn + run_cmd "Removing iptables service file" rm /etc/systemd/system/iptables-openvpn.service + run_cmd "Reloading systemd" systemctl daemon-reload + run_cmd "Removing iptables add script" rm /etc/iptables/add-openvpn-rules.sh + run_cmd "Removing iptables rm script" rm /etc/iptables/rm-openvpn-rules.sh # SELinux if hash sestatus 2>/dev/null; then if sestatus | grep "Current mode" | grep -qs "enforcing"; then if [[ $PORT != '1194' ]]; then - semanage port -d -t openvpn_port_t -p "$PROTOCOL" "$PORT" + run_cmd "Removing SELinux port" semanage port -d -t openvpn_port_t -p "$PROTOCOL" "$PORT" fi fi fi + log_info "Removing OpenVPN package..." if [[ $OS =~ (debian|ubuntu) ]]; then - apt-get remove --purge -y openvpn - if [[ -e /etc/apt/sources.list.d/openvpn.list ]]; then - rm /etc/apt/sources.list.d/openvpn.list - apt-get update + run_cmd "Removing OpenVPN" apt-get remove --purge -y openvpn + # Remove OpenVPN official repository and GPG key + if [[ -e /etc/apt/sources.list.d/openvpn-aptrepo.list ]]; then + run_cmd "Removing OpenVPN repo" rm /etc/apt/sources.list.d/openvpn-aptrepo.list fi + if [[ -e /etc/apt/keyrings/openvpn-repo-public.asc ]]; then + run_cmd "Removing OpenVPN GPG key" rm /etc/apt/keyrings/openvpn-repo-public.asc + fi + run_cmd "Updating package lists" apt-get update elif [[ $OS == 'arch' ]]; then - pacman --noconfirm -R openvpn - elif [[ $OS =~ (centos|amzn|oracle) ]]; then - yum remove -y openvpn + run_cmd "Removing OpenVPN" pacman --noconfirm -R openvpn + elif [[ $OS =~ (centos|oracle) ]]; then + run_cmd "Removing OpenVPN" yum remove -y openvpn + # Disable Copr repo if it was enabled + if command -v dnf &>/dev/null; then + run_cmd "Disabling OpenVPN Copr repo" dnf copr disable -y @OpenVPN/openvpn-release-2.6 2>/dev/null || true + else + run_cmd "Disabling OpenVPN Copr repo" yum copr disable -y @OpenVPN/openvpn-release-2.6 2>/dev/null || true + fi + elif [[ $OS == 'amzn2023' ]]; then + run_cmd "Removing OpenVPN" dnf remove -y openvpn elif [[ $OS == 'fedora' ]]; then - dnf remove -y openvpn + run_cmd "Removing OpenVPN" dnf remove -y openvpn + elif [[ $OS == 'opensuse' ]]; then + run_cmd "Removing OpenVPN" zypper remove -y openvpn fi # Cleanup - find /home/ -maxdepth 2 -name "*.ovpn" -delete - find /root/ -maxdepth 1 -name "*.ovpn" -delete - rm -rf /etc/openvpn - rm -rf /usr/share/doc/openvpn* - rm -f /etc/sysctl.d/99-openvpn.conf - rm -rf /var/log/openvpn + run_cmd "Removing client configs from /home" find /home/ -maxdepth 2 -name "*.ovpn" -delete + run_cmd "Removing client configs from /root" find /root/ -maxdepth 1 -name "*.ovpn" -delete + run_cmd "Removing /etc/openvpn" rm -rf /etc/openvpn + run_cmd "Removing OpenVPN docs" rm -rf /usr/share/doc/openvpn* + run_cmd "Removing sysctl config" rm -f /etc/sysctl.d/99-openvpn.conf + run_cmd "Removing OpenVPN logs" rm -rf /var/log/openvpn # Unbound - if [[ -e /etc/unbound/openvpn.conf ]]; then + if [[ -e /etc/unbound/unbound.conf.d/openvpn.conf ]]; then removeUnbound fi - echo "" - echo "OpenVPN removed!" + log_success "OpenVPN removed!" else - echo "" - echo "Removal aborted!" + log_info "Removal aborted!" fi } function manageMenu() { - echo "Welcome to OpenVPN-install!" - echo "The git repository is available at: https://github.com/angristan/openvpn-install" - echo "" - echo "It looks like OpenVPN is already installed." - echo "" - echo "What do you want to do?" - echo " 1) Add a new user" - echo " 2) Revoke existing user" - echo " 3) Remove OpenVPN" - echo " 4) Exit" - until [[ $MENU_OPTION =~ ^[1-4]$ ]]; do - read -rp "Select an option [1-4]: " MENU_OPTION - done + local menu_option - case $MENU_OPTION in + log_header "OpenVPN Management" + log_prompt "The git repository is available at: https://github.com/angristan/openvpn-install" + log_success "OpenVPN is already installed." + log_menu "" + log_prompt "What do you want to do?" + log_menu " 1) Add a new user" + log_menu " 2) Revoke existing user" + log_menu " 3) Renew certificate" + log_menu " 4) Remove OpenVPN" + log_menu " 5) Exit" + until [[ ${MENU_OPTION:-$menu_option} =~ ^[1-5]$ ]]; do + read -rp "Select an option [1-5]: " menu_option + done + menu_option="${MENU_OPTION:-$menu_option}" + + case $menu_option in 1) newClient ;; @@ -1337,9 +2102,12 @@ function manageMenu() { revokeClient ;; 3) - removeOpenVPN + renewMenu ;; 4) + removeOpenVPN + ;; + 5) exit 0 ;; esac @@ -1349,7 +2117,7 @@ function manageMenu() { initialCheck # Check if OpenVPN is already installed -if [[ -e /etc/openvpn/server.conf && $AUTO_INSTALL != "y" ]]; then +if [[ -e /etc/openvpn/server/server.conf ]]; then manageMenu else installOpenVPN diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..ab80063 --- /dev/null +++ b/renovate.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "ignorePaths": [".github/workflows/do-test.yml"], + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/^openvpn-install\\.sh$/"], + "matchStrings": [ + "readonly\\s+EASYRSA_VERSION=\"(?\\d+\\.\\d+\\.\\d+)\"" + ], + "depNameTemplate": "OpenVPN/easy-rsa", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.*)$" + } + ] +} diff --git a/test/Dockerfile.client b/test/Dockerfile.client new file mode 100644 index 0000000..63b0cc6 --- /dev/null +++ b/test/Dockerfile.client @@ -0,0 +1,26 @@ +# checkov:skip=CKV_DOCKER_2:Test container doesn't need healthcheck +# checkov:skip=CKV_DOCKER_3:OpenVPN client requires root for NET_ADMIN +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Install OpenVPN client and testing tools +# dnsutils provides dig for DNS testing with Unbound +RUN apt-get update && apt-get install -y --no-install-recommends \ + openvpn \ + iproute2 \ + iputils-ping \ + procps \ + dnsutils \ + && rm -rf /var/lib/apt/lists/* + +# Create TUN device directory (device will be mounted at runtime) +RUN mkdir -p /dev/net + +# Copy test scripts +COPY test/client-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +WORKDIR /etc/openvpn + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/test/Dockerfile.server b/test/Dockerfile.server new file mode 100644 index 0000000..6f9438c --- /dev/null +++ b/test/Dockerfile.server @@ -0,0 +1,69 @@ +# checkov:skip=CKV_DOCKER_2:Test container doesn't need healthcheck +# checkov:skip=CKV_DOCKER_3:OpenVPN server requires root for NET_ADMIN +# checkov:skip=CKV_DOCKER_7:Base image is parameterized, some use latest tag +ARG BASE_IMAGE=ubuntu:24.04 +FROM ${BASE_IMAGE} + +ARG BASE_IMAGE +ENV DEBIAN_FRONTEND=noninteractive + +# Install basic dependencies based on the OS +# dnsutils/bind-utils provides dig for DNS testing with Unbound +RUN if command -v apt-get >/dev/null; then \ + apt-get update && apt-get install -y --no-install-recommends \ + iproute2 iptables curl procps systemd systemd-sysv dnsutils \ + && rm -rf /var/lib/apt/lists/*; \ + elif command -v dnf >/dev/null; then \ + dnf install -y --allowerasing \ + iproute iptables curl procps-ng systemd tar gzip bind-utils \ + && dnf clean all; \ + elif command -v yum >/dev/null; then \ + yum install -y \ + iproute iptables curl procps-ng systemd tar gzip bind-utils \ + && yum clean all; \ + elif command -v pacman >/dev/null; then \ + pacman -Syu --noconfirm \ + iproute2 iptables curl procps-ng bind \ + && pacman -Scc --noconfirm; \ + elif command -v zypper >/dev/null; then \ + zypper install -y \ + iproute2 iptables curl procps systemd tar gzip bind-utils gawk \ + && zypper clean -a; \ + fi + +# Create TUN device (will be mounted at runtime) +RUN mkdir -p /dev/net + +# Copy the install script +COPY openvpn-install.sh /opt/openvpn-install.sh +RUN chmod +x /opt/openvpn-install.sh + +# Copy test scripts +COPY test/server-entrypoint.sh /entrypoint.sh +COPY test/validate-output.sh /opt/test/validate-output.sh +RUN chmod +x /entrypoint.sh /opt/test/validate-output.sh + +# Create systemd service for the test script +RUN printf '%s\n' \ + '[Unit]' \ + 'Description=OpenVPN Installation Test' \ + 'After=network.target' \ + '' \ + '[Service]' \ + 'Type=oneshot' \ + 'Environment=HOME=/root' \ + 'WorkingDirectory=/root' \ + 'ExecStart=/entrypoint.sh' \ + 'RemainAfterExit=yes' \ + 'StandardOutput=journal+console' \ + 'StandardError=journal+console' \ + '' \ + '[Install]' \ + 'WantedBy=multi-user.target' \ + > /etc/systemd/system/openvpn-test.service \ + && systemctl enable openvpn-test.service + +WORKDIR /opt + +STOPSIGNAL SIGRTMIN+3 +CMD ["/sbin/init"] diff --git a/test/client-entrypoint.sh b/test/client-entrypoint.sh new file mode 100755 index 0000000..648c285 --- /dev/null +++ b/test/client-entrypoint.sh @@ -0,0 +1,368 @@ +#!/bin/bash +set -e + +echo "=== OpenVPN Client Container ===" + +# Create TUN device if it doesn't exist +if [ ! -c /dev/net/tun ]; then + mkdir -p /dev/net + mknod /dev/net/tun c 10 200 + chmod 600 /dev/net/tun +fi + +echo "TUN device ready" + +# Wait for client config to be available +echo "Waiting for client config..." +MAX_WAIT=120 +WAITED=0 +while [ ! -f /shared/client.ovpn ] && [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) + echo "Waiting... ($WAITED/$MAX_WAIT seconds)" +done + +if [ ! -f /shared/client.ovpn ]; then + echo "ERROR: Client config not found after ${MAX_WAIT}s" + exit 1 +fi + +echo "Client config found!" +cat /shared/client.ovpn + +# Connect to VPN +echo "Connecting to OpenVPN server..." +openvpn --config /shared/client.ovpn --daemon --log /var/log/openvpn.log + +# Wait for connection +echo "Waiting for VPN connection..." +MAX_WAIT=60 +WAITED=0 +while ! ip addr show tun0 2>/dev/null | grep -q "inet " && [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) + echo "Waiting for tun0... ($WAITED/$MAX_WAIT seconds)" + + # Check for errors + if [ -f /var/log/openvpn.log ]; then + tail -5 /var/log/openvpn.log + fi +done + +if ! ip addr show tun0 2>/dev/null | grep -q "inet "; then + echo "ERROR: VPN connection failed" + echo "=== OpenVPN log ===" + cat /var/log/openvpn.log || true + exit 1 +fi + +echo "=== VPN Connected! ===" +ip addr show tun0 + +# Run connectivity tests +echo "" +echo "=== Running connectivity tests ===" + +# Test 1: Check tun0 interface +echo "Test 1: Checking tun0 interface..." +if ip addr show tun0 | grep -q "10.8.0"; then + echo "PASS: tun0 interface has correct IP range (10.8.0.x)" +else + echo "FAIL: tun0 interface doesn't have expected IP" + exit 1 +fi + +# Test 2: Ping VPN gateway +echo "Test 2: Pinging VPN gateway (10.8.0.1)..." +if ping -c 10 10.8.0.1; then + echo "PASS: Can ping VPN gateway" +else + echo "FAIL: Cannot ping VPN gateway" + exit 1 +fi + +# Test 3: DNS resolution through Unbound +echo "Test 3: Testing DNS resolution via Unbound (10.8.0.1)..." +DNS_SUCCESS=false +DNS_MAX_RETRIES=10 +for i in $(seq 1 $DNS_MAX_RETRIES); do + DIG_OUTPUT=$(dig @10.8.0.1 example.com +short +time=5 2>&1) + if [ -n "$DIG_OUTPUT" ] && ! echo "$DIG_OUTPUT" | grep -qi "timed out\|SERVFAIL\|connection refused"; then + DNS_SUCCESS=true + break + fi + echo "DNS attempt $i failed:" + echo "$DIG_OUTPUT" + sleep 2 +done +if [ "$DNS_SUCCESS" = true ]; then + echo "PASS: DNS resolution through Unbound works" + echo "Resolved example.com to: $(dig @10.8.0.1 example.com +short +time=5)" +else + echo "FAIL: DNS resolution through Unbound failed after $DNS_MAX_RETRIES attempts" + dig @10.8.0.1 example.com +time=5 || true + exit 1 +fi + +echo "" +echo "=== Initial connectivity tests PASSED ===" + +# Signal server that initial tests passed +touch /shared/initial-tests-passed + +# ===================================================== +# Certificate Revocation E2E Tests +# ===================================================== +echo "" +echo "=== Starting Certificate Revocation E2E Tests ===" + +REVOKE_CLIENT="revoketest" + +# Wait for revoke test client config +echo "Waiting for revoke test client config..." +MAX_WAIT=120 +WAITED=0 +while [ ! -f /shared/revoke-client-config-ready ] && [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) + echo "Waiting for revoke test config... ($WAITED/$MAX_WAIT seconds)" +done + +if [ ! -f /shared/revoke-client-config-ready ]; then + echo "FAIL: Revoke test client config not ready in time" + exit 1 +fi + +if [ ! -f "/shared/$REVOKE_CLIENT.ovpn" ]; then + echo "FAIL: Revoke test client config file not found" + exit 1 +fi + +echo "Revoke test client config found!" + +# Disconnect current VPN (testclient) before connecting with revoke test client +echo "Disconnecting current VPN connection..." +pkill openvpn || true +sleep 2 + +# Connect with revoke test client +echo "Connecting with '$REVOKE_CLIENT' certificate..." +openvpn --config "/shared/$REVOKE_CLIENT.ovpn" --daemon --log /var/log/openvpn-revoke.log + +# Wait for connection +echo "Waiting for VPN connection with revoke test client..." +MAX_WAIT=60 +WAITED=0 +while ! ip addr show tun0 2>/dev/null | grep -q "inet " && [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) + echo "Waiting for tun0... ($WAITED/$MAX_WAIT seconds)" + if [ -f /var/log/openvpn-revoke.log ]; then + tail -3 /var/log/openvpn-revoke.log + fi +done + +if ! ip addr show tun0 2>/dev/null | grep -q "inet "; then + echo "FAIL: VPN connection with revoke test client failed" + cat /var/log/openvpn-revoke.log || true + exit 1 +fi + +echo "PASS: Connected with '$REVOKE_CLIENT' certificate" +ip addr show tun0 + +# Verify connectivity +if ping -c 2 10.8.0.1 >/dev/null 2>&1; then + echo "PASS: Can ping VPN gateway with revoke test client" +else + echo "FAIL: Cannot ping VPN gateway with revoke test client" + exit 1 +fi + +# Signal server that we're connected with revoke test client +touch /shared/revoke-client-connected + +# Wait for server to signal us to disconnect +echo "Waiting for server to signal disconnect..." +MAX_WAIT=60 +WAITED=0 +while [ ! -f /shared/revoke-client-disconnect ] && [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) +done + +if [ ! -f /shared/revoke-client-disconnect ]; then + echo "FAIL: Server did not signal disconnect" + exit 1 +fi + +# Disconnect +echo "Disconnecting revoke test client..." +pkill openvpn || true + +# Wait for openvpn to fully exit and tun0 to be released +WAITED=0 +MAX_WAIT_DISCONNECT=10 +while (pgrep openvpn >/dev/null || ip addr show tun0 2>/dev/null | grep -q "inet ") && [ $WAITED -lt $MAX_WAIT_DISCONNECT ]; do + sleep 1 + WAITED=$((WAITED + 1)) +done + +# Verify disconnected +if ip addr show tun0 2>/dev/null | grep -q "inet "; then + echo "FAIL: tun0 still has IP after disconnect" + exit 1 +fi +echo "PASS: Disconnected successfully" + +# Signal server that we're disconnected +touch /shared/revoke-client-disconnected + +# Wait for server to revoke the certificate and signal us to reconnect +echo "Waiting for server to revoke certificate and signal reconnect..." +MAX_WAIT=60 +WAITED=0 +while [ ! -f /shared/revoke-try-reconnect ] && [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) +done + +if [ ! -f /shared/revoke-try-reconnect ]; then + echo "FAIL: Server did not signal to try reconnect" + exit 1 +fi + +# Try to reconnect with the now-revoked certificate (should fail) +echo "Attempting to reconnect with revoked certificate (should fail)..." +rm -f /var/log/openvpn-revoke-fail.log +openvpn --config "/shared/$REVOKE_CLIENT.ovpn" --daemon --log /var/log/openvpn-revoke-fail.log + +# Wait and check if connection fails +# The connection should fail due to certificate being revoked +echo "Waiting to verify connection is rejected..." +CONNECT_FAILED=false +MAX_WAIT=30 +WAITED=0 +while [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) + + # Check if tun0 came up (would mean revocation didn't work) + if ip addr show tun0 2>/dev/null | grep -q "inet "; then + echo "FAIL: Connection succeeded with revoked certificate!" + cat /var/log/openvpn-revoke-fail.log || true + exit 1 + fi + + # Check for certificate verification failure in log + if [ -f /var/log/openvpn-revoke-fail.log ]; then + if grep -qi "certificate verify failed\|TLS Error\|AUTH_FAILED\|certificate revoked" /var/log/openvpn-revoke-fail.log; then + echo "Connection correctly rejected (certificate revoked)" + CONNECT_FAILED=true + break + fi + fi + + echo "Checking connection status... ($WAITED/$MAX_WAIT seconds)" + if [ -f /var/log/openvpn-revoke-fail.log ]; then + tail -3 /var/log/openvpn-revoke-fail.log + fi +done + +# Kill any remaining openvpn process +pkill openvpn 2>/dev/null || true +sleep 1 + +# Even if we didn't see explicit error, verify tun0 is not up +if ip addr show tun0 2>/dev/null | grep -q "inet "; then + echo "FAIL: tun0 interface exists - revoked cert may have connected" + exit 1 +fi + +if [ "$CONNECT_FAILED" = true ]; then + echo "PASS: Connection with revoked certificate was correctly rejected" +else + echo "PASS: Connection with revoked certificate did not succeed (no tun0)" + echo "OpenVPN log:" + cat /var/log/openvpn-revoke-fail.log || true +fi + +# Signal server that reconnect with revoked cert failed +touch /shared/revoke-reconnect-failed + +# ===================================================== +# Test connecting with new certificate (same name) +# ===================================================== +echo "" +echo "=== Testing connection with recreated certificate ===" + +# Wait for server to create new cert and signal us +echo "Waiting for new client config with same name..." +MAX_WAIT=120 +WAITED=0 +while [ ! -f /shared/new-client-config-ready ] && [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) + echo "Waiting for new config... ($WAITED/$MAX_WAIT seconds)" +done + +if [ ! -f /shared/new-client-config-ready ]; then + echo "FAIL: New client config not ready in time" + exit 1 +fi + +if [ ! -f "/shared/$REVOKE_CLIENT-new.ovpn" ]; then + echo "FAIL: New client config file not found" + exit 1 +fi + +echo "New client config found!" + +# Connect with the new certificate +echo "Connecting with new '$REVOKE_CLIENT' certificate..." +rm -f /var/log/openvpn-new.log +openvpn --config "/shared/$REVOKE_CLIENT-new.ovpn" --daemon --log /var/log/openvpn-new.log + +# Wait for connection +echo "Waiting for VPN connection with new certificate..." +MAX_WAIT=60 +WAITED=0 +while ! ip addr show tun0 2>/dev/null | grep -q "inet " && [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) + echo "Waiting for tun0... ($WAITED/$MAX_WAIT seconds)" + if [ -f /var/log/openvpn-new.log ]; then + tail -3 /var/log/openvpn-new.log + fi +done + +if ! ip addr show tun0 2>/dev/null | grep -q "inet "; then + echo "FAIL: VPN connection with new certificate failed" + cat /var/log/openvpn-new.log || true + exit 1 +fi + +echo "PASS: Connected with new '$REVOKE_CLIENT' certificate" +ip addr show tun0 + +# Verify connectivity +if ping -c 2 10.8.0.1 >/dev/null 2>&1; then + echo "PASS: Can ping VPN gateway with new certificate" +else + echo "FAIL: Cannot ping VPN gateway with new certificate" + exit 1 +fi + +# Signal server that we connected with new cert +touch /shared/new-client-connected + +echo "" +echo "=== Certificate Revocation E2E Tests PASSED ===" + +echo "" +echo "==========================================" +echo " ALL TESTS PASSED!" +echo "==========================================" + +# Keep container running for debugging if needed +exec tail -f /var/log/openvpn-new.log 2>/dev/null || tail -f /var/log/openvpn.log 2>/dev/null || sleep infinity diff --git a/test/server-entrypoint.sh b/test/server-entrypoint.sh new file mode 100755 index 0000000..83e5328 --- /dev/null +++ b/test/server-entrypoint.sh @@ -0,0 +1,627 @@ +#!/bin/bash +set -e + +echo "=== OpenVPN Server Container ===" + +# Create TUN device if it doesn't exist +if [ ! -c /dev/net/tun ]; then + mkdir -p /dev/net + mknod /dev/net/tun c 10 200 + chmod 600 /dev/net/tun +fi + +echo "TUN device ready" + +# Set up environment for auto-install +export AUTO_INSTALL=y +export FORCE_COLOR=1 +export APPROVE_INSTALL=y +export APPROVE_IP=y +export IPV6_SUPPORT=n +export PORT_CHOICE=1 +export PROTOCOL_CHOICE=1 +export DNS=2 # Self-hosted Unbound DNS resolver +export COMPRESSION_ENABLED=n +export CLIENT=testclient +export PASS=1 +export ENDPOINT=openvpn-server + +# TLS key type configuration (default: tls-crypt-v2) +# TLS_SIG: 1=tls-crypt-v2, 2=tls-crypt, 3=tls-auth +# TLS_KEY_FILE: the expected key file name for verification +TLS_SIG="${TLS_SIG:-1}" +TLS_KEY_FILE="${TLS_KEY_FILE:-tls-crypt-v2.key}" +export TLS_SIG + +# If using non-default TLS settings, enable encryption customization +if [ "$TLS_SIG" != "1" ]; then + export CUSTOMIZE_ENC=y + # Set other encryption defaults when customizing + export CIPHER_CHOICE=1 # AES-128-GCM + export CERT_TYPE=1 # ECDSA + export CERT_CURVE_CHOICE=1 # prime256v1 + export CC_CIPHER_CHOICE=1 # ECDHE-ECDSA-AES-128-GCM-SHA256 + export DH_TYPE=1 # ECDH + export DH_CURVE_CHOICE=1 # prime256v1 + export HMAC_ALG_CHOICE=1 # SHA-256 + echo "Testing TLS key type: $TLS_SIG (key file: $TLS_KEY_FILE)" +else + export CUSTOMIZE_ENC=n +fi + +echo "Running OpenVPN install script..." +# Run in subshell because the script calls 'exit 0' after generating client config +# Capture output to validate logging format, while still displaying it +# Use || true to prevent set -e from exiting on failure, then check exit code +INSTALL_OUTPUT="/tmp/install-output.log" +(bash /opt/openvpn-install.sh) 2>&1 | tee "$INSTALL_OUTPUT" +INSTALL_EXIT_CODE=${PIPESTATUS[0]} + +echo "=== Installation complete (exit code: $INSTALL_EXIT_CODE) ===" + +# Validate that all output uses proper logging format (ANSI color codes) +echo "Validating output format..." +if /opt/test/validate-output.sh "$INSTALL_OUTPUT"; then + echo "PASS: All script output uses proper log formatting" +else + echo "FAIL: Script output contains unformatted lines" + echo "This indicates echo statements that should use log_* functions" + exit 1 +fi + +if [ "$INSTALL_EXIT_CODE" -ne 0 ]; then + echo "ERROR: Install script failed with exit code $INSTALL_EXIT_CODE" + exit 1 +fi + +# Verify all expected files were created +echo "Verifying installation..." +MISSING_FILES=0 +for f in \ + /etc/openvpn/server/server.conf \ + /etc/openvpn/server/ca.crt \ + /etc/openvpn/server/ca.key \ + "/etc/openvpn/server/$TLS_KEY_FILE" \ + /etc/openvpn/server/crl.pem \ + /etc/openvpn/server/easy-rsa/pki/ca.crt \ + /etc/iptables/add-openvpn-rules.sh \ + /root/testclient.ovpn; do + if [ ! -f "$f" ]; then + echo "ERROR: Missing file: $f" + MISSING_FILES=$((MISSING_FILES + 1)) + fi +done + +if [ $MISSING_FILES -gt 0 ]; then + echo "ERROR: $MISSING_FILES required files are missing" + exit 1 +fi + +echo "All required files present" + +# Copy client config to shared volume for the client container +cp /root/testclient.ovpn /shared/client.ovpn +sed -i 's/^remote .*/remote openvpn-server 1194/' /shared/client.ovpn +echo "Client config copied to /shared/client.ovpn" + +# ===================================================== +# Verify systemd service file configuration +# ===================================================== +echo "" +echo "=== Verifying systemd service configuration ===" + +# Check that the correct service file was created +SERVICE_FILE="/etc/systemd/system/openvpn-server@.service" +if [ -f "$SERVICE_FILE" ]; then + echo "PASS: openvpn-server@.service exists at $SERVICE_FILE" +else + echo "FAIL: openvpn-server@.service not found at $SERVICE_FILE" + echo "Contents of /etc/systemd/system/:" + find /etc/systemd/system/ -maxdepth 1 -name '*openvpn*' -ls 2>/dev/null || echo "No openvpn service files found" + exit 1 +fi + +# Verify the service file points to /etc/openvpn/server/ (not patched back to /etc/openvpn/) +if grep -q "/etc/openvpn/server" "$SERVICE_FILE"; then + echo "PASS: Service file uses correct path /etc/openvpn/server/" +else + echo "FAIL: Service file does not reference /etc/openvpn/server/" + echo "Service file contents:" + cat "$SERVICE_FILE" + exit 1 +fi + +# Verify the service file syntax is valid (if systemd-analyze is available) +if command -v systemd-analyze >/dev/null 2>&1; then + echo "Validating service file syntax..." + if systemd-analyze verify "$SERVICE_FILE" 2>&1 | tee /tmp/service-verify.log; then + echo "PASS: Service file syntax is valid" + else + # systemd-analyze verify may return non-zero for warnings, check for actual errors + if grep -qi "error" /tmp/service-verify.log; then + echo "FAIL: Service file has syntax errors" + cat /tmp/service-verify.log + exit 1 + else + echo "PASS: Service file syntax is valid (warnings only)" + fi + fi +else + echo "SKIP: systemd-analyze not available, skipping syntax validation" +fi + +# Verify the old service file pattern (openvpn@.service) was NOT created +OLD_SERVICE_FILE="/etc/systemd/system/openvpn@.service" +if [ -f "$OLD_SERVICE_FILE" ]; then + echo "FAIL: Legacy openvpn@.service was created (should use openvpn-server@.service)" + exit 1 +else + echo "PASS: Legacy openvpn@.service not present (correct)" +fi + +echo "=== systemd service configuration verified ===" +echo "" +echo "Server config:" +cat /etc/openvpn/server/server.conf + +# ===================================================== +# Test certificate renewal functionality +# ===================================================== +echo "" +echo "=== Testing Certificate Renewal ===" + +# Get the original certificate serial number for comparison +ORIG_CERT_SERIAL=$(openssl x509 -in /etc/openvpn/server/easy-rsa/pki/issued/testclient.crt -noout -serial | cut -d= -f2) +echo "Original client certificate serial: $ORIG_CERT_SERIAL" + +# Test client certificate renewal using the script +echo "Testing client certificate renewal..." +RENEW_OUTPUT="/tmp/renew-client-output.log" +(MENU_OPTION=3 RENEW_OPTION=1 CLIENTNUMBER=1 CLIENT_CERT_DURATION_DAYS=3650 bash /opt/openvpn-install.sh) 2>&1 | tee "$RENEW_OUTPUT" || true + +# Verify renewal succeeded +if grep -q "Certificate for client testclient renewed" "$RENEW_OUTPUT"; then + echo "PASS: Client renewal completed successfully" +else + echo "FAIL: Client renewal did not complete" + cat "$RENEW_OUTPUT" + exit 1 +fi + +# Verify new certificate has different serial +NEW_CERT_SERIAL=$(openssl x509 -in /etc/openvpn/server/easy-rsa/pki/issued/testclient.crt -noout -serial | cut -d= -f2) +echo "New client certificate serial: $NEW_CERT_SERIAL" +if [ "$ORIG_CERT_SERIAL" != "$NEW_CERT_SERIAL" ]; then + echo "PASS: Certificate serial changed (renewal created new cert)" +else + echo "FAIL: Certificate serial unchanged" + exit 1 +fi + +# Verify renewed certificate has correct validity period +# The default is 3650 days, so the cert should be valid for ~10 years from now +CLIENT_CERT_NOT_AFTER=$(openssl x509 -in /etc/openvpn/server/easy-rsa/pki/issued/testclient.crt -noout -enddate | cut -d= -f2) +CLIENT_CERT_NOT_BEFORE=$(openssl x509 -in /etc/openvpn/server/easy-rsa/pki/issued/testclient.crt -noout -startdate | cut -d= -f2) +echo "Client certificate valid from: $CLIENT_CERT_NOT_BEFORE" +echo "Client certificate valid until: $CLIENT_CERT_NOT_AFTER" + +# Calculate days until expiry (should be close to 3650) +CERT_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) +NOW_EPOCH=$(date +%s) +DAYS_VALID_ACTUAL=$(((CERT_END_EPOCH - NOW_EPOCH) / 86400)) +echo "Client certificate validity: $DAYS_VALID_ACTUAL days" + +# Should be between 3640 and 3650 days (allowing some tolerance for timing) +if [ "$DAYS_VALID_ACTUAL" -ge 3640 ] && [ "$DAYS_VALID_ACTUAL" -le 3650 ]; then + echo "PASS: Client certificate validity is correct (~3650 days)" +else + echo "FAIL: Client certificate validity is unexpected: $DAYS_VALID_ACTUAL days (expected ~3650)" + exit 1 +fi + +# Verify new .ovpn file was generated +if [ -f /root/testclient.ovpn ]; then + echo "PASS: New .ovpn file generated" +else + echo "FAIL: .ovpn file not found after renewal" + exit 1 +fi + +# Verify CRL was updated +if [ -f /etc/openvpn/server/crl.pem ]; then + echo "PASS: CRL file exists" +else + echo "FAIL: CRL file missing after renewal" + exit 1 +fi + +# Update shared client config with renewed certificate +cp /root/testclient.ovpn /shared/client.ovpn +sed -i 's/^remote .*/remote openvpn-server 1194/' /shared/client.ovpn +echo "Updated client config with renewed certificate" + +echo "=== Client Certificate Renewal Tests PASSED ===" + +# ===================================================== +# Test server certificate renewal +# ===================================================== +echo "" +echo "=== Testing Server Certificate Renewal ===" + +# Get server certificate name and original serial (extract basename since path may be relative) +SERVER_NAME=$(basename "$(grep '^cert ' /etc/openvpn/server/server.conf | cut -d ' ' -f 2)" .crt) +ORIG_SERVER_SERIAL=$(openssl x509 -in "/etc/openvpn/server/easy-rsa/pki/issued/$SERVER_NAME.crt" -noout -serial | cut -d= -f2) +echo "Server certificate: $SERVER_NAME" +echo "Original server certificate serial: $ORIG_SERVER_SERIAL" + +# Test server certificate renewal +echo "Testing server certificate renewal..." +RENEW_SERVER_OUTPUT="/tmp/renew-server-output.log" +(MENU_OPTION=3 RENEW_OPTION=2 CONTINUE=y SERVER_CERT_DURATION_DAYS=3650 bash /opt/openvpn-install.sh) 2>&1 | tee "$RENEW_SERVER_OUTPUT" || true + +# Verify renewal succeeded +if grep -q "Server certificate renewed successfully" "$RENEW_SERVER_OUTPUT"; then + echo "PASS: Server renewal completed successfully" +else + echo "FAIL: Server renewal did not complete" + cat "$RENEW_SERVER_OUTPUT" + exit 1 +fi + +# Verify new certificate has different serial +NEW_SERVER_SERIAL=$(openssl x509 -in "/etc/openvpn/server/easy-rsa/pki/issued/$SERVER_NAME.crt" -noout -serial | cut -d= -f2) +echo "New server certificate serial: $NEW_SERVER_SERIAL" +if [ "$ORIG_SERVER_SERIAL" != "$NEW_SERVER_SERIAL" ]; then + echo "PASS: Server certificate serial changed (renewal created new cert)" +else + echo "FAIL: Server certificate serial unchanged" + exit 1 +fi + +# Verify renewed server certificate has correct validity period +SERVER_CERT_NOT_AFTER=$(openssl x509 -in "/etc/openvpn/server/easy-rsa/pki/issued/$SERVER_NAME.crt" -noout -enddate | cut -d= -f2) +SERVER_CERT_NOT_BEFORE=$(openssl x509 -in "/etc/openvpn/server/easy-rsa/pki/issued/$SERVER_NAME.crt" -noout -startdate | cut -d= -f2) +echo "Server certificate valid from: $SERVER_CERT_NOT_BEFORE" +echo "Server certificate valid until: $SERVER_CERT_NOT_AFTER" + +# Calculate days until expiry (should be close to 3650) +SERVER_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) +SERVER_DAYS_VALID=$(((SERVER_END_EPOCH - NOW_EPOCH) / 86400)) +echo "Server certificate validity: $SERVER_DAYS_VALID days" + +if [ "$SERVER_DAYS_VALID" -ge 3640 ] && [ "$SERVER_DAYS_VALID" -le 3650 ]; then + echo "PASS: Server certificate validity is correct (~3650 days)" +else + echo "FAIL: Server certificate validity is unexpected: $SERVER_DAYS_VALID days (expected ~3650)" + exit 1 +fi + +# Verify the new certificate was copied to /etc/openvpn/server/ +if [ -f "/etc/openvpn/server/$SERVER_NAME.crt" ]; then + DEPLOYED_SERIAL=$(openssl x509 -in "/etc/openvpn/server/$SERVER_NAME.crt" -noout -serial | cut -d= -f2) + if [ "$NEW_SERVER_SERIAL" = "$DEPLOYED_SERIAL" ]; then + echo "PASS: New server certificate deployed to /etc/openvpn/server/" + else + echo "FAIL: Deployed certificate doesn't match renewed certificate" + exit 1 + fi +else + echo "FAIL: Server certificate not found in /etc/openvpn/server/" + exit 1 +fi + +echo "=== Server Certificate Renewal Tests PASSED ===" +echo "" +echo "=== All Certificate Renewal Tests PASSED ===" +echo "" + +# ===================================================== +# Verify Unbound DNS resolver (started by systemd via install script) +# ===================================================== +echo "=== Verifying Unbound DNS Resolver ===" + +if [ -f /etc/unbound/unbound.conf ]; then + # Verify Unbound is running (started by systemctl in install script) + echo "Checking Unbound service status..." + for _ in $(seq 1 30); do + if pgrep -x unbound >/dev/null; then + echo "PASS: Unbound is running" + break + fi + sleep 1 + done + if ! pgrep -x unbound >/dev/null; then + echo "FAIL: Unbound is not running" + systemctl status unbound 2>&1 || true + journalctl -u unbound --no-pager -n 50 2>&1 || true + exit 1 + fi +else + echo "FAIL: /etc/unbound/unbound.conf not found" + exit 1 +fi + +echo "" +echo "=== Verifying Unbound Installation ===" + +# Verify Unbound config exists in conf.d directory +UNBOUND_OPENVPN_CONF="/etc/unbound/unbound.conf.d/openvpn.conf" +if [ -f "$UNBOUND_OPENVPN_CONF" ]; then + echo "PASS: Found Unbound config at $UNBOUND_OPENVPN_CONF" +else + echo "FAIL: OpenVPN Unbound config not found at $UNBOUND_OPENVPN_CONF" + echo "Contents of /etc/unbound/:" + ls -la /etc/unbound/ + ls -la /etc/unbound/unbound.conf.d/ 2>/dev/null || true + exit 1 +fi + +# Verify Unbound listens on VPN gateway +if grep -q "interface: 10.8.0.1" "$UNBOUND_OPENVPN_CONF"; then + echo "PASS: Unbound configured to listen on 10.8.0.1" +else + echo "FAIL: Unbound not configured for 10.8.0.1" + cat "$UNBOUND_OPENVPN_CONF" + exit 1 +fi + +# Verify OpenVPN pushes correct DNS +if grep -q 'push "dhcp-option DNS 10.8.0.1"' /etc/openvpn/server/server.conf; then + echo "PASS: OpenVPN configured to push Unbound DNS" +else + echo "FAIL: OpenVPN not configured to push Unbound DNS" + grep "dhcp-option DNS" /etc/openvpn/server/server.conf || echo "No DNS push found" + exit 1 +fi + +echo "=== Unbound Installation Verified ===" +echo "" + +# Verify OpenVPN server (started by systemd via install script) +echo "Verifying OpenVPN server..." + +# Verify iptables NAT rules exist (applied by iptables-openvpn service) +echo "Verifying iptables NAT rules..." +for _ in $(seq 1 10); do + if iptables -t nat -L POSTROUTING -n | grep -q "10.8.0.0"; then + echo "PASS: NAT POSTROUTING rule for 10.8.0.0/24 exists" + break + fi + sleep 1 +done +if ! iptables -t nat -L POSTROUTING -n | grep -q "10.8.0.0"; then + echo "FAIL: NAT POSTROUTING rule for 10.8.0.0/24 not found" + echo "Current NAT rules:" + iptables -t nat -L POSTROUTING -n -v + systemctl status iptables-openvpn 2>&1 || true + exit 1 +fi + +# Verify IP forwarding is enabled +if [ "$(cat /proc/sys/net/ipv4/ip_forward)" != "1" ]; then + echo "ERROR: IP forwarding is not enabled" + exit 1 +fi + +# Wait for OpenVPN to start (started by systemctl in install script) +echo "Waiting for OpenVPN server to start..." +for _ in $(seq 1 30); do + if pgrep -f "openvpn.*server.conf" >/dev/null; then + echo "PASS: OpenVPN server is running" + break + fi + sleep 1 +done + +if ! pgrep -f "openvpn.*server.conf" >/dev/null; then + echo "FAIL: OpenVPN server is not running" + systemctl status openvpn-server@server 2>&1 || true + journalctl -u openvpn-server@server --no-pager -n 50 2>&1 || true + exit 1 +fi + +# ===================================================== +# Wait for initial client tests to complete +# ===================================================== +echo "" +echo "=== Waiting for initial client connectivity tests ===" +MAX_WAIT=120 +WAITED=0 +while [ ! -f /shared/initial-tests-passed ] && [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) + echo "Waiting for initial tests... ($WAITED/$MAX_WAIT seconds)" +done + +if [ ! -f /shared/initial-tests-passed ]; then + echo "ERROR: Initial client tests did not complete in time" + exit 1 +fi +echo "Initial client tests passed, proceeding with revocation tests" + +# ===================================================== +# Test certificate revocation functionality +# ===================================================== +echo "" +echo "=== Testing Certificate Revocation ===" + +# Create a new client for revocation testing +REVOKE_CLIENT="revoketest" +echo "Creating client '$REVOKE_CLIENT' for revocation testing..." +REVOKE_CREATE_OUTPUT="/tmp/revoke-create-output.log" +(MENU_OPTION=1 CLIENT=$REVOKE_CLIENT PASS=1 CLIENT_CERT_DURATION_DAYS=3650 bash /opt/openvpn-install.sh) 2>&1 | tee "$REVOKE_CREATE_OUTPUT" || true + +if [ -f "/root/$REVOKE_CLIENT.ovpn" ]; then + echo "PASS: Client '$REVOKE_CLIENT' created successfully" +else + echo "FAIL: Failed to create client '$REVOKE_CLIENT'" + cat "$REVOKE_CREATE_OUTPUT" + exit 1 +fi + +# Copy config for revocation test client +cp "/root/$REVOKE_CLIENT.ovpn" "/shared/$REVOKE_CLIENT.ovpn" +sed -i 's/^remote .*/remote openvpn-server 1194/' "/shared/$REVOKE_CLIENT.ovpn" +echo "Copied $REVOKE_CLIENT config to /shared/" + +# Signal client that revoke test config is ready +touch /shared/revoke-client-config-ready + +# Wait for client to confirm connection with revoke test client +echo "Waiting for client to connect with '$REVOKE_CLIENT' certificate..." +MAX_WAIT=60 +WAITED=0 +while [ ! -f /shared/revoke-client-connected ] && [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) + echo "Waiting for revoke test connection... ($WAITED/$MAX_WAIT seconds)" +done + +if [ ! -f /shared/revoke-client-connected ]; then + echo "ERROR: Client did not connect with revoke test certificate" + exit 1 +fi +echo "PASS: Client connected with '$REVOKE_CLIENT' certificate" + +# Signal client to disconnect before revocation +touch /shared/revoke-client-disconnect + +# Wait for client to disconnect +echo "Waiting for client to disconnect..." +MAX_WAIT=30 +WAITED=0 +while [ ! -f /shared/revoke-client-disconnected ] && [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) +done + +if [ ! -f /shared/revoke-client-disconnected ]; then + echo "ERROR: Client did not signal disconnect" + exit 1 +fi +echo "Client disconnected" + +# Now revoke the certificate +echo "Revoking certificate for '$REVOKE_CLIENT'..." +REVOKE_OUTPUT="/tmp/revoke-output.log" +# MENU_OPTION=2 is revoke, CLIENTNUMBER is dynamically determined from index.txt +# We need to find the client number for revoketest +REVOKE_CLIENT_NUM=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep "^V" | grep -n "CN=$REVOKE_CLIENT\$" | cut -d: -f1) +if [ -z "$REVOKE_CLIENT_NUM" ]; then + echo "ERROR: Could not find client number for '$REVOKE_CLIENT'" + cat /etc/openvpn/server/easy-rsa/pki/index.txt + exit 1 +fi +echo "Revoke client number: $REVOKE_CLIENT_NUM" +(MENU_OPTION=2 CLIENTNUMBER=$REVOKE_CLIENT_NUM bash /opt/openvpn-install.sh) 2>&1 | tee "$REVOKE_OUTPUT" || true + +if grep -q "Certificate for client $REVOKE_CLIENT revoked" "$REVOKE_OUTPUT"; then + echo "PASS: Certificate for '$REVOKE_CLIENT' revoked successfully" +else + echo "FAIL: Failed to revoke certificate" + cat "$REVOKE_OUTPUT" + exit 1 +fi + +# Verify certificate is marked as revoked in index.txt +if tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep -q "^R.*CN=$REVOKE_CLIENT\$"; then + echo "PASS: Certificate marked as revoked in index.txt" +else + echo "FAIL: Certificate not marked as revoked" + cat /etc/openvpn/server/easy-rsa/pki/index.txt + exit 1 +fi + +# Signal client to try reconnecting (should fail) +touch /shared/revoke-try-reconnect + +# Wait for client to confirm that connection with revoked cert failed +echo "Waiting for client to confirm revoked cert connection failure..." +MAX_WAIT=60 +WAITED=0 +while [ ! -f /shared/revoke-reconnect-failed ] && [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) + echo "Waiting for reconnect failure confirmation... ($WAITED/$MAX_WAIT seconds)" +done + +if [ ! -f /shared/revoke-reconnect-failed ]; then + echo "ERROR: Client did not confirm that revoked cert connection failed" + exit 1 +fi +echo "PASS: Connection with revoked certificate correctly rejected" + +echo "=== Certificate Revocation Tests PASSED ===" + +# ===================================================== +# Test reusing revoked client name +# ===================================================== +echo "" +echo "=== Testing Reuse of Revoked Client Name ===" + +# Create a new certificate with the same name as the revoked one +echo "Creating new client with same name '$REVOKE_CLIENT'..." +RECREATE_OUTPUT="/tmp/recreate-output.log" +(MENU_OPTION=1 CLIENT=$REVOKE_CLIENT PASS=1 CLIENT_CERT_DURATION_DAYS=3650 bash /opt/openvpn-install.sh) 2>&1 | tee "$RECREATE_OUTPUT" || true + +if [ -f "/root/$REVOKE_CLIENT.ovpn" ]; then + echo "PASS: New client '$REVOKE_CLIENT' created successfully (reusing revoked name)" +else + echo "FAIL: Failed to create client with revoked name" + cat "$RECREATE_OUTPUT" + exit 1 +fi + +# Verify the new certificate is valid (V) in index.txt +if tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep -q "^V.*CN=$REVOKE_CLIENT\$"; then + echo "PASS: New certificate is valid in index.txt" +else + echo "FAIL: New certificate not marked as valid" + cat /etc/openvpn/server/easy-rsa/pki/index.txt + exit 1 +fi + +# Verify there's also a revoked entry (both should exist) +REVOKED_COUNT=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep -c "^R.*CN=$REVOKE_CLIENT\$") +VALID_COUNT=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep -c "^V.*CN=$REVOKE_CLIENT\$") +echo "Certificates for '$REVOKE_CLIENT': $REVOKED_COUNT revoked, $VALID_COUNT valid" +if [ "$REVOKED_COUNT" -ge 1 ] && [ "$VALID_COUNT" -eq 1 ]; then + echo "PASS: Both revoked and new valid certificate entries exist" +else + echo "FAIL: Unexpected certificate state" + cat /etc/openvpn/server/easy-rsa/pki/index.txt + exit 1 +fi + +# Copy the new config +cp "/root/$REVOKE_CLIENT.ovpn" "/shared/$REVOKE_CLIENT-new.ovpn" +sed -i 's/^remote .*/remote openvpn-server 1194/' "/shared/$REVOKE_CLIENT-new.ovpn" +echo "Copied new $REVOKE_CLIENT config to /shared/" + +# Signal client that new config is ready +touch /shared/new-client-config-ready + +# Wait for client to confirm successful connection with new cert +echo "Waiting for client to connect with new '$REVOKE_CLIENT' certificate..." +MAX_WAIT=60 +WAITED=0 +while [ ! -f /shared/new-client-connected ] && [ $WAITED -lt $MAX_WAIT ]; do + sleep 2 + WAITED=$((WAITED + 2)) + echo "Waiting for new cert connection... ($WAITED/$MAX_WAIT seconds)" +done + +if [ ! -f /shared/new-client-connected ]; then + echo "ERROR: Client did not connect with new certificate" + exit 1 +fi +echo "PASS: Client connected with new '$REVOKE_CLIENT' certificate" + +echo "=== Reuse of Revoked Client Name Tests PASSED ===" +echo "" +echo "=== All Revocation Tests PASSED ===" + +# Server tests complete - systemd keeps the container running via /sbin/init +# OpenVPN service (openvpn-server@server) continues independently +echo "Server tests complete. Container will remain running via systemd." +echo "OpenVPN is managed by: systemctl status openvpn-server@server" diff --git a/test/validate-output.sh b/test/validate-output.sh new file mode 100755 index 0000000..88c8bbd --- /dev/null +++ b/test/validate-output.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# Validates that script output only contains properly formatted log messages +# All output from openvpn-install.sh should use logging functions +# +# Usage: ./validate-output.sh +# Or pipe: some_command | ./validate-output.sh + +set -euo pipefail + +INPUT_FILE="${1:-/dev/stdin}" + +# Valid output patterns: +# - Lines starting with ANSI escape codes (colored output) +# - Lines starting with our log prefixes (non-TTY mode) +# - Lines starting with > (command echo from run_cmd) +# - Empty lines + +# ANSI escape code pattern +ANSI_PATTERN=$'^\033\\[' + +# Log prefix patterns (for non-TTY mode where colors are disabled) +# These match: [INFO], [WARN], [ERROR], [OK], [DEBUG], or > (command line) +LOG_PREFIXES='^(\[INFO\]|\[WARN\]|\[ERROR\]|\[OK\]|\[DEBUG\]|> )' + +# Count issues +INVALID_LINES=0 +TOTAL_LINES=0 +LINE_NUM=0 + +echo "Validating script output for unformatted lines..." +echo "" + +while IFS= read -r line || [[ -n "$line" ]]; do + LINE_NUM=$((LINE_NUM + 1)) + + # Skip empty lines + if [[ -z "$line" ]]; then + continue + fi + + TOTAL_LINES=$((TOTAL_LINES + 1)) + + # Check if line starts with ANSI escape code (colored output from log functions) + if [[ "$line" =~ $ANSI_PATTERN ]]; then + continue + fi + + # Check if line starts with our log prefixes (non-TTY mode) + if [[ "$line" =~ $LOG_PREFIXES ]]; then + continue + fi + + # If we get here, the line doesn't match expected patterns - it's raw output + INVALID_LINES=$((INVALID_LINES + 1)) + # Truncate long lines for display + if [[ ${#line} -gt 100 ]]; then + DISPLAY_LINE="${line:0:100}..." + else + DISPLAY_LINE="$line" + fi + echo " [LEAK] Line $LINE_NUM: $DISPLAY_LINE" + +done <"$INPUT_FILE" + +echo "" +echo "----------------------------------------" +echo "Total lines checked: $TOTAL_LINES" +echo "Invalid lines found: $INVALID_LINES" + +if [[ $INVALID_LINES -gt 0 ]]; then + echo "" + echo "ERROR: Found $INVALID_LINES line(s) without proper log formatting." + echo "" + echo "All user-visible output should use log_* functions:" + echo " - log_info 'message' -> [INFO] message" + echo " - log_warn 'message' -> [WARN] message" + echo " - log_error 'message' -> [ERROR] message" + echo " - log_success 'message' -> [OK] message" + echo " - run_cmd 'desc' cmd -> > cmd" + echo "" + echo "Raw echo statements or command output should not leak to stdout." + exit 1 +fi + +echo "" +echo "All output is properly formatted!" +exit 0