A single-file PHP web interface for managing OpenVPN clients — create certificates, download .ovpn profiles, revoke access, and monitor live connections, all from a browser. No framework, no Composer, no dependencies.
PHP 7.4+ · OpenSSL · single file · session auth · CSRF · Nginx · Apache
01 — What it Does
The manager is a single openvpn-manager.php file that handles the entire OpenVPN client lifecycle through a web browser. It calls the system's openssl binary directly, reads and writes the PKI directory on disk, and uses sudo systemctl to reload the OpenVPN daemon after CRL changes — with no intermediary daemon, database, or dependency.
Everything Easy-RSA does from the command line, this interface does from a browser — with the same raw OpenSSL calls underneath, just wrapped in a clean UI.
| Feature | What happens underneath |
| Create client | Generates an EC key, creates a CSR, signs it with the CA via openssl ca, and assembles the inline .ovpn profile. |
| Download .ovpn | Serves the pre-built profile file as a browser file download with the correct MIME type. |
| Revoke client | Runs openssl ca -revoke, regenerates the CRL, copies it to /etc/openvpn/server/, and reloads OpenVPN via systemctl. |
| Renew CRL | Regenerates the CRL from the CA database and deploys it without touching any certificates. |
| Connected clients | Parses the OpenVPN status file (openvpn-status.log) in real time. |
| JSON API | Returns current status as JSON for monitoring integration. |
02 — Requirements
| Requirement | Notes |
| PHP 7.4+ | php-cli or mod_php / php-fpm. Uses str_starts_with() (7.4+) and str_contains() (8.0+). |
| openssl binary | Must be in PATH for the web server user. Standard on all Linux distros. |
| OpenVPN PKI directory | Must exist and be writable by the web server user. Created by the OpenSSL PKI setup described in the companion article. |
| sudo rights | The web server user needs passwordless sudo for exactly two commands: cp (CRL deploy) and systemctl reload openvpn. |
| HTTPS | Mandatory in production — the app handles private keys and serves .ovpn files containing client private keys. |
Install PHP
# Debian / Ubuntu
sudo apt install php php-cli
# RHEL / Fedora
sudo dnf install php php-cli
# Verify
php --version
03 — Configuration
All configuration is in the CONFIG block at the top of openvpn-manager.php. Edit these constants before deploying — they map to your PKI directory layout and the OpenVPN server details that get embedded in every .ovpn profile.
Configuration constants — top of the file
// ── PKI paths — must match your actual PKI directory ──────────────────
define('PKI_DIR', '/root/openvpn-pki');
define('CA_CERT', PKI_DIR . '/ca/ca.crt');
define('CA_KEY', PKI_DIR . '/ca/ca.key');
define('TA_KEY', PKI_DIR . '/ta.key');
define('OPENSSL_CNF', PKI_DIR . '/openssl.cnf');
define('CLIENTS_DIR', PKI_DIR . '/clients');
define('CSR_DIR', PKI_DIR . '/csr');
define('CRL_PATH', PKI_DIR . '/crl/crl.pem');
define('REVOKED_DIR', PKI_DIR . '/revoked');
define('PROFILES_DIR', PKI_DIR . '/profiles');
// ── Embedded in every .ovpn profile ───────────────────────────────────
define('SERVER_IP', 'YOUR_SERVER_IP'); // ← your VPN server's public IP
define('SERVER_PORT', '1194');
define('PROTO', 'udp');
// ── OpenVPN runtime ───────────────────────────────────────────────────
define('OVPN_STATUS', '/var/log/openvpn/openvpn-status.log');
define('OVPN_SYSTEMD', 'openvpn-server@server');
define('OVPN_SERVER_CRL', '/etc/openvpn/server/crl.pem');
// ── Certificate subject fields ────────────────────────────────────────
define('CERT_COUNTRY', 'PT');
define('CERT_STATE', 'Porto');
define('CERT_CITY', 'Porto');
define('CERT_ORG', 'MyOrg');
define('CERT_OU', 'VPN');
define('CERT_DAYS', '825');
define('KEY_CURVE', 'secp384r1');
// ── Web app ───────────────────────────────────────────────────────────
define('ADMIN_PASSWORD', 'changeme'); // ← set a strong password
define('SESSION_NAME', 'ovpn_mgr');
⚠ Change ADMIN_PASSWORD, SERVER_IP, and the certificate subject fields before deploying. The default password is intentionally obvious to make it impossible to accidentally forget.
04 — Deployment
The entire application is one PHP file. Drop it into any web server directory that can execute PHP. The recommended layout isolates it from the public web root so it can only be reached by administrators.
Step 1 — copy the file
# Create a dedicated directory
sudo mkdir -p /var/www/html/vpnmgr
# Copy the manager
sudo cp openvpn-manager.php /var/www/html/vpnmgr/index.php
# Lock down permissions — readable by web server, not world-readable
sudo chown www-data:www-data /var/www/html/vpnmgr/index.php
sudo chmod 640 /var/www/html/vpnmgr/index.php
Step 2 — grant PKI directory access
# Option A — change ownership to the web server user
sudo chown -R www-data:www-data /root/openvpn-pki
# Option B — use ACLs (keeps root as owner, grants www-data rwX)
sudo apt install acl
sudo setfacl -R -m u:www-data:rwX /root/openvpn-pki
sudo setfacl -R -d -m u:www-data:rwX /root/openvpn-pki # default for new files
Step 3 — configure sudo for CRL deploy and OpenVPN reload
# Create a dedicated sudoers file — never edit /etc/sudoers directly
sudo visudo -f /etc/sudoers.d/openvpn-manager
# Add these two lines:
www-data ALL=(ALL) NOPASSWD: /bin/systemctl reload openvpn-server@server
www-data ALL=(ALL) NOPASSWD: /bin/cp /root/openvpn-pki/crl/crl.pem /etc/openvpn/server/crl.pem
Step 4 — verify openssl is accessible to www-data
# Test that the web server user can run openssl
sudo -u www-data openssl version
# If it fails, find openssl and add its directory to the PHP open_basedir
# or use the full path in the config (replace all openssl calls with /usr/bin/openssl)
05 — Nginx Configuration
The manager should only be accessible to specific IP addresses — never the open internet. Enforce this at the web server level in addition to the app-level password.
/etc/nginx/sites-available/vpnmgr
server {
listen 443 ssl http2;
server_name vpn.example.com;
ssl_certificate /etc/letsencrypt/live/vpn.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/vpn.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /var/www/html/vpnmgr;
index index.php;
# Restrict to admin IPs only — add your own IPs here
location / {
allow 203.0.113.10; # office static IP
allow 198.51.100.0/24; # admin VPN subnet
deny all;
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# Block direct access to the PKI directory
location ~ /openvpn-pki { deny all; }
}
# Redirect HTTP → HTTPS
server {
listen 80;
server_name vpn.example.com;
return 301 https://$host$request_uri;
}
sudo ln -s /etc/nginx/sites-available/vpnmgr /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
06 — Apache Configuration
/etc/apache2/sites-available/vpnmgr.conf
<VirtualHost *:443>
ServerName vpn.example.com
DocumentRoot /var/www/html/vpnmgr
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/vpn.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/vpn.example.com/privkey.pem
<Directory /var/www/html/vpnmgr>
Options -Indexes
AllowOverride None
# Restrict to admin IPs
Require ip 203.0.113.10
Require ip 198.51.100.0/24
</Directory>
# PHP execution
<FilesMatch "\.php$">
SetHandler application/x-httpd-php
</FilesMatch>
</VirtualHost>
<VirtualHost *:80>
ServerName vpn.example.com
Redirect permanent / https://vpn.example.com/
</VirtualHost>
sudo a2enmod ssl rewrite
sudo a2ensite vpnmgr
sudo apache2ctl configtest && sudo systemctl reload apache2
07 — How the App Works
The file is a self-contained MVC-style application. A single query parameter (?action=) drives the router, which dispatches to render functions that write HTML directly. Every mutating action is a POST with a CSRF token.
Request flow
Browser request
│
├── No session? → render_login()
│
├── POST without CSRF token? → die()
│
└── Dispatch on ?action=
dashboard → get_clients() + get_connected() → render_dashboard()
add → render_add()
create → create_client_cert() + build_ovpn() → render_dashboard()
gen_ovpn → build_ovpn() → render_dashboard()
download → readfile(.ovpn) with Content-Disposition header
revoke → revoke_client() → render_dashboard()
crl_renew → renew_crl() → render_dashboard()
connected → get_connected() → render_connected()
api_status → json_encode(status) → exit
logout → session_destroy() → redirect login
Key functions
| Function | What it does |
| run(string $cmd) | Executes a shell command via proc_open(), captures stdout and stderr separately, returns exit code. Never uses shell_exec() or system(). |
| q(string $s) | Wraps escapeshellarg() — all user input and all file paths are escaped before being passed to any shell command. |
| valid_name(string $name) | Strict regex whitelist — only [a-zA-Z0-9_\-] allowed as a client name. Rejects path traversal, spaces, special characters. |
| cert_info(string $path) | Parses openssl x509 -noout -subject -dates -fingerprint output into an associative array. Computes days until expiry. |
| create_client_cert(string $name) | EC key → CSR → signed cert, with cleanup of partial files on any failure. |
| build_ovpn(string $name) | Reads CA cert, client cert (PEM only via openssl x509), client key, and ta.key, assembles inline .ovpn file. |
| revoke_client(string $name) | Revoke → regenerate CRL → sudo cp → sudo systemctl reload → archive files → remove profile. |
| get_connected() | Parses the openvpn-status.log CSV format, returning CN, real address, bytes transferred, and connection time. |
08 — Security Model
The application handles highly sensitive material — CA-signed certificates and private keys. The security model relies on layered controls, none of which should be skipped.
| Control | Implementation |
| Network restriction | Nginx/Apache allow/deny rules restrict access to specific admin IPs before PHP even runs. |
| HTTPS only | TLS encrypts the session, login credentials, and the .ovpn download which contains a private key. |
| Session authentication | PHP session with cookie_httponly and cookie_samesite=Strict. Session destroyed on logout. |
| CSRF protection | A random 32-hex-char token stored in session, embedded as a hidden field in every form, verified on every POST. |
| Input validation | All client names validated against /^[a-zA-Z0-9_\-]+$/ before use. No raw user input ever reaches a shell command without escapeshellarg(). |
| Shell argument escaping | Every value passed to run() goes through q() — a thin wrapper around escapeshellarg(). File paths included. |
| Minimal sudo scope | Sudoers rules allow only two exact commands with exact paths — not ALL, not /usr/bin/sudo *. |
| File permissions | Private keys written at 0400, .ovpn profiles at 0600. The PKI directory itself is 0700. |
| Output escaping | All values rendered into HTML pass through h() — a wrapper around htmlspecialchars() with ENT_QUOTES. |
⚠ The CA private key (ca/ca.key) sits in the PKI directory. If you give www-data read access to the entire PKI tree, it can read the CA key. For a stricter setup, move the CA key offline and only bring it online when signing certificates — then sign manually and copy only the resulting .crt to the server.
09 — The Dashboard
The dashboard at ?action=dashboard is the main view. It shows four stat cards at the top and a table of all active clients below.
| UI element | Data source |
| Active Clients count | Number of .crt files in CLIENTS_DIR. |
| Connected Now count | Live parse of openvpn-status.log. |
| Revoked count | Number of .crt files in REVOKED_DIR. |
| CRL Expires date | openssl crl -noout -nextupdate on the current CRL. |
| Online badge | CN present in the connected list from status file. |
| Offline badge | CN not in connected list, certificate valid. |
| Expired badge | Certificate notAfter is in the past. |
| Expires soon badge | Certificate expires within 30 days. |
| ⬇ .ovpn button | POST to ?action=gen_ovpn — regenerates the profile. |
| Download button | GET to ?action=download&name=X — serves the file. |
| Revoke button | Opens a confirm modal, then POST to ?action=revoke. |
10 — Adding a New Client
The ?action=add form accepts a client name and triggers the full certificate chain in one request: key generation, CSR, CA signing, verification, and profile assembly.
What happens on submit
# 1. Validate name against /^[a-zA-Z0-9_\-]+$/
# 2. Check CLIENTS_DIR/{name}.crt does not already exist
# 3. create_client_cert($name):
openssl ecparam -name secp384r1 -genkey -noout -out clients/{name}.key
openssl req -new -key clients/{name}.key -out csr/{name}.csr -subj "/C=PT/.../CN={name}"
openssl ca -config openssl.cnf -extensions usr_cert -days 825 \
-notext -batch -in csr/{name}.csr -out clients/{name}.crt
openssl verify -CAfile ca/ca.crt clients/{name}.crt
# 4. build_ovpn($name):
# Reads ca.crt, extracts PEM from clients/{name}.crt via openssl x509,
# reads clients/{name}.key and ta.key,
# assembles profiles/{name}.ovpn with inline , , , blocks
# 5. Flash success message → redirect to dashboard
Client naming conventions
| Pattern | Use case |
| alice | Single device per user — simple and readable. |
| alice-laptop | User with multiple devices — one cert per device. |
| alice-iphone | Mobile device — separate cert makes revocation surgical. |
| svc-backup | Service account — automated backup job connecting to the VPN. |
| office-router | Site-to-site — a fixed device with a fixed cert. |
ℹ One certificate per device, not per user. If a user's laptop is stolen, you revoke alice-laptop without affecting alice-iphone. If you issue one cert per user, you have to revoke all their devices at once.
11 — Downloading .ovpn Profiles
The Download button serves the .ovpn file directly from PHP with the correct headers for a browser file download. The file is self-contained — everything the client needs is embedded inline.
Profile download headers
Content-Type: application/x-openvpn-profile
Content-Disposition: attachment; filename="alice.ovpn"
Content-Length: {filesize}
Cache-Control: no-store
What's inside the .ovpn file
client
dev tun
proto udp
remote YOUR_SERVER_IP 1194
resolv-retry infinite
nobind
persist-key
persist-tun
cipher AES-256-GCM
auth SHA256
tls-version-min 1.2
key-direction 1
verb 3
<ca>
-----BEGIN CERTIFICATE-----
(CA certificate — the trust anchor)
-----END CERTIFICATE-----
</ca>
<cert>
-----BEGIN CERTIFICATE-----
(Client certificate — identifies this user/device)
-----END CERTIFICATE-----
</cert>
<key>
-----BEGIN EC PRIVATE KEY-----
(Client private key — keep this secret)
-----END EC PRIVATE KEY-----
</key>
<tls-crypt>
-----BEGIN OpenVPN Static key V1-----
(Pre-shared TLS authentication key)
-----END OpenVPN Static key V1-----
</tls-crypt>
⚠ The .ovpn file contains the client's private key. It must be transferred to the end user over a secure channel only — never by plain email or public file sharing. Consider using Signal, SFTP, or a time-limited download link.
12 — Revoking a Client
Revocation immediately blocks a client — the CRL is regenerated, deployed to /etc/openvpn/server/crl.pem, and OpenVPN is reloaded. Any active connection from that client is dropped within seconds.
The revocation sequence
# 1. Revoke the certificate in the CA database
openssl ca -config openssl.cnf -revoke clients/{name}.crt
# 2. Regenerate the CRL
openssl ca -config openssl.cnf -gencrl -out crl/crl.pem
# 3. Deploy the CRL (requires sudo)
sudo cp /root/openvpn-pki/crl/crl.pem /etc/openvpn/server/crl.pem
# 4. Reload OpenVPN (no connections dropped, CRL re-read)
sudo systemctl reload openvpn-server@server
# 5. Archive the revoked files
mv clients/{name}.crt → revoked/{name}.crt
mv clients/{name}.key → revoked/{name}.key
# 6. Delete the .ovpn profile and CSR
rm profiles/{name}.ovpn
rm csr/{name}.csr
✔ The revoke action uses systemctl reload (SIGHUP), not restart — existing connections from other clients are not dropped. Only the revoked client is refused on their next reconnection attempt.
13 — CRL Management
The Certificate Revocation List has an expiry date. If it expires, OpenVPN refuses all connections until a fresh CRL is deployed. The dashboard shows the CRL expiry date as a stat card — watch it.
Check CRL expiry from the command line
openssl crl -in /root/openvpn-pki/crl/crl.pem -noout -nextupdate
Automate CRL renewal with cron
# /etc/cron.d/openvpn-crl-renew
# Renew the CRL every Monday at 03:00 — adjust to be well before expiry
0 3 * * 1 root \
openssl ca -config /root/openvpn-pki/openssl.cnf \
-gencrl -out /root/openvpn-pki/crl/crl.pem && \
cp /root/openvpn-pki/crl/crl.pem /etc/openvpn/server/crl.pem && \
systemctl reload openvpn-server@server
Or use the web UI's Renew CRL button
# The "↺ Renew CRL" button on the dashboard POSTs to ?action=crl_renew
# which runs:
openssl ca -config openssl.cnf -gencrl -out crl/crl.pem
sudo cp crl/crl.pem /etc/openvpn/server/crl.pem
sudo systemctl reload openvpn-server@server
⚠ The CRL validity period is controlled by default_crl_days in openssl.cnf (default 30 days). Set a cron job to renew it at least once a week — far more frequently than the validity period — so an unnoticed failure doesn't lock everyone out.
14 — JSON API
The ?action=api_status endpoint returns a JSON snapshot of the current VPN state — useful for monitoring dashboards, alerting pipelines, or shell scripts.
Request
curl -s -b "ovpn_mgr=YOUR_SESSION_COOKIE" \
"https://vpn.example.com/vpnmgr/?action=api_status" | python3 -m json.tool
Response
{
"clients": 8,
"revoked": 2,
"crl_expiry": "Apr 16 03:00:00 2026 GMT",
"connected": [
{
"cn": "alice-laptop",
"real_addr": "203.0.113.42:51234",
"bytes_recv": "1048576",
"bytes_sent": "524288",
"connected_since": "2026-03-17 09:14:03"
},
{
"cn": "bob-iphone",
"real_addr": "198.51.100.7:44821",
"bytes_recv": "204800",
"bytes_sent": "98304",
"connected_since": "2026-03-17 11:02:51"
}
]
}
15 — Troubleshooting
| Symptom | Cause & Fix |
| White page / 500 error on load | PHP error — check /var/log/nginx/error.log or /var/log/apache2/error.log. Enable display_errors = On temporarily in php.ini to see the message. |
| Create client fails: "Key generation failed" | openssl not in PATH for www-data. Test with sudo -u www-data openssl version. Use full path /usr/bin/openssl in the run() calls if needed. |
| Create client fails: "Certificate signing failed" | openssl.cnf path wrong, or index.txt / serial files missing or not writable. Check PKI_DIR ownership. |
| Revoke fails: "CRL deploy failed" | Sudoers rule missing or path mismatch. Test manually: sudo -u www-data sudo cp /root/openvpn-pki/crl/crl.pem /etc/openvpn/server/crl.pem. |
| Revoke fails: "systemctl reload failed" | Sudoers rule for systemctl missing or service name wrong. Check OVPN_SYSTEMD matches systemctl status openvpn*. |
| Download button shows no file | Profile was not built yet — click "⬇ .ovpn" first to generate it, then Download. |
| Connected count always 0 | OVPN_STATUS path is wrong, or www-data can't read the file. Check: sudo -u www-data cat /var/log/openvpn/openvpn-status.log. |
| CSRF token mismatch error | Session expired or cookies disabled. Log out and back in. Ensure session.save_path is writable by www-data. |
| Client cert already exists error | A .crt file with that name is already in CLIENTS_DIR. Choose a different name or revoke the old one first. |
Useful diagnostic commands
# Check PHP version and loaded modules
php -v
php -m | grep -i openssl
# Verify www-data can write to the PKI directory
sudo -u www-data touch /root/openvpn-pki/test && echo "OK" && \
sudo -u www-data rm /root/openvpn-pki/test
# Test openssl access as www-data
sudo -u www-data openssl version
# Test the full cert signing pipeline manually as www-data
sudo -u www-data openssl ecparam -name secp384r1 -genkey -noout -out /tmp/test.key
sudo -u www-data openssl req -new -key /tmp/test.key -out /tmp/test.csr \
-subj "/C=PT/ST=Porto/O=Test/CN=testclient"
sudo -u www-data openssl ca -config /root/openvpn-pki/openssl.cnf \
-extensions usr_cert -days 30 -notext -batch \
-in /tmp/test.csr -out /tmp/test.crt
rm /tmp/test.key /tmp/test.csr /tmp/test.crt
# Check sudo rules work
sudo -u www-data sudo systemctl reload openvpn-server@server
sudo -u www-data sudo cp /root/openvpn-pki/crl/crl.pem /etc/openvpn/server/crl.pem
# Check PHP session directory is writable
ls -la $(php -r "echo session_save_path() ?: '/var/lib/php/sessions';")
sudo chown www-data:www-data /var/lib/php/sessions
16 — Quick Reference
| URL | What it does |
| ?action=dashboard | Main view — stats, client table, revoked list |
| ?action=add | Form to create a new client |
| ?action=create (POST) | Create key + cert + .ovpn profile |
| ?action=gen_ovpn (POST) | Regenerate .ovpn profile for an existing client |
| ?action=download&name=X | Download X.ovpn as a file attachment |
| ?action=revoke (POST) | Revoke certificate, rebuild and deploy CRL |
| ?action=crl_renew (POST) | Renew and redeploy the CRL |
| ?action=connected | Live connected clients from status file |
| ?action=api_status | JSON status for monitoring |
| ?action=logout | Destroy session and redirect to login |
✔ Deployment checklist: set SERVER_IP and ADMIN_PASSWORD → place file in web root → configure HTTPS in Nginx/Apache → restrict by IP → grant www-data PKI write access → add two sudoers lines → verify with sudo -u www-data openssl version → open browser and test.