diff --git a/.github/workflows/do-test.yml b/.github/workflows/do-test.yml
index f176a04..e51b4a5 100644
--- a/.github/workflows/do-test.yml
+++ b/.github/workflows/do-test.yml
@@ -97,7 +97,7 @@ jobs:
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'
+ script: 'set -x && bash -x ~/openvpn-install/openvpn-install.sh install && ps aux | grep openvpn | grep -v grep > /dev/null 2>&1 && echo "Success: OpenVPN is running" && exit 0 || echo "Failure: OpenVPN is not running" && exit 1'
- name: Delete server
run: doctl compute droplet delete -f "openvpn-action-${GITHUB_RUN_ID}-${GITHUB_RUN_NUMBER}-${{ matrix.os-image }}"
diff --git a/.github/workflows/docker-test.yml b/.github/workflows/docker-test.yml
index df4b3be..82bce59 100644
--- a/.github/workflows/docker-test.yml
+++ b/.github/workflows/docker-test.yml
@@ -73,7 +73,7 @@ jobs:
# Default TLS settings (tls-crypt-v2)
tls:
- name: tls-crypt-v2
- sig: "1"
+ sig: crypt-v2
key_file: tls-crypt-v2.key
# Additional TLS types tested on Ubuntu 24.04 only
include:
@@ -82,14 +82,14 @@ jobs:
image: ubuntu:24.04
tls:
name: tls-crypt
- sig: "2"
+ sig: crypt
key_file: tls-crypt.key
- os:
name: ubuntu-24.04-tls-auth
image: ubuntu:24.04
tls:
name: tls-auth
- sig: "3"
+ sig: auth
key_file: tls-auth.key
# Test firewalld support on Fedora
- os:
@@ -98,7 +98,7 @@ jobs:
enable_firewalld: true
tls:
name: tls-crypt-v2
- sig: "1"
+ sig: crypt-v2
key_file: tls-crypt-v2.key
# Test nftables support on Debian
- os:
@@ -107,7 +107,7 @@ jobs:
enable_nftables: true
tls:
name: tls-crypt-v2
- sig: "1"
+ sig: crypt-v2
key_file: tls-crypt-v2.key
name: ${{ matrix.os.name }}
diff --git a/FAQ.md b/FAQ.md
index ef94482..88c6f58 100644
--- a/FAQ.md
+++ b/FAQ.md
@@ -10,7 +10,18 @@ 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).
+**A:** Use the CLI commands to renew certificates:
+
+```bash
+# Renew a client certificate
+./openvpn-install.sh client renew alice
+
+# Renew with custom validity period (365 days)
+./openvpn-install.sh client renew alice --cert-days 365
+
+# Renew the server certificate
+./openvpn-install.sh server renew
+```
For client renewals, a new `.ovpn` file will be generated that you need to distribute to the client. For server renewals, the OpenVPN service will need to be restarted (the script will prompt you).
@@ -61,20 +72,6 @@ down /usr/share/openvpn/contrib/pull-resolv-conf/client.down
---
-**Q:** Can I use an OpenVPN 2.3 client?
-
-**A:** Yes. I really recommend using an up-to-date client, but if you really need it, choose the following options:
-
-- No compression or LZ0
-- RSA certificate
-- DH Key
-- AES CBC
-- tls-auth
-
-If your client is <2.3.3, remove `tls-version-min 1.2` from your `/etc/openvpn/server/server.conf` and `.ovpn` files.
-
----
-
**Q:** IPv6 is not working on my Hetzner VM
**A:** This an issue on their side. See
@@ -109,10 +106,6 @@ Sysctl options are at `/etc/sysctl.d/99-openvpn.conf`
type `yes` when asked to customize encryption settings and choose `tls-auth`
-- `Options error: Unrecognized option or missing parameter(s) in config.ovpn:36: tls-version-min (2.3.2)` :
-
- see question "Can I use an OpenVPN 2.3 client?"
-
---
**Q:** How can I access computers the OpenVPN server's remote LAN?
@@ -125,22 +118,31 @@ Sysctl options are at `/etc/sysctl.d/99-openvpn.conf`
**A:** Here is a sample Bash script to achieve this:
-```sh
+```bash
+#!/bin/bash
userlist=(user1 user2 user3)
-for i in ${userlist[@]};do
- MENU_OPTION=1 CLIENT=$i PASS=1 ./openvpn-install.sh
+for user in "${userlist[@]}"; do
+ ./openvpn-install.sh client add "$user"
done
```
From a list in a text file:
-```sh
-while read USER
- do MENU_OPTION="1" CLIENT="$USER" PASS="1" ./openvpn-install.sh
+```bash
+#!/bin/bash
+while read -r user; do
+ ./openvpn-install.sh client add "$user"
done < users.txt
```
+To add password-protected clients:
+
+```bash
+#!/bin/bash
+./openvpn-install.sh client add alice --password "secretpass123"
+```
+
---
**Q:** How do I change the default `.ovpn` file created for future clients?
diff --git a/README.md b/README.md
index 45a7fd0..8dd1f77 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,10 @@
# openvpn-install
-
-
[](https://saythanks.io/to/angristan)
OpenVPN installer for Debian, Ubuntu, Fedora, openSUSE, CentOS, Amazon Linux, Arch Linux, Oracle Linux, Rocky Linux and AlmaLinux.
-This script will let you setup your own secure VPN server in just a few seconds.
+This script will let you setup and manage your own secure VPN server in just a few seconds.
## What is this?
@@ -37,129 +35,18 @@ That said, OpenVPN still makes sense when you need:
- **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, on your server, get the script and make it executable:
-
-```bash
-curl -O https://raw.githubusercontent.com/angristan/openvpn-install/master/openvpn-install.sh
-chmod +x openvpn-install.sh
-```
-
-Then run it:
-
-```sh
-./openvpn-install.sh
-```
-
-You need to run the script as root and have the TUN module enabled.
-
-The first time you run it, you'll have to follow the assistant and answer a few questions to setup your VPN server.
-
-When OpenVPN is installed, you can run the script again, and you will get the choice to:
-
-- Add a client
-- List client certificates
-- Revoke a client
-- Renew certificates (client or server)
-- Uninstall OpenVPN
-- List connected clients (shows real-time connection status)
-
-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. 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
-
-It's also possible to run the script headless, e.g. without waiting for user input, in an automated manner.
-
-Example usage:
-
-```bash
-AUTO_INSTALL=y ./openvpn-install.sh
-
-# or
-
-export AUTO_INSTALL=y
-./openvpn-install.sh
-```
-
-A default set of variables will then be set, by passing the need for user input.
-
-If you want to customise your installation, you can export them or specify them on the same line, as shown above.
-
-- `APPROVE_INSTALL=y`
-- `APPROVE_IP=y`
-- `IPV6_SUPPORT=n`
-- `VPN_SUBNET=10.8.0.0` (VPN subnet, must be a valid RFC1918 /24 network like `10.8.0.0`, `10.9.0.0`, `172.16.0.0`, or `192.168.1.0`)
-- `PORT_CHOICE=1`
-- `PROTOCOL_CHOICE=1`
-- `DNS=1`
-- `COMPRESSION_ENABLED=n`
-- `CUSTOMIZE_ENC=n`
-- `CLIENT=clientname`
-- `PASS=1` (set to `2` for password-protected clients, requires `PASSPHRASE`)
-- `MULTI_CLIENT=n`
-- `CLIENT_CERT_DURATION_DAYS=3650`
-- `SERVER_CERT_DURATION_DAYS=3650`
-- `NEW_CLIENT=y` (set to `n` to skip client creation after installation)
-- `CLIENT_FILEPATH=/custom/path/client.ovpn` (optional, overrides default output path)
-
-The `.ovpn` file is saved to `CLIENT_FILEPATH` if defined, otherwise: the client's home directory if it exists (`/home/$CLIENT`), otherwise `SUDO_USER`'s home, otherwise `/root`. When the client name matches a system user, the script automatically sets proper ownership and permissions on the file.
-
-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.
-
-Other variables can be set depending on your choice (encryption, compression). You can search for them in the `installQuestions()` function of the script.
-
-The headless install is more-or-less idempotent, in that it has been made safe to run multiple times with the same parameters, e.g. by a state provisioner like Ansible/Terraform/Salt/Chef/Puppet. It will only install and regenerate the Easy-RSA PKI if it doesn't already exist, and it will only install OpenVPN and other upstream dependencies if OpenVPN isn't already installed. It will recreate all local config and re-generate the client file on each headless run.
-
-### Headless User Addition
-
-It's also possible to automate the addition of a new user. Here, the key is to provide the (string) value of the `MENU_OPTION` variable along with the remaining mandatory variables before invoking the script.
-
-The following Bash script adds a new user `foo` to an existing OpenVPN configuration
-
-```bash
-#!/bin/bash
-export MENU_OPTION="1"
-export CLIENT="foo"
-export PASS="1" # set to "2" for a password-protected client, and set PASSPHRASE
-export CLIENT_FILEPATH="" # optional, custom path for .ovpn file
-./openvpn-install.sh
-```
-
-### Headless User Revocation
-
-It's also possible to automate the revocation of an existing user. The key is to provide the `MENU_OPTION` variable set to `3` along with either `CLIENT` (client name) or `CLIENTNUMBER` (1-based index from the client list).
-
-The following Bash script revokes the existing user `foo`:
-
-```bash
-#!/bin/bash
-export MENU_OPTION="3"
-export CLIENT="foo"
-./openvpn-install.sh
-```
-
-Alternatively, you can use the client number:
-
-```bash
-#!/bin/bash
-export MENU_OPTION="3"
-export CLIENTNUMBER="1" # Revokes the first client in the list
-./openvpn-install.sh
-```
-
## Features
- Installs and configures a ready-to-use OpenVPN server
+- CLI interface for automation and scripting (non-interactive mode with JSON output)
- Certificate renewal for both client and server certificates
+- List and monitor connected clients
- Uses [official OpenVPN repositories](https://community.openvpn.net/openvpn/wiki/OpenvpnSoftwareRepos) when possible for the latest stable releases
- Firewall rules and forwarding managed seamlessly (native firewalld and nftables support, iptables fallback)
- Configurable VPN subnet (default: `10.8.0.0/24`)
- If needed, the script can cleanly remove OpenVPN, including configuration and firewall rules
- Customisable encryption settings, enhanced default settings (see [Security and Encryption](#security-and-encryption) below)
-- OpenVPN 2.4 features, mainly encryption improvements (see [Security and Encryption](#security-and-encryption) below)
+- Uses latest OpenVPN features when available (see [Security and Encryption](#security-and-encryption) below)
- Variety of DNS resolvers to be pushed to the clients
- Choice to use a self-hosted resolver with Unbound (supports already existing Unbound installations)
- Choice between TCP and UDP
@@ -196,6 +83,258 @@ To be noted:
- It's only tested on `amd64` architecture.
- The script requires `systemd`.
+## Usage
+
+First, download the script on your server and make it executable:
+
+```bash
+curl -O https://raw.githubusercontent.com/angristan/openvpn-install/master/openvpn-install.sh
+chmod +x openvpn-install.sh
+```
+
+You need to run the script as root and have the TUN module enabled.
+
+### Interactive Mode
+
+The easiest way to get started is the interactive menu:
+
+```bash
+./openvpn-install.sh interactive
+```
+
+This will guide you through installation and client management.
+
+In your home directory, you will have `.ovpn` files. These are the client configuration files. Download them from your server (using `scp` for example) and connect using your favorite OpenVPN client.
+
+If you have any question, head to the [FAQ](#faq) first. And if you need help, you can open a [discussion](https://github.com/angristan/openvpn-install/discussions). Please search existing issues and discussions first.
+
+### CLI Mode
+
+> [!WARNING]
+> API compatibility is not guaranteed. Breaking changes may occur between versions. If you use this script programmatically (e.g., in automation or CI/CD), pin to a specific commit rather than using the master branch.
+
+For automation and scripting, use the CLI interface:
+
+```bash
+# Install with defaults
+./openvpn-install.sh install
+
+# Add a client
+./openvpn-install.sh client add alice
+
+# List clients
+./openvpn-install.sh client list
+
+# Revoke a client
+./openvpn-install.sh client revoke alice
+```
+
+#### Commands
+
+```text
+openvpn-install [options]
+
+Commands:
+ install Install and configure OpenVPN server
+ uninstall Remove OpenVPN server
+ client Manage client certificates
+ server Server management
+ interactive Launch interactive menu
+
+Global Options:
+ --verbose Show detailed output
+ --log Log file path (default: openvpn-install.log)
+ --no-log Disable file logging
+ --no-color Disable colored output
+ -h, --help Show help
+```
+
+Run `./openvpn-install.sh --help` for command-specific options.
+
+#### Client Management
+
+```bash
+# Add a new client
+./openvpn-install.sh client add alice
+
+# Add a password-protected client
+./openvpn-install.sh client add bob --password
+
+# Revoke a client
+./openvpn-install.sh client revoke alice
+
+# Renew a client certificate
+./openvpn-install.sh client renew bob --cert-days 365
+```
+
+List all clients:
+
+```text
+$ ./openvpn-install.sh client list
+══ Client Certificates ══
+[INFO] Found 3 client certificate(s)
+
+ Name Status Expiry Remaining
+ ---- ------ ------ ---------
+ alice Valid 2035-01-15 3650 days
+ bob Valid 2035-01-15 3650 days
+ charlie Revoked 2035-01-15 unknown
+```
+
+JSON output for scripting:
+
+```text
+$ ./openvpn-install.sh client list --format json | jq
+{
+ "clients": [
+ {
+ "name": "alice",
+ "status": "valid",
+ "expiry": "2035-01-15",
+ "days_remaining": 3650
+ },
+ {
+ "name": "bob",
+ "status": "valid",
+ "expiry": "2035-01-15",
+ "days_remaining": 3650
+ },
+ {
+ "name": "charlie",
+ "status": "revoked",
+ "expiry": "2035-01-15",
+ "days_remaining": null
+ }
+ ]
+}
+```
+
+#### Server Management
+
+```bash
+# Renew server certificate
+./openvpn-install.sh server renew
+
+# Uninstall OpenVPN
+./openvpn-install.sh uninstall
+```
+
+Show connected clients (data refreshes every 60 seconds):
+
+```text
+$ ./openvpn-install.sh server status
+══ Connected Clients ══
+[INFO] Found 2 connected client(s)
+
+ Name Real Address VPN IP Connected Since Transfer
+ ---- ------------ ------ --------------- --------
+ alice 203.0.113.45:52341 10.8.0.2 2025-01-15 14:32 ↓1.2M ↑500K
+ bob 198.51.100.22:41892 10.8.0.3 2025-01-15 09:15 ↓800K ↑200K
+
+[INFO] Note: Data refreshes every 60 seconds.
+```
+
+#### Install Options
+
+The `install` command supports many options for customization:
+
+```bash
+# Custom port and protocol
+./openvpn-install.sh install --port 443 --protocol tcp
+
+# Custom DNS provider
+./openvpn-install.sh install --dns quad9
+
+# Custom encryption settings
+./openvpn-install.sh install --cipher AES-256-GCM --cert-type rsa --rsa-bits 4096
+
+# Custom VPN subnet
+./openvpn-install.sh install --subnet 10.9.0.0
+
+# Skip initial client creation
+./openvpn-install.sh install --no-client
+
+# Full example with multiple options
+./openvpn-install.sh install \
+ --port 443 \
+ --protocol tcp \
+ --dns cloudflare \
+ --cipher AES-256-GCM \
+ --client mydevice \
+ --client-cert-days 365
+```
+
+**Network Options:**
+
+- `--endpoint ` - Public IP or hostname for clients (default: auto-detected)
+- `--ip ` - Server listening IP (default: auto-detected)
+- `--ipv6` - Enable IPv6 support (default: disabled)
+- `--subnet ` - VPN subnet (default: `10.8.0.0`)
+- `--port ` - OpenVPN port (default: `1194`)
+- `--port-random` - Use random port (49152-65535)
+- `--protocol ` - Protocol (default: `udp`)
+
+**DNS Options:**
+
+- `--dns ` - DNS provider (default: `cloudflare`). Options: `system`, `unbound`, `cloudflare`, `quad9`, `quad9-uncensored`, `fdn`, `dnswatch`, `opendns`, `google`, `yandex`, `adguard`, `nextdns`, `custom`
+- `--dns-primary ` - Custom primary DNS (requires `--dns custom`)
+- `--dns-secondary ` - Custom secondary DNS (requires `--dns custom`)
+
+**Security Options:**
+
+- `--cipher ` - Data cipher (default: `AES-128-GCM`). Options: `AES-128-GCM`, `AES-192-GCM`, `AES-256-GCM`, `AES-128-CBC`, `AES-192-CBC`, `AES-256-CBC`, `CHACHA20-POLY1305`
+- `--cert-type ` - Certificate type (default: `ecdsa`)
+- `--cert-curve ` - ECDSA curve (default: `prime256v1`). Options: `prime256v1`, `secp384r1`, `secp521r1`
+- `--rsa-bits <2048|3072|4096>` - RSA key size (default: `2048`)
+- `--hmac ` - HMAC algorithm (default: `SHA256`). Options: `SHA256`, `SHA384`, `SHA512`
+- `--tls-sig ` - TLS mode (default: `crypt-v2`). Options: `crypt-v2`, `crypt`, `auth`
+- `--dh-type ` - DH key exchange type (default: `ecdh`)
+- `--dh-curve ` - ECDH curve (default: `prime256v1`). Options: `prime256v1`, `secp384r1`, `secp521r1`
+- `--dh-bits <2048|3072|4096>` - DH key size when using `--dh-type dh` (default: `2048`)
+- `--server-cert-days ` - Server cert validity in days (default: `3650`)
+
+**Client Options:**
+
+- `--client ` - Initial client name (default: `client`)
+- `--client-password [pass]` - Password-protect client key (default: no password)
+- `--client-cert-days ` - Client cert validity in days (default: `3650`)
+- `--no-client` - Skip initial client creation
+
+**Other Options:**
+
+- `--compression ` - Compression (default: `none`). Options: `none`, `lz4-v2`, `lz4`, `lzo`
+- `--multi-client` - Allow same cert on multiple devices (default: disabled)
+
+#### Automation Examples
+
+**Batch client creation:**
+
+```bash
+#!/bin/bash
+for user in alice bob charlie; do
+ ./openvpn-install.sh client add "$user"
+done
+```
+
+**Create clients from a file:**
+
+```bash
+#!/bin/bash
+while read -r user; do
+ ./openvpn-install.sh client add "$user"
+done < users.txt
+```
+
+**JSON output for scripting:**
+
+```bash
+# Get client list as JSON
+./openvpn-install.sh client list --format json | jq '.clients[] | select(.status == "valid")'
+
+# Get connected clients as JSON
+./openvpn-install.sh server status --format json
+```
+
## Fork
This script is based on the great work of [Nyr and its contributors](https://github.com/Nyr/openvpn-install).
@@ -236,7 +375,7 @@ More Q&A in [FAQ.md](FAQ.md).
**Q:** Is there an OpenVPN documentation?
-**A:** Yes, please head to the [OpenVPN Manual](https://community.openvpn.net/openvpn/wiki/Openvpn24ManPage), which references all the options.
+**A:** Yes, please head to the [OpenVPN Manual](https://openvpn.net/community-docs/community-articles/openvpn-2-6-manual.html), which references all the options.
---
@@ -244,14 +383,10 @@ More Q&A in [FAQ.md](FAQ.md).
## Contributing
-## Discuss changes
+### 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 [lint workflow configuration](https://github.com/angristan/openvpn-install/blob/master/.github/workflows/lint.yml).
-
## Security and Encryption
> [!NOTE]
diff --git a/openvpn-install.sh b/openvpn-install.sh
index 76ed0f6..587d4de 100755
--- a/openvpn-install.sh
+++ b/openvpn-install.sh
@@ -20,6 +20,7 @@ readonly EASYRSA_SHA256="662ee3b453155aeb1dff7096ec052cd83176c460cfa82ac130ef856
# Set LOG_FILE="" to disable file logging
VERBOSE=${VERBOSE:-0}
LOG_FILE=${LOG_FILE:-openvpn-install.log}
+OUTPUT_FORMAT=${OUTPUT_FORMAT:-table} # table or json - json suppresses log output
# Color definitions (disabled if not a terminal, unless FORCE_COLOR=1)
if [[ -t 1 ]] || [[ $FORCE_COLOR == "1" ]]; then
@@ -51,11 +52,13 @@ _log_to_file() {
# Logging functions
log_info() {
+ [[ $OUTPUT_FORMAT == "json" ]] && return
echo -e "${COLOR_BLUE}[INFO]${COLOR_RESET} $*"
_log_to_file "[INFO] $*"
}
log_warn() {
+ [[ $OUTPUT_FORMAT == "json" ]] && return
echo -e "${COLOR_YELLOW}[WARN]${COLOR_RESET} $*"
_log_to_file "[WARN] $*"
}
@@ -79,12 +82,13 @@ log_fatal() {
}
log_success() {
+ [[ $OUTPUT_FORMAT == "json" ]] && return
echo -e "${COLOR_GREEN}[OK]${COLOR_RESET} $*"
_log_to_file "[OK] $*"
}
log_debug() {
- if [[ $VERBOSE -eq 1 ]]; then
+ if [[ $VERBOSE -eq 1 && $OUTPUT_FORMAT != "json" ]]; then
echo -e "${COLOR_DIM}[DEBUG]${COLOR_RESET} $*"
fi
_log_to_file "[DEBUG] $*"
@@ -157,6 +161,1086 @@ run_cmd_fatal() {
fi
}
+# =============================================================================
+# CLI Configuration
+# =============================================================================
+readonly SCRIPT_NAME="openvpn-install"
+
+# =============================================================================
+# Help Text Functions
+# =============================================================================
+show_help() {
+ cat <<-EOF
+ OpenVPN installer and manager
+
+ Usage: $SCRIPT_NAME [options]
+
+ Commands:
+ install Install and configure OpenVPN server
+ uninstall Remove OpenVPN server
+ client Manage client certificates
+ server Server management
+ interactive Launch interactive menu
+
+ Global Options:
+ --verbose Show detailed output
+ --log Log file path (default: openvpn-install.log)
+ --no-log Disable file logging
+ --no-color Disable colored output
+ -h, --help Show help
+
+ Run '$SCRIPT_NAME --help' for command-specific help.
+ EOF
+}
+
+show_install_help() {
+ cat <<-EOF
+ Install and configure OpenVPN server
+
+ Usage: $SCRIPT_NAME install [options]
+
+ Options:
+ -i, --interactive Run interactive install wizard
+
+ Network Options:
+ --endpoint Public IP or hostname for clients (auto-detected)
+ --ip Server listening IP (auto-detected)
+ --ipv6 Enable IPv6 support
+ --subnet VPN subnet (default: 10.8.0.0)
+ --port OpenVPN port (default: 1194)
+ --port-random Use random port (49152-65535)
+ --protocol Protocol: udp or tcp (default: udp)
+
+ DNS Options:
+ --dns DNS provider (default: cloudflare)
+ Providers: system, unbound, cloudflare, quad9, quad9-uncensored,
+ fdn, dnswatch, opendns, google, yandex, adguard, nextdns, custom
+ --dns-primary Custom primary DNS (requires --dns custom)
+ --dns-secondary Custom secondary DNS (optional)
+
+ Security Options:
+ --cipher Data channel cipher (default: AES-128-GCM)
+ Ciphers: AES-128-GCM, AES-192-GCM, AES-256-GCM, AES-128-CBC,
+ AES-192-CBC, AES-256-CBC, CHACHA20-POLY1305
+ --cert-type Certificate type: ecdsa or rsa (default: ecdsa)
+ --cert-curve ECDSA curve (default: prime256v1)
+ Curves: prime256v1, secp384r1, secp521r1
+ --rsa-bits RSA key size: 2048, 3072, 4096 (default: 2048)
+ --cc-cipher Control channel cipher (auto-selected)
+ --dh-type DH type: ecdh or dh (default: ecdh)
+ --dh-curve ECDH curve (default: prime256v1)
+ --dh-bits DH key size: 2048, 3072, 4096 (default: 2048)
+ --hmac HMAC algorithm: SHA256, SHA384, SHA512 (default: SHA256)
+ --tls-sig TLS mode: crypt-v2, crypt, auth (default: crypt-v2)
+ --server-cert-days Server cert validity in days (default: 3650)
+
+ Other Options:
+ --compression Compression: lz4-v2, lz4, lzo, none (default: none)
+ --multi-client Allow same cert on multiple devices
+
+ Initial Client Options:
+ --client Initial client name (default: client)
+ --client-password [p] Password-protect client (prompts if no value given)
+ --client-cert-days Client cert validity in days (default: 3650)
+ --no-client Skip initial client creation
+
+ Examples:
+ $SCRIPT_NAME install
+ $SCRIPT_NAME install --port 443 --protocol tcp
+ $SCRIPT_NAME install --dns quad9 --cipher AES-256-GCM
+ $SCRIPT_NAME install -i
+ EOF
+}
+
+show_uninstall_help() {
+ cat <<-EOF
+ Remove OpenVPN server
+
+ Usage: $SCRIPT_NAME uninstall [options]
+
+ Options:
+ -f, --force Skip confirmation prompt
+
+ Examples:
+ $SCRIPT_NAME uninstall
+ $SCRIPT_NAME uninstall --force
+ EOF
+}
+
+show_client_help() {
+ cat <<-EOF
+ Manage client certificates
+
+ Usage: $SCRIPT_NAME client [options]
+
+ Subcommands:
+ add Add a new client
+ list List all clients
+ revoke Revoke a client certificate
+ renew Renew a client certificate
+
+ Run '$SCRIPT_NAME client --help' for more info.
+ EOF
+}
+
+show_client_add_help() {
+ cat <<-EOF
+ Add a new VPN client
+
+ Usage: $SCRIPT_NAME client add [options]
+
+ Options:
+ --password [pass] Password-protect client (prompts if no value given)
+ --cert-days Certificate validity in days (default: 3650)
+ --output Output path for .ovpn file (default: ~/.ovpn)
+
+ Examples:
+ $SCRIPT_NAME client add alice
+ $SCRIPT_NAME client add bob --password
+ $SCRIPT_NAME client add charlie --cert-days 365 --output /tmp/charlie.ovpn
+ EOF
+}
+
+show_client_list_help() {
+ cat <<-EOF
+ List all client certificates
+
+ Usage: $SCRIPT_NAME client list [options]
+
+ Options:
+ --format Output format: table or json (default: table)
+
+ Examples:
+ $SCRIPT_NAME client list
+ $SCRIPT_NAME client list --format json
+ EOF
+}
+
+show_client_revoke_help() {
+ cat <<-EOF
+ Revoke a client certificate
+
+ Usage: $SCRIPT_NAME client revoke [options]
+
+ Options:
+ -f, --force Skip confirmation prompt
+
+ Examples:
+ $SCRIPT_NAME client revoke alice
+ $SCRIPT_NAME client revoke bob --force
+ EOF
+}
+
+show_client_renew_help() {
+ cat <<-EOF
+ Renew a client certificate
+
+ Usage: $SCRIPT_NAME client renew [options]
+
+ Options:
+ --cert-days New certificate validity in days (default: 3650)
+
+ Examples:
+ $SCRIPT_NAME client renew alice
+ $SCRIPT_NAME client renew bob --cert-days 365
+ EOF
+}
+
+show_server_help() {
+ cat <<-EOF
+ Server management
+
+ Usage: $SCRIPT_NAME server [options]
+
+ Subcommands:
+ status List currently connected clients
+ renew Renew server certificate
+
+ Run '$SCRIPT_NAME server --help' for more info.
+ EOF
+}
+
+show_server_status_help() {
+ cat <<-EOF
+ List currently connected clients
+
+ Note: Client data is updated every 60 seconds by OpenVPN.
+
+ Usage: $SCRIPT_NAME server status [options]
+
+ Options:
+ --format Output format: table or json (default: table)
+
+ Examples:
+ $SCRIPT_NAME server status
+ $SCRIPT_NAME server status --format json
+ EOF
+}
+
+show_server_renew_help() {
+ cat <<-EOF
+ Renew server certificate
+
+ Usage: $SCRIPT_NAME server renew [options]
+
+ Options:
+ --cert-days New certificate validity in days (default: 3650)
+ -f, --force Skip confirmation/warning
+
+ Examples:
+ $SCRIPT_NAME server renew
+ $SCRIPT_NAME server renew --cert-days 1825
+ EOF
+}
+
+# =============================================================================
+# CLI Command Handlers
+# =============================================================================
+
+# Check if OpenVPN is installed
+isOpenVPNInstalled() {
+ [[ -e /etc/openvpn/server/server.conf ]]
+}
+
+# Require OpenVPN to be installed
+requireOpenVPN() {
+ if ! isOpenVPNInstalled; then
+ log_fatal "OpenVPN is not installed. Run '$SCRIPT_NAME install' first."
+ fi
+}
+
+# Require OpenVPN to NOT be installed
+requireNoOpenVPN() {
+ if isOpenVPNInstalled; then
+ log_fatal "OpenVPN is already installed. Use '$SCRIPT_NAME client' to manage clients or '$SCRIPT_NAME uninstall' to remove."
+ fi
+}
+
+# Parse DNS provider string to DNS number
+parse_dns_provider() {
+ case "$1" in
+ system) DNS=1 ;;
+ unbound) DNS=2 ;;
+ cloudflare) DNS=3 ;;
+ quad9) DNS=4 ;;
+ quad9-uncensored) DNS=5 ;;
+ fdn) DNS=6 ;;
+ dnswatch) DNS=7 ;;
+ opendns) DNS=8 ;;
+ google) DNS=9 ;;
+ yandex) DNS=10 ;;
+ adguard) DNS=11 ;;
+ nextdns) DNS=12 ;;
+ custom) DNS=13 ;;
+ *) log_fatal "Invalid DNS provider: $1. See '$SCRIPT_NAME install --help' for valid providers." ;;
+ esac
+}
+
+# Parse cipher string
+parse_cipher() {
+ case "$1" in
+ AES-128-GCM | AES-192-GCM | AES-256-GCM | AES-128-CBC | AES-192-CBC | AES-256-CBC | CHACHA20-POLY1305)
+ CIPHER="$1"
+ ;;
+ *) log_fatal "Invalid cipher: $1. See '$SCRIPT_NAME install --help' for valid ciphers." ;;
+ esac
+}
+
+# Parse curve string
+parse_curve() {
+ case "$1" in
+ prime256v1 | secp384r1 | secp521r1) echo "$1" ;;
+ *) log_fatal "Invalid curve: $1. Valid curves: prime256v1, secp384r1, secp521r1" ;;
+ esac
+}
+
+# Set default encryption settings (non-interactive mode)
+set_default_encryption() {
+ CIPHER="${CIPHER:-AES-128-GCM}"
+ CERT_TYPE="${CERT_TYPE:-1}" # ECDSA
+ CERT_CURVE="${CERT_CURVE:-prime256v1}"
+ CC_CIPHER="${CC_CIPHER:-TLS-ECDHE-ECDSA-WITH-AES-128-GCM-SHA256}"
+ DH_TYPE="${DH_TYPE:-1}" # ECDH
+ DH_CURVE="${DH_CURVE:-prime256v1}"
+ HMAC_ALG="${HMAC_ALG:-SHA256}"
+ TLS_SIG="${TLS_SIG:-1}" # tls-crypt-v2
+}
+
+# Validation functions
+validate_port() {
+ local port="$1"
+ if ! [[ "$port" =~ ^[0-9]+$ ]] || [[ "$port" -lt 1 ]] || [[ "$port" -gt 65535 ]]; then
+ log_fatal "Invalid port: $port. Must be a number between 1 and 65535."
+ fi
+}
+
+validate_subnet() {
+ local subnet="$1"
+ # Check format: x.x.x.0 where x is 0-255
+ if ! [[ "$subnet" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.0$ ]]; then
+ log_fatal "Invalid subnet: $subnet. Must be in format x.x.x.0 (e.g., 10.8.0.0)"
+ fi
+ local octet1="${BASH_REMATCH[1]}"
+ local octet2="${BASH_REMATCH[2]}"
+ local octet3="${BASH_REMATCH[3]}"
+ # Validate each octet is 0-255
+ if [[ "$octet1" -gt 255 ]] || [[ "$octet2" -gt 255 ]] || [[ "$octet3" -gt 255 ]]; then
+ log_fatal "Invalid subnet: $subnet. Octets must be 0-255."
+ fi
+ # Check for RFC1918 private address ranges
+ if ! { [[ "$octet1" -eq 10 ]] ||
+ [[ "$octet1" -eq 172 && "$octet2" -ge 16 && "$octet2" -le 31 ]] ||
+ [[ "$octet1" -eq 192 && "$octet2" -eq 168 ]]; }; then
+ log_fatal "Invalid subnet: $subnet. Must be a private network (10.x.x.0, 172.16-31.x.0, or 192.168.x.0)."
+ fi
+}
+
+validate_positive_int() {
+ local value="$1"
+ local name="$2"
+ if ! [[ "$value" =~ ^[0-9]+$ ]] || [[ "$value" -lt 1 ]]; then
+ log_fatal "Invalid $name: $value. Must be a positive integer."
+ fi
+}
+
+# Handle install command
+cmd_install() {
+ local interactive=false
+ local no_client=false
+ local client_password_flag=false
+ local client_password_value=""
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -i | --interactive)
+ interactive=true
+ shift
+ ;;
+ --endpoint)
+ [[ -z "${2:-}" ]] && log_fatal "--endpoint requires an argument"
+ ENDPOINT="$2"
+ shift 2
+ ;;
+ --ip)
+ [[ -z "${2:-}" ]] && log_fatal "--ip requires an argument"
+ IP="$2"
+ APPROVE_IP=y
+ shift 2
+ ;;
+ --ipv6)
+ IPV6_SUPPORT=y
+ shift
+ ;;
+ --subnet)
+ [[ -z "${2:-}" ]] && log_fatal "--subnet requires an argument"
+ validate_subnet "$2"
+ VPN_SUBNET="$2"
+ SUBNET_CHOICE=2
+ shift 2
+ ;;
+ --port)
+ [[ -z "${2:-}" ]] && log_fatal "--port requires an argument"
+ validate_port "$2"
+ PORT="$2"
+ PORT_CHOICE=2
+ shift 2
+ ;;
+ --port-random)
+ PORT_CHOICE=3
+ shift
+ ;;
+ --protocol)
+ [[ -z "${2:-}" ]] && log_fatal "--protocol requires an argument"
+ case "$2" in
+ udp)
+ PROTOCOL=udp
+ PROTOCOL_CHOICE=1
+ ;;
+ tcp)
+ PROTOCOL=tcp
+ PROTOCOL_CHOICE=2
+ ;;
+ *) log_fatal "Invalid protocol: $2. Use 'udp' or 'tcp'." ;;
+ esac
+ shift 2
+ ;;
+ --dns)
+ [[ -z "${2:-}" ]] && log_fatal "--dns requires an argument"
+ parse_dns_provider "$2"
+ shift 2
+ ;;
+ --dns-primary)
+ [[ -z "${2:-}" ]] && log_fatal "--dns-primary requires an argument"
+ DNS1="$2"
+ shift 2
+ ;;
+ --dns-secondary)
+ [[ -z "${2:-}" ]] && log_fatal "--dns-secondary requires an argument"
+ DNS2="$2"
+ shift 2
+ ;;
+ --compression)
+ [[ -z "${2:-}" ]] && log_fatal "--compression requires an argument"
+ case "$2" in
+ none) COMPRESSION_ENABLED=n ;;
+ lz4-v2)
+ COMPRESSION_ENABLED=y
+ COMPRESSION_ALG=lz4-v2
+ ;;
+ lz4)
+ COMPRESSION_ENABLED=y
+ COMPRESSION_ALG=lz4
+ ;;
+ lzo)
+ COMPRESSION_ENABLED=y
+ COMPRESSION_ALG=lzo
+ ;;
+ *) log_fatal "Invalid compression: $2. Use 'none', 'lz4-v2', 'lz4', or 'lzo'." ;;
+ esac
+ shift 2
+ ;;
+ --multi-client)
+ MULTI_CLIENT=y
+ shift
+ ;;
+ --cipher)
+ [[ -z "${2:-}" ]] && log_fatal "--cipher requires an argument"
+ parse_cipher "$2"
+ CUSTOMIZE_ENC=y
+ shift 2
+ ;;
+ --cert-type)
+ [[ -z "${2:-}" ]] && log_fatal "--cert-type requires an argument"
+ case "$2" in
+ ecdsa) CERT_TYPE=1 ;;
+ rsa) CERT_TYPE=2 ;;
+ *) log_fatal "Invalid cert-type: $2. Use 'ecdsa' or 'rsa'." ;;
+ esac
+ CUSTOMIZE_ENC=y
+ shift 2
+ ;;
+ --cert-curve)
+ [[ -z "${2:-}" ]] && log_fatal "--cert-curve requires an argument"
+ CERT_CURVE=$(parse_curve "$2")
+ CUSTOMIZE_ENC=y
+ shift 2
+ ;;
+ --rsa-bits)
+ [[ -z "${2:-}" ]] && log_fatal "--rsa-bits requires an argument"
+ case "$2" in
+ 2048 | 3072 | 4096) RSA_KEY_SIZE="$2" ;;
+ *) log_fatal "Invalid RSA key size: $2. Use 2048, 3072, or 4096." ;;
+ esac
+ CUSTOMIZE_ENC=y
+ shift 2
+ ;;
+ --cc-cipher)
+ [[ -z "${2:-}" ]] && log_fatal "--cc-cipher requires an argument"
+ CC_CIPHER="$2"
+ CUSTOMIZE_ENC=y
+ shift 2
+ ;;
+ --dh-type)
+ [[ -z "${2:-}" ]] && log_fatal "--dh-type requires an argument"
+ case "$2" in
+ ecdh) DH_TYPE=1 ;;
+ dh) DH_TYPE=2 ;;
+ *) log_fatal "Invalid dh-type: $2. Use 'ecdh' or 'dh'." ;;
+ esac
+ CUSTOMIZE_ENC=y
+ shift 2
+ ;;
+ --dh-curve)
+ [[ -z "${2:-}" ]] && log_fatal "--dh-curve requires an argument"
+ DH_CURVE=$(parse_curve "$2")
+ CUSTOMIZE_ENC=y
+ shift 2
+ ;;
+ --dh-bits)
+ [[ -z "${2:-}" ]] && log_fatal "--dh-bits requires an argument"
+ case "$2" in
+ 2048 | 3072 | 4096) DH_KEY_SIZE="$2" ;;
+ *) log_fatal "Invalid DH key size: $2. Use 2048, 3072, or 4096." ;;
+ esac
+ CUSTOMIZE_ENC=y
+ shift 2
+ ;;
+ --hmac)
+ [[ -z "${2:-}" ]] && log_fatal "--hmac requires an argument"
+ case "$2" in
+ SHA256 | SHA384 | SHA512) HMAC_ALG="$2" ;;
+ *) log_fatal "Invalid HMAC algorithm: $2. Use SHA256, SHA384, or SHA512." ;;
+ esac
+ CUSTOMIZE_ENC=y
+ shift 2
+ ;;
+ --tls-sig)
+ [[ -z "${2:-}" ]] && log_fatal "--tls-sig requires an argument"
+ case "$2" in
+ crypt-v2) TLS_SIG=1 ;;
+ crypt) TLS_SIG=2 ;;
+ auth) TLS_SIG=3 ;;
+ *) log_fatal "Invalid TLS mode: $2. Use 'crypt-v2', 'crypt', or 'auth'." ;;
+ esac
+ CUSTOMIZE_ENC=y
+ shift 2
+ ;;
+ --server-cert-days)
+ [[ -z "${2:-}" ]] && log_fatal "--server-cert-days requires an argument"
+ validate_positive_int "$2" "server-cert-days"
+ SERVER_CERT_DURATION_DAYS="$2"
+ shift 2
+ ;;
+ --client)
+ [[ -z "${2:-}" ]] && log_fatal "--client requires an argument"
+ CLIENT="$2"
+ shift 2
+ ;;
+ --client-password)
+ client_password_flag=true
+ # Check if next arg is a value or another flag
+ if [[ -n "${2:-}" ]] && [[ ! "$2" =~ ^- ]]; then
+ client_password_value="$2"
+ shift
+ fi
+ shift
+ ;;
+ --client-cert-days)
+ [[ -z "${2:-}" ]] && log_fatal "--client-cert-days requires an argument"
+ validate_positive_int "$2" "client-cert-days"
+ CLIENT_CERT_DURATION_DAYS="$2"
+ shift 2
+ ;;
+ --no-client)
+ no_client=true
+ shift
+ ;;
+ -h | --help)
+ show_install_help
+ exit 0
+ ;;
+ *)
+ log_fatal "Unknown option: $1. See '$SCRIPT_NAME install --help' for usage."
+ ;;
+ esac
+ done
+
+ # Validate custom DNS settings
+ if [[ -n "${DNS1:-}" || -n "${DNS2:-}" ]] && [[ "${DNS:-}" != "13" ]]; then
+ log_fatal "--dns-primary and --dns-secondary require --dns custom"
+ fi
+
+ # Check if already installed
+ requireNoOpenVPN
+
+ if [[ $interactive == true ]]; then
+ # Run interactive installer
+ installQuestions
+ installOpenVPN
+ else
+ # Non-interactive mode - set defaults
+ AUTO_INSTALL=y
+ APPROVE_INSTALL=y
+ APPROVE_IP=${APPROVE_IP:-y}
+ IPV6_SUPPORT=${IPV6_SUPPORT:-n}
+
+ # Subnet
+ if [[ -n $VPN_SUBNET ]]; then
+ SUBNET_CHOICE=${SUBNET_CHOICE:-2}
+ else
+ SUBNET_CHOICE=${SUBNET_CHOICE:-1}
+ VPN_SUBNET="10.8.0.0"
+ fi
+
+ # Port
+ PORT_CHOICE=${PORT_CHOICE:-1}
+
+ # Protocol
+ PROTOCOL_CHOICE=${PROTOCOL_CHOICE:-1}
+
+ # DNS
+ DNS=${DNS:-3}
+
+ # Compression
+ COMPRESSION_ENABLED=${COMPRESSION_ENABLED:-n}
+
+ # Multi-client
+ MULTI_CLIENT=${MULTI_CLIENT:-n}
+
+ # Encryption
+ CUSTOMIZE_ENC=${CUSTOMIZE_ENC:-n}
+ if [[ $CUSTOMIZE_ENC == "n" ]]; then
+ set_default_encryption
+ fi
+
+ # Client setup
+ if [[ $no_client == true ]]; then
+ NEW_CLIENT=n
+ else
+ NEW_CLIENT=y
+ CLIENT=${CLIENT:-client}
+ if [[ $client_password_flag == true ]]; then
+ PASS=2
+ if [[ -n "$client_password_value" ]]; then
+ PASSPHRASE="$client_password_value"
+ fi
+ else
+ PASS=${PASS:-1}
+ fi
+ fi
+
+ # Certificate duration
+ 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=y
+
+ installOpenVPN
+ fi
+}
+
+# Handle uninstall command
+cmd_uninstall() {
+ local force=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -f | --force)
+ force=true
+ shift
+ ;;
+ -h | --help)
+ show_uninstall_help
+ exit 0
+ ;;
+ *)
+ log_fatal "Unknown option: $1. See '$SCRIPT_NAME uninstall --help' for usage."
+ ;;
+ esac
+ done
+
+ requireOpenVPN
+
+ if [[ $force == true ]]; then
+ REMOVE=y
+ fi
+
+ removeOpenVPN
+}
+
+# Handle client command
+cmd_client() {
+ local subcmd="${1:-}"
+ shift || true
+
+ case "$subcmd" in
+ "" | "-h" | "--help")
+ show_client_help
+ exit 0
+ ;;
+ add)
+ cmd_client_add "$@"
+ ;;
+ list)
+ cmd_client_list "$@"
+ ;;
+ revoke)
+ cmd_client_revoke "$@"
+ ;;
+ renew)
+ cmd_client_renew "$@"
+ ;;
+ *)
+ log_fatal "Unknown client subcommand: $subcmd. See '$SCRIPT_NAME client --help' for usage."
+ ;;
+ esac
+}
+
+# Handle client add command
+cmd_client_add() {
+ local client_name=""
+ local password_flag=false
+ local password_value=""
+
+ # First non-flag argument is the client name
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --password)
+ password_flag=true
+ # Check if next arg is a value or another flag
+ if [[ -n "${2:-}" ]] && [[ ! "$2" =~ ^- ]]; then
+ password_value="$2"
+ shift
+ fi
+ shift
+ ;;
+ --cert-days)
+ [[ -z "${2:-}" ]] && log_fatal "--cert-days requires an argument"
+ validate_positive_int "$2" "cert-days"
+ CLIENT_CERT_DURATION_DAYS="$2"
+ shift 2
+ ;;
+ --output)
+ [[ -z "${2:-}" ]] && log_fatal "--output requires an argument"
+ CLIENT_FILEPATH="$2"
+ shift 2
+ ;;
+ -h | --help)
+ show_client_add_help
+ exit 0
+ ;;
+ -*)
+ log_fatal "Unknown option: $1. See '$SCRIPT_NAME client add --help' for usage."
+ ;;
+ *)
+ if [[ -z "$client_name" ]]; then
+ client_name="$1"
+ else
+ log_fatal "Unexpected argument: $1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ [[ -z "$client_name" ]] && log_fatal "Client name is required. See '$SCRIPT_NAME client add --help' for usage."
+
+ requireOpenVPN
+
+ # Set up variables for newClient function
+ CLIENT="$client_name"
+ CLIENT_CERT_DURATION_DAYS=${CLIENT_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}
+
+ if [[ $password_flag == true ]]; then
+ PASS=2
+ if [[ -n "$password_value" ]]; then
+ PASSPHRASE="$password_value"
+ fi
+ else
+ PASS=1
+ fi
+
+ newClient
+}
+
+# Handle client list command
+cmd_client_list() {
+ local format="table"
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --format)
+ [[ -z "${2:-}" ]] && log_fatal "--format requires an argument"
+ case "$2" in
+ table | json) format="$2" ;;
+ *) log_fatal "Invalid format: $2. Use 'table' or 'json'." ;;
+ esac
+ shift 2
+ ;;
+ -h | --help)
+ show_client_list_help
+ exit 0
+ ;;
+ *)
+ log_fatal "Unknown option: $1. See '$SCRIPT_NAME client list --help' for usage."
+ ;;
+ esac
+ done
+
+ requireOpenVPN
+
+ OUTPUT_FORMAT="$format" listClients
+}
+
+# Handle client revoke command
+cmd_client_revoke() {
+ local client_name=""
+ local force=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -f | --force)
+ force=true
+ shift
+ ;;
+ -h | --help)
+ show_client_revoke_help
+ exit 0
+ ;;
+ -*)
+ log_fatal "Unknown option: $1. See '$SCRIPT_NAME client revoke --help' for usage."
+ ;;
+ *)
+ if [[ -z "$client_name" ]]; then
+ client_name="$1"
+ else
+ log_fatal "Unexpected argument: $1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ [[ -z "$client_name" ]] && log_fatal "Client name is required. See '$SCRIPT_NAME client revoke --help' for usage."
+
+ requireOpenVPN
+
+ CLIENT="$client_name"
+ if [[ $force == true ]]; then
+ REVOKE_CONFIRM=y
+ fi
+
+ revokeClient
+}
+
+# Handle client renew command
+cmd_client_renew() {
+ local client_name=""
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --cert-days)
+ [[ -z "${2:-}" ]] && log_fatal "--cert-days requires an argument"
+ validate_positive_int "$2" "cert-days"
+ CLIENT_CERT_DURATION_DAYS="$2"
+ shift 2
+ ;;
+ -h | --help)
+ show_client_renew_help
+ exit 0
+ ;;
+ -*)
+ log_fatal "Unknown option: $1. See '$SCRIPT_NAME client renew --help' for usage."
+ ;;
+ *)
+ if [[ -z "$client_name" ]]; then
+ client_name="$1"
+ else
+ log_fatal "Unexpected argument: $1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ [[ -z "$client_name" ]] && log_fatal "Client name is required. See '$SCRIPT_NAME client renew --help' for usage."
+
+ requireOpenVPN
+
+ CLIENT="$client_name"
+ CLIENT_CERT_DURATION_DAYS=${CLIENT_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}
+
+ renewClient
+}
+
+# Handle server command
+cmd_server() {
+ local subcmd="${1:-}"
+ shift || true
+
+ case "$subcmd" in
+ "" | "-h" | "--help")
+ show_server_help
+ exit 0
+ ;;
+ status)
+ cmd_server_status "$@"
+ ;;
+ renew)
+ cmd_server_renew "$@"
+ ;;
+ *)
+ log_fatal "Unknown server subcommand: $subcmd. See '$SCRIPT_NAME server --help' for usage."
+ ;;
+ esac
+}
+
+# Handle server status command
+cmd_server_status() {
+ local format="table"
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --format)
+ [[ -z "${2:-}" ]] && log_fatal "--format requires an argument"
+ case "$2" in
+ table | json) format="$2" ;;
+ *) log_fatal "Invalid format: $2. Use 'table' or 'json'." ;;
+ esac
+ shift 2
+ ;;
+ -h | --help)
+ show_server_status_help
+ exit 0
+ ;;
+ *)
+ log_fatal "Unknown option: $1. See '$SCRIPT_NAME server status --help' for usage."
+ ;;
+ esac
+ done
+
+ requireOpenVPN
+
+ OUTPUT_FORMAT="$format" listConnectedClients
+}
+
+# Handle server renew command
+cmd_server_renew() {
+ local force=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --cert-days)
+ [[ -z "${2:-}" ]] && log_fatal "--cert-days requires an argument"
+ validate_positive_int "$2" "cert-days"
+ SERVER_CERT_DURATION_DAYS="$2"
+ shift 2
+ ;;
+ -f | --force)
+ force=true
+ shift
+ ;;
+ -h | --help)
+ show_server_renew_help
+ exit 0
+ ;;
+ *)
+ log_fatal "Unknown option: $1. See '$SCRIPT_NAME server renew --help' for usage."
+ ;;
+ esac
+ done
+
+ requireOpenVPN
+
+ SERVER_CERT_DURATION_DAYS=${SERVER_CERT_DURATION_DAYS:-$DEFAULT_CERT_VALIDITY_DURATION_DAYS}
+ if [[ $force == true ]]; then
+ CONTINUE=y
+ fi
+
+ renewServer
+}
+
+# Handle interactive command (legacy menu)
+cmd_interactive() {
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -h | --help)
+ echo "Launch interactive menu for OpenVPN management"
+ echo ""
+ echo "Usage: $SCRIPT_NAME interactive"
+ exit 0
+ ;;
+ *)
+ log_fatal "Unknown option: $1"
+ ;;
+ esac
+ done
+
+ if isOpenVPNInstalled; then
+ manageMenu
+ else
+ installQuestions
+ installOpenVPN
+ fi
+}
+
+# Main argument parser
+parse_args() {
+ # Parse global options first
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --verbose)
+ VERBOSE=1
+ shift
+ ;;
+ --log)
+ [[ -z "${2:-}" ]] && log_fatal "--log requires an argument"
+ LOG_FILE="$2"
+ shift 2
+ ;;
+ --no-log)
+ LOG_FILE=""
+ shift
+ ;;
+ --no-color)
+ # Colors already set at script start, but we can unset them
+ COLOR_RESET=''
+ COLOR_RED=''
+ COLOR_GREEN=''
+ COLOR_YELLOW=''
+ COLOR_BLUE=''
+ COLOR_CYAN=''
+ COLOR_DIM=''
+ COLOR_BOLD=''
+ shift
+ ;;
+ -h | --help)
+ show_help
+ exit 0
+ ;;
+ -*)
+ # Could be a command-specific option, let command handle it
+ break
+ ;;
+ *)
+ # First non-option is the command
+ break
+ ;;
+ esac
+ done
+
+ # Get the command
+ local cmd="${1:-}"
+ shift || true
+
+ # Check if user just wants help (don't require root for help)
+ # Also detect --format json early to suppress log output before initialCheck
+ local wants_help=false
+ local prev_arg=""
+ for arg in "$@"; do
+ if [[ "$arg" == "-h" || "$arg" == "--help" ]]; then
+ wants_help=true
+ fi
+ if [[ "$prev_arg" == "--format" && "$arg" == "json" ]]; then
+ OUTPUT_FORMAT="json"
+ fi
+ prev_arg="$arg"
+ done
+
+ # Dispatch to command handler
+ case "$cmd" in
+ "")
+ show_help
+ exit 0
+ ;;
+ install)
+ [[ $wants_help == false ]] && initialCheck
+ cmd_install "$@"
+ ;;
+ uninstall)
+ [[ $wants_help == false ]] && initialCheck
+ cmd_uninstall "$@"
+ ;;
+ client)
+ [[ $wants_help == false ]] && initialCheck
+ cmd_client "$@"
+ ;;
+ server)
+ [[ $wants_help == false ]] && initialCheck
+ cmd_server "$@"
+ ;;
+ interactive)
+ [[ $wants_help == false ]] && initialCheck
+ cmd_interactive "$@"
+ ;;
+ *)
+ log_fatal "Unknown command: $cmd. See '$SCRIPT_NAME --help' for usage."
+ ;;
+ esac
+}
+
+# =============================================================================
+# System Check Functions
+# =============================================================================
function isRoot() {
if [ "$EUID" -ne 0 ]; then
return 1
@@ -309,7 +1393,7 @@ function initialCheck() {
log_debug "Detecting operating system..."
checkOS
- log_info "Detected OS: $OS (${PRETTY_NAME:-unknown})"
+ log_debug "Detected OS: $OS (${PRETTY_NAME:-unknown})"
checkArchPendingKernelUpgrade
}
@@ -1821,86 +2905,122 @@ function selectClient() {
CLIENT=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep "^V" | cut -d '=' -f 2 | sed -n "$CLIENTNUMBER"p)
}
-function listClients() {
- log_header "Client Certificates"
+# Escape a string for JSON output
+function json_escape() {
+ local str="$1"
+ # Escape backslashes first, then quotes, then control characters
+ str="${str//\\/\\\\}"
+ str="${str//\"/\\\"}"
+ str="${str//$'\n'/\\n}"
+ str="${str//$'\r'/\\r}"
+ str="${str//$'\t'/\\t}"
+ printf '%s' "$str"
+}
+function listClients() {
local index_file="/etc/openvpn/server/easy-rsa/pki/index.txt"
+ local cert_dir="/etc/openvpn/server/easy-rsa/pki/issued"
local number_of_clients
+ local format="${OUTPUT_FORMAT:-table}"
+
# Exclude server certificates (CN starting with server_)
number_of_clients=$(tail -n +2 "$index_file" | grep "^[VR]" | grep -cv "/CN=server_")
if [[ $number_of_clients == '0' ]]; then
- log_warn "You have no existing client certificates!"
+ if [[ $format == "json" ]]; then
+ echo '{"clients":[]}'
+ else
+ log_warn "You have no existing client certificates!"
+ fi
return
fi
- log_info "Found $number_of_clients client certificate(s)"
- log_menu ""
- printf " %-25s %-10s %-12s %s\n" "Name" "Status" "Expiry" "Remaining"
- printf " %-25s %-10s %-12s %s\n" "----" "------" "------" "---------"
+ # Collect client data
+ local clients_data=()
+ while read -r line; do
+ local status="${line:0:1}"
+ local client_name
+ client_name=$(echo "$line" | sed 's/.*\/CN=//')
- local cert_dir="/etc/openvpn/server/easy-rsa/pki/issued"
+ local status_text
+ if [[ "$status" == "V" ]]; then
+ status_text="valid"
+ elif [[ "$status" == "R" ]]; then
+ status_text="revoked"
+ else
+ status_text="unknown"
+ fi
- # Parse index.txt and sort by expiry date (oldest first)
- # Exclude server certificates (CN starting with server_)
- {
- while read -r line; do
- local status="${line:0:1}"
- local client_name
- client_name=$(echo "$line" | sed 's/.*\/CN=//')
+ local cert_file="$cert_dir/$client_name.crt"
+ local expiry_date="unknown"
+ local days_remaining="null"
- # Format status
- local status_text
- if [[ "$status" == "V" ]]; then
- status_text="Valid"
- elif [[ "$status" == "R" ]]; then
- status_text="Revoked"
- else
- status_text="Unknown"
- fi
+ if [[ -f "$cert_file" ]]; then
+ local enddate
+ enddate=$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2)
- # Get expiry date from certificate file
- local cert_file="$cert_dir/$client_name.crt"
- local expiry_date="unknown"
- local relative="unknown"
+ if [[ -n "$enddate" ]]; then
+ local expiry_epoch
+ expiry_epoch=$(date -d "$enddate" +%s 2>/dev/null || date -j -f "%b %d %H:%M:%S %Y %Z" "$enddate" +%s 2>/dev/null)
- if [[ -f "$cert_file" ]]; then
- # Get expiry from certificate (format: notAfter=Mon DD HH:MM:SS YYYY GMT)
- local enddate
- enddate=$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2)
-
- if [[ -n "$enddate" ]]; then
- # Parse date and convert to epoch
- local expiry_epoch
- expiry_epoch=$(date -d "$enddate" +%s 2>/dev/null || date -j -f "%b %d %H:%M:%S %Y %Z" "$enddate" +%s 2>/dev/null)
-
- if [[ -n "$expiry_epoch" ]]; then
- # Format as YYYY-MM-DD
- expiry_date=$(date -d "@$expiry_epoch" +%Y-%m-%d 2>/dev/null || date -r "$expiry_epoch" +%Y-%m-%d 2>/dev/null)
-
- # Calculate days remaining
- local now_epoch days_remaining
- now_epoch=$(date +%s)
- days_remaining=$(((expiry_epoch - now_epoch) / 86400))
-
- if [[ $days_remaining -lt 0 ]]; then
- relative="$((-days_remaining)) days ago"
- elif [[ $days_remaining -eq 0 ]]; then
- relative="today"
- elif [[ $days_remaining -eq 1 ]]; then
- relative="1 day"
- else
- relative="$days_remaining days"
- fi
- fi
+ if [[ -n "$expiry_epoch" ]]; then
+ expiry_date=$(date -d "@$expiry_epoch" +%Y-%m-%d 2>/dev/null || date -r "$expiry_epoch" +%Y-%m-%d 2>/dev/null)
+ local now_epoch
+ now_epoch=$(date +%s)
+ days_remaining=$(((expiry_epoch - now_epoch) / 86400))
fi
fi
+ fi
- printf " %-25s %-10s %-12s %s\n" "$client_name" "$status_text" "$expiry_date" "$relative"
- done < <(tail -n +2 "$index_file" | grep "^[VR]" | grep -v "/CN=server_" | sort -t$'\t' -k2)
- }
+ clients_data+=("$client_name|$status_text|$expiry_date|$days_remaining")
+ done < <(tail -n +2 "$index_file" | grep "^[VR]" | grep -v "/CN=server_" | sort -t$'\t' -k2)
- log_menu ""
+ if [[ $format == "json" ]]; then
+ # Output JSON
+ echo '{"clients":['
+ local first=true
+ for client_entry in "${clients_data[@]}"; do
+ IFS='|' read -r name status expiry days <<<"$client_entry"
+ [[ $first == true ]] && first=false || printf ','
+ # Handle null for days_remaining (no quotes for JSON null)
+ local days_json
+ if [[ "$days" == "null" || -z "$days" ]]; then
+ days_json="null"
+ else
+ days_json="$days"
+ fi
+ printf '{"name":"%s","status":"%s","expiry":"%s","days_remaining":%s}\n' \
+ "$(json_escape "$name")" "$(json_escape "$status")" "$(json_escape "$expiry")" "$days_json"
+ done
+ echo ']}'
+ else
+ # Output table
+ log_header "Client Certificates"
+ log_info "Found $number_of_clients client certificate(s)"
+ log_menu ""
+ printf " %-25s %-10s %-12s %s\n" "Name" "Status" "Expiry" "Remaining"
+ printf " %-25s %-10s %-12s %s\n" "----" "------" "------" "---------"
+
+ for client_entry in "${clients_data[@]}"; do
+ IFS='|' read -r name status expiry days <<<"$client_entry"
+ local relative
+ if [[ $days == "null" ]]; then
+ relative="unknown"
+ elif [[ $days -lt 0 ]]; then
+ relative="$((-days)) days ago"
+ elif [[ $days -eq 0 ]]; then
+ relative="today"
+ elif [[ $days -eq 1 ]]; then
+ relative="1 day"
+ else
+ relative="$days days"
+ fi
+ # Capitalize status for table display
+ local status_display="${status^}"
+ printf " %-25s %-10s %-12s %s\n" "$name" "$status_display" "$expiry" "$relative"
+ done
+ log_menu ""
+ fi
}
function formatBytes() {
@@ -1922,13 +3042,16 @@ function formatBytes() {
}
function listConnectedClients() {
- log_header "Connected Clients"
-
local status_file="/var/log/openvpn/status.log"
+ local format="${OUTPUT_FORMAT:-table}"
if [[ ! -f "$status_file" ]]; then
- log_warn "Status file not found: $status_file"
- log_info "Make sure OpenVPN is running."
+ if [[ $format == "json" ]]; then
+ echo '{"error":"Status file not found","clients":[]}'
+ else
+ log_warn "Status file not found: $status_file"
+ log_info "Make sure OpenVPN is running."
+ fi
return
fi
@@ -1936,36 +3059,66 @@ function listConnectedClients() {
client_count=$(grep -c "^CLIENT_LIST" "$status_file" 2>/dev/null) || client_count=0
if [[ "$client_count" -eq 0 ]]; then
- log_info "No clients currently connected."
+ if [[ $format == "json" ]]; then
+ echo '{"clients":[]}'
+ else
+ log_header "Connected Clients"
+ log_info "No clients currently connected."
+ log_info "Note: Data refreshes every 60 seconds."
+ fi
return
fi
- log_info "Found $client_count connected client(s)"
- log_menu ""
- printf " %-20s %-22s %-16s %-20s %s\n" "Name" "Real Address" "VPN IP" "Connected Since" "Transfer"
- printf " %-20s %-22s %-16s %-20s %s\n" "----" "------------" "------" "---------------" "--------"
-
+ # Collect client data
+ local clients_data=()
while IFS=',' read -r _ name real_addr vpn_ip _ bytes_recv bytes_sent connected_since _; do
- local recv_human sent_human
- recv_human=$(formatBytes "$bytes_recv")
- sent_human=$(formatBytes "$bytes_sent")
- local transfer="↓${recv_human} ↑${sent_human}"
-
- printf " %-20s %-22s %-16s %-20s %s\n" "$name" "$real_addr" "$vpn_ip" "$connected_since" "$transfer"
+ clients_data+=("$name|$real_addr|$vpn_ip|$bytes_recv|$bytes_sent|$connected_since")
done < <(grep "^CLIENT_LIST" "$status_file")
- log_menu ""
+ if [[ $format == "json" ]]; then
+ echo '{"clients":['
+ local first=true
+ for client_entry in "${clients_data[@]}"; do
+ IFS='|' read -r name real_addr vpn_ip bytes_recv bytes_sent connected_since <<<"$client_entry"
+ [[ $first == true ]] && first=false || printf ','
+ printf '{"name":"%s","real_address":"%s","vpn_ip":"%s","bytes_received":%s,"bytes_sent":%s,"connected_since":"%s"}\n' \
+ "$(json_escape "$name")" "$(json_escape "$real_addr")" "$(json_escape "$vpn_ip")" \
+ "${bytes_recv:-0}" "${bytes_sent:-0}" "$(json_escape "$connected_since")"
+ done
+ echo ']}'
+ else
+ log_header "Connected Clients"
+ log_info "Found $client_count connected client(s)"
+ log_menu ""
+ printf " %-20s %-22s %-16s %-20s %s\n" "Name" "Real Address" "VPN IP" "Connected Since" "Transfer"
+ printf " %-20s %-22s %-16s %-20s %s\n" "----" "------------" "------" "---------------" "--------"
+
+ for client_entry in "${clients_data[@]}"; do
+ IFS='|' read -r name real_addr vpn_ip bytes_recv bytes_sent connected_since <<<"$client_entry"
+ local recv_human sent_human
+ recv_human=$(formatBytes "$bytes_recv")
+ sent_human=$(formatBytes "$bytes_sent")
+ local transfer="↓${recv_human} ↑${sent_human}"
+ printf " %-20s %-22s %-16s %-20s %s\n" "$name" "$real_addr" "$vpn_ip" "$connected_since" "$transfer"
+ done
+ log_menu ""
+ log_info "Note: Data refreshes every 60 seconds."
+ fi
}
function newClient() {
log_header "New Client Setup"
- 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
+ # Only prompt for client name if not already set
+ if ! [[ $CLIENT =~ ^[a-zA-Z0-9_-]+$ ]]; then
+ 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
+ fi
+ # Only prompt for cert duration if not already set
if [[ -z $CLIENT_CERT_DURATION_DAYS ]] || ! [[ $CLIENT_CERT_DURATION_DAYS =~ ^[0-9]+$ ]] || [[ $CLIENT_CERT_DURATION_DAYS -lt 1 ]]; then
log_menu ""
log_prompt "How many days should the client certificate be valid for?"
@@ -1974,15 +3127,17 @@ function newClient() {
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
+ # Only prompt for password if not already set
+ if ! [[ $PASS =~ ^[1-2]$ ]]; then
+ log_menu ""
+ log_prompt "Do you want to protect the configuration file with a password?"
+ log_prompt "(e.g. encrypt the private key with a password)"
+ log_menu " 1) Add a passwordless client"
+ log_menu " 2) Use a password for the client"
+ until [[ $PASS =~ ^[1-2]$ ]]; do
+ read -rp "Select an option [1-2]: " -e -i 1 PASS
+ done
+ fi
CLIENTEXISTS=$(tail -n +2 /etc/openvpn/server/easy-rsa/pki/index.txt | grep -E "^V" | grep -c -E "/CN=$CLIENT\$")
if [[ $CLIENTEXISTS != '0' ]]; then
@@ -2255,7 +3410,9 @@ function removeUnbound() {
function removeOpenVPN() {
log_header "Remove OpenVPN"
- read -rp "Do you really want to remove OpenVPN? [y/n]: " -e -i n REMOVE
+ if [[ -z $REMOVE ]]; then
+ read -rp "Do you really want to remove OpenVPN? [y/n]: " -e -i n REMOVE
+ fi
if [[ $REMOVE == 'y' ]]; then
# Get OpenVPN configuration
PORT=$(grep '^port ' /etc/openvpn/server/server.conf | cut -d " " -f 2)
@@ -2397,12 +3554,7 @@ function manageMenu() {
esac
}
-# Check for root, TUN, OS...
-initialCheck
-
-# Check if OpenVPN is already installed
-if [[ -e /etc/openvpn/server/server.conf ]]; then
- manageMenu
-else
- installOpenVPN
-fi
+# =============================================================================
+# Main Entry Point
+# =============================================================================
+parse_args "$@"
diff --git a/test/Dockerfile.server b/test/Dockerfile.server
index 7eae918..5c0814b 100644
--- a/test/Dockerfile.server
+++ b/test/Dockerfile.server
@@ -17,29 +17,29 @@ ENV ENABLE_NFTABLES=${ENABLE_NFTABLES}
# 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 \
+ iproute2 iptables curl procps systemd systemd-sysv dnsutils jq \
&& if [ "$ENABLE_NFTABLES" = "y" ]; then apt-get install -y --no-install-recommends nftables; fi \
&& 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 \
+ iproute iptables curl procps-ng systemd tar gzip bind-utils jq \
&& if [ "$ENABLE_FIREWALLD" = "y" ]; then dnf install -y firewalld; fi \
&& if [ "$ENABLE_NFTABLES" = "y" ]; then dnf install -y nftables; fi \
&& dnf clean all; \
elif command -v yum >/dev/null; then \
yum install -y \
- iproute iptables curl procps-ng systemd tar gzip bind-utils \
+ iproute iptables curl procps-ng systemd tar gzip bind-utils jq \
&& if [ "$ENABLE_FIREWALLD" = "y" ]; then yum install -y firewalld; fi \
&& if [ "$ENABLE_NFTABLES" = "y" ]; then yum install -y nftables; fi \
&& yum clean all; \
elif command -v pacman >/dev/null; then \
pacman -Syu --noconfirm \
- iproute2 iptables curl procps-ng bind \
+ iproute2 iptables curl procps-ng bind jq \
&& if [ "$ENABLE_NFTABLES" = "y" ]; then pacman -S --noconfirm nftables; fi \
&& pacman -Scc --noconfirm; \
elif command -v zypper >/dev/null; then \
zypper install -y \
- iproute2 iptables curl procps systemd tar gzip bind-utils gawk \
+ iproute2 iptables curl procps systemd tar gzip bind-utils gawk jq \
&& if [ "$ENABLE_NFTABLES" = "y" ]; then zypper install -y nftables; fi \
&& zypper clean -a; \
fi
diff --git a/test/server-entrypoint.sh b/test/server-entrypoint.sh
index d3a5694..5f39f48 100755
--- a/test/server-entrypoint.sh
+++ b/test/server-entrypoint.sh
@@ -12,54 +12,40 @@ fi
echo "TUN device ready"
-# Set up environment for auto-install
-export AUTO_INSTALL=y
+# Configuration for install
export FORCE_COLOR=1
-export APPROVE_INSTALL=y
-export APPROVE_IP=y
-export IPV6_SUPPORT=n
-export VPN_SUBNET=10.9.0.0 # Custom subnet to test configurability
-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
+VPN_SUBNET=10.9.0.0 # Custom subnet to test configurability
# Calculate VPN gateway from subnet (first usable IP)
VPN_GATEWAY="${VPN_SUBNET%.*}.1"
export VPN_GATEWAY
# TLS key type configuration (default: tls-crypt-v2)
-# TLS_SIG: 1=tls-crypt-v2, 2=tls-crypt, 3=tls-auth
+# TLS_SIG: crypt-v2, crypt, auth
# TLS_KEY_FILE: the expected key file name for verification
-TLS_SIG="${TLS_SIG:-1}"
+TLS_SIG="${TLS_SIG:-crypt-v2}"
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
+# Build install command with CLI flags (using array for proper quoting)
+INSTALL_CMD=(/opt/openvpn-install.sh install)
+INSTALL_CMD+=(--endpoint openvpn-server)
+INSTALL_CMD+=(--dns unbound)
+INSTALL_CMD+=(--subnet "$VPN_SUBNET")
+INSTALL_CMD+=(--client testclient)
+
+# Add TLS signature mode if non-default
+if [ "$TLS_SIG" != "crypt-v2" ]; then
+ INSTALL_CMD+=(--tls-sig "$TLS_SIG")
echo "Testing TLS key type: $TLS_SIG (key file: $TLS_KEY_FILE)"
-else
- export CUSTOMIZE_ENC=n
fi
echo "Running OpenVPN install script..."
+echo "Command: ${INSTALL_CMD[*]}"
# 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_CMD[@]}") 2>&1 | tee "$INSTALL_OUTPUT"
INSTALL_EXIT_CODE=${PIPESTATUS[0]}
echo "=== Installation complete (exit code: $INSTALL_EXIT_CODE) ==="
@@ -196,7 +182,7 @@ 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=4 RENEW_OPTION=1 CLIENTNUMBER=1 CLIENT_CERT_DURATION_DAYS=3650 bash /opt/openvpn-install.sh) 2>&1 | tee "$RENEW_OUTPUT" || true
+(bash /opt/openvpn-install.sh client renew testclient --cert-days 3650) 2>&1 | tee "$RENEW_OUTPUT" || true
# Verify renewal succeeded
if grep -q "Certificate for client testclient renewed" "$RENEW_OUTPUT"; then
@@ -276,7 +262,7 @@ 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=4 RENEW_OPTION=2 CONTINUE=y SERVER_CERT_DURATION_DAYS=3650 bash /opt/openvpn-install.sh) 2>&1 | tee "$RENEW_SERVER_OUTPUT" || true
+(bash /opt/openvpn-install.sh server renew --cert-days 3650 --force) 2>&1 | tee "$RENEW_SERVER_OUTPUT" || true
# Verify renewal succeeded
if grep -q "Server certificate renewed successfully" "$RENEW_SERVER_OUTPUT"; then
@@ -545,7 +531,7 @@ echo "=== Testing Certificate Revocation ==="
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
+(bash /opt/openvpn-install.sh client add "$REVOKE_CLIENT" --cert-days 3650) 2>&1 | tee "$REVOKE_CREATE_OUTPUT" || true
if [ -f "/root/$REVOKE_CLIENT.ovpn" ]; then
echo "PASS: Client '$REVOKE_CLIENT' created successfully"
@@ -579,6 +565,42 @@ if [ ! -f /shared/revoke-client-connected ]; then
fi
echo "PASS: Client connected with '$REVOKE_CLIENT' certificate"
+# =====================================================
+# Test server status command
+# =====================================================
+echo ""
+echo "=== Testing Server Status ==="
+
+# Note: OpenVPN status file updates periodically (default: 1 min)
+# so we just verify the command works, not that a specific client is visible
+
+# Test table output
+STATUS_OUTPUT="/tmp/server-status-output.log"
+(bash /opt/openvpn-install.sh server status) 2>&1 | tee "$STATUS_OUTPUT" || true
+
+if grep -q "Connected Clients" "$STATUS_OUTPUT"; then
+ echo "PASS: Server status shows header"
+else
+ echo "FAIL: Server status missing header"
+ cat "$STATUS_OUTPUT"
+ exit 1
+fi
+
+# Test JSON output
+STATUS_JSON_OUTPUT="/tmp/server-status-json-output.log"
+(bash /opt/openvpn-install.sh server status --format json) 2>&1 | tee "$STATUS_JSON_OUTPUT" || true
+
+# Validate JSON structure (clients array exists, even if empty)
+if jq -e '.clients' "$STATUS_JSON_OUTPUT" >/dev/null 2>&1; then
+ echo "PASS: Server status JSON is valid"
+else
+ echo "FAIL: Server status JSON is invalid"
+ cat "$STATUS_JSON_OUTPUT"
+ exit 1
+fi
+
+echo "=== Server Status Tests PASSED ==="
+
# Signal client to disconnect before revocation
touch /shared/revoke-client-disconnect
@@ -597,11 +619,10 @@ if [ ! -f /shared/revoke-client-disconnected ]; then
fi
echo "Client disconnected"
-# Now revoke the certificate using the new CLIENT name feature
+# Now revoke the certificate
echo "Revoking certificate for '$REVOKE_CLIENT'..."
REVOKE_OUTPUT="/tmp/revoke-output.log"
-# MENU_OPTION=3 is revoke, CLIENT specifies the client name directly
-(MENU_OPTION=3 CLIENT=$REVOKE_CLIENT bash /opt/openvpn-install.sh) 2>&1 | tee "$REVOKE_OUTPUT" || true
+(bash /opt/openvpn-install.sh client revoke "$REVOKE_CLIENT" --force) 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"
@@ -652,7 +673,7 @@ echo "=== Testing List Client Certificates ==="
# - testclient (Revoked) - the old certificate revoked during renewal
# - revoketest (Revoked) - the revoked certificate
LIST_OUTPUT="/tmp/list-clients-output.log"
-(MENU_OPTION=2 bash /opt/openvpn-install.sh) 2>&1 | tee "$LIST_OUTPUT" || true
+(bash /opt/openvpn-install.sh client list) 2>&1 | tee "$LIST_OUTPUT" || true
# Verify list output contains expected clients
if grep -q "testclient" "$LIST_OUTPUT" && grep -q "Valid" "$LIST_OUTPUT"; then
@@ -680,6 +701,48 @@ else
exit 1
fi
+# Test JSON output
+echo "Testing client list JSON output..."
+LIST_JSON_OUTPUT="/tmp/list-clients-json-output.log"
+(bash /opt/openvpn-install.sh client list --format json) 2>&1 | tee "$LIST_JSON_OUTPUT" || true
+
+# Validate JSON structure
+if jq -e '.clients' "$LIST_JSON_OUTPUT" >/dev/null 2>&1; then
+ echo "PASS: Client list JSON is valid"
+else
+ echo "FAIL: Client list JSON is invalid"
+ cat "$LIST_JSON_OUTPUT"
+ exit 1
+fi
+
+# Verify client count in JSON
+JSON_CLIENT_COUNT=$(jq '.clients | length' "$LIST_JSON_OUTPUT")
+if [ "$JSON_CLIENT_COUNT" -eq 3 ]; then
+ echo "PASS: Client list JSON has correct count ($JSON_CLIENT_COUNT)"
+else
+ echo "FAIL: Client list JSON has wrong count: $JSON_CLIENT_COUNT (expected 3)"
+ cat "$LIST_JSON_OUTPUT"
+ exit 1
+fi
+
+# Verify valid client in JSON
+if jq -e '.clients[] | select(.name == "testclient" and .status == "valid")' "$LIST_JSON_OUTPUT" >/dev/null 2>&1; then
+ echo "PASS: Client list JSON shows testclient as valid"
+else
+ echo "FAIL: Client list JSON does not show testclient correctly"
+ cat "$LIST_JSON_OUTPUT"
+ exit 1
+fi
+
+# Verify revoked client in JSON
+if jq -e ".clients[] | select(.name == \"$REVOKE_CLIENT\" and .status == \"revoked\")" "$LIST_JSON_OUTPUT" >/dev/null 2>&1; then
+ echo "PASS: Client list JSON shows $REVOKE_CLIENT as revoked"
+else
+ echo "FAIL: Client list JSON does not show $REVOKE_CLIENT correctly"
+ cat "$LIST_JSON_OUTPUT"
+ exit 1
+fi
+
echo "=== List Client Certificates Tests PASSED ==="
# =====================================================
@@ -691,7 +754,7 @@ 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
+(bash /opt/openvpn-install.sh client add "$REVOKE_CLIENT" --cert-days 3650) 2>&1 | tee "$RECREATE_OUTPUT" || true
if [ -f "/root/$REVOKE_CLIENT.ovpn" ]; then
echo "PASS: New client '$REVOKE_CLIENT' created successfully (reusing revoked name)"
@@ -758,7 +821,7 @@ PASSPHRASE_CLIENT="passphrasetest"
TEST_PASSPHRASE="TestP@ssw0rd#123"
echo "Creating client '$PASSPHRASE_CLIENT' with passphrase in headless mode..."
PASSPHRASE_OUTPUT="/tmp/passphrase-output.log"
-(MENU_OPTION=1 CLIENT=$PASSPHRASE_CLIENT PASS=2 PASSPHRASE="$TEST_PASSPHRASE" CLIENT_CERT_DURATION_DAYS=3650 bash /opt/openvpn-install.sh) 2>&1 | tee "$PASSPHRASE_OUTPUT" || true
+(bash /opt/openvpn-install.sh client add "$PASSPHRASE_CLIENT" --password "$TEST_PASSPHRASE" --cert-days 3650) 2>&1 | tee "$PASSPHRASE_OUTPUT" || true
# Verify client was created
if [ -f "/root/$PASSPHRASE_CLIENT.ovpn" ]; then