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.
FeatureWhat happens underneath
Create clientGenerates an EC key, creates a CSR, signs it with the CA via openssl ca, and assembles the inline .ovpn profile.
Download .ovpnServes the pre-built profile file as a browser file download with the correct MIME type.
Revoke clientRuns openssl ca -revoke, regenerates the CRL, copies it to /etc/openvpn/server/, and reloads OpenVPN via systemctl.
Renew CRLRegenerates the CRL from the CA database and deploys it without touching any certificates.
Connected clientsParses the OpenVPN status file (openvpn-status.log) in real time.
JSON APIReturns current status as JSON for monitoring integration.
02 — Requirements
RequirementNotes
PHP 7.4+php-cli or mod_php / php-fpm. Uses str_starts_with() (7.4+) and str_contains() (8.0+).
openssl binaryMust be in PATH for the web server user. Standard on all Linux distros.
OpenVPN PKI directoryMust exist and be writable by the web server user. Created by the OpenSSL PKI setup described in the companion article.
sudo rightsThe web server user needs passwordless sudo for exactly two commands: cp (CRL deploy) and systemctl reload openvpn.
HTTPSMandatory 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
FunctionWhat 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.

ControlImplementation
Network restrictionNginx/Apache allow/deny rules restrict access to specific admin IPs before PHP even runs.
HTTPS onlyTLS encrypts the session, login credentials, and the .ovpn download which contains a private key.
Session authenticationPHP session with cookie_httponly and cookie_samesite=Strict. Session destroyed on logout.
CSRF protectionA random 32-hex-char token stored in session, embedded as a hidden field in every form, verified on every POST.
Input validationAll client names validated against /^[a-zA-Z0-9_\-]+$/ before use. No raw user input ever reaches a shell command without escapeshellarg().
Shell argument escapingEvery value passed to run() goes through q() — a thin wrapper around escapeshellarg(). File paths included.
Minimal sudo scopeSudoers rules allow only two exact commands with exact paths — not ALL, not /usr/bin/sudo *.
File permissionsPrivate keys written at 0400, .ovpn profiles at 0600. The PKI directory itself is 0700.
Output escapingAll 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 elementData source
Active Clients countNumber of .crt files in CLIENTS_DIR.
Connected Now countLive parse of openvpn-status.log.
Revoked countNumber of .crt files in REVOKED_DIR.
CRL Expires dateopenssl crl -noout -nextupdate on the current CRL.
Online badgeCN present in the connected list from status file.
Offline badgeCN not in connected list, certificate valid.
Expired badgeCertificate notAfter is in the past.
Expires soon badgeCertificate expires within 30 days.
⬇ .ovpn buttonPOST to ?action=gen_ovpn — regenerates the profile.
Download buttonGET to ?action=download&name=X — serves the file.
Revoke buttonOpens 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
PatternUse case
aliceSingle device per user — simple and readable.
alice-laptopUser with multiple devices — one cert per device.
alice-iphoneMobile device — separate cert makes revocation surgical.
svc-backupService account — automated backup job connecting to the VPN.
office-routerSite-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
SymptomCause & Fix
White page / 500 error on loadPHP 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 fileProfile was not built yet — click "⬇ .ovpn" first to generate it, then Download.
Connected count always 0OVPN_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 errorSession expired or cookies disabled. Log out and back in. Ensure session.save_path is writable by www-data.
Client cert already exists errorA .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
URLWhat it does
?action=dashboardMain view — stats, client table, revoked list
?action=addForm 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=XDownload 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=connectedLive connected clients from status file
?action=api_statusJSON status for monitoring
?action=logoutDestroy 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.