A web-based management tool for OpenVPN servers. Manage configuration, certificates, clients, routes, and backups across local and remote servers through a unified UI.
- Server management — Add local or remote OpenVPN servers (SSH-based); read and write
/etc/openvpnconfig files with full directive support and descriptions - VPN instances — Manage multiple OpenVPN server processes per host; edit port, protocol, device, network settings
- Client management — Generate
.ovpnclient profiles with embedded certificates and TLS-auth key; import existing PAM users with pre-existing certificates - Certificate management — Full Easy-RSA PKI integration; create, revoke, and inspect client certificates; supports separate Easy-RSA servers
- PAM integration — Create/delete system users on the VPN server via PAM; enforces
nologinshell for VPN-only accounts - Active Directory / LDAP — Authenticate VPN users against AD; sync group members as VPN clients; deploy a self-contained auth plugin to the OpenVPN server
- Route management — Configure routes between multiple
tundevices - Backup & restore — Archive
/etc/openvpnand Easy-RSA PKI directories; SHA-256 confirmation required for restore; automatic pre-restore snapshot - Deployment — Deploy and configure OpenVPN and Easy-RSA from scratch on Ubuntu systems
- User management — Role-based access control (
admin,operator,viewer,vpn_user) with full CRUD; local and LDAP users - Audit logging — All mutating operations are logged
| Layer | Technology |
|---|---|
| Backend | Python 3.12, FastAPI, SQLAlchemy (async), PostgreSQL (SQLite is used only for the in-memory test suite) |
| Frontend | Vue 3, TypeScript, Vite, PrimeVue 4, Pinia, Axios |
| Remote execution | asyncssh — all commands run via SSH on remote servers |
| Auth | JWT RS256 — 15 min access tokens + 7-day httpOnly refresh cookie |
| SSH key storage | AES-256-GCM (HKDF-derived key, decrypted in memory only) |
- All shell commands run with
shell=Falseagainst anALLOWED_BINARIESwhitelist — no command injection possible - Passwords passed via stdin pipe, never as CLI arguments
- Pydantic v2 strict validation + regex patterns on all path/name fields
- Backup restore requires SHA-256 checksum confirmation + path-traversal protection on tar extraction
- Rate limiting and account lockout on login endpoint
cp .env.example .env
# Edit .env — set SSH_KEY_ENCRYPTION_SECRET and adjust CORS_ALLOWED_ORIGINS
# Generate RS256 JWT keys
openssl genrsa -out private.pem 4096
openssl rsa -in private.pem -pubout -out public.pem
docker compose up -dThe UI is available at http://localhost:8080. The default admin account is created on first run (see APP_ADMIN_PASSWORD in .env).
Backend
cd backend
uv sync
uv run alembic upgrade head
uv run uvicorn app.main:app --reloadDatabase note:
alembic upgraderequires PostgreSQL. Some migrations useALTER TABLE … ADD CONSTRAINT, which SQLite does not support, so SQLite cannot be used as a development database via migrations. SQLite is used only by the test suite, which builds the schema directly withBase.metadata.create_all.
Frontend
cd frontend
npm install
npm run devThe Vite dev server proxies /api to http://localhost:8000.
Key environment variables (see .env.example for the full list):
| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL (e.g. postgresql+asyncpg://…). Required for migrations; SQLite is test-only |
JWT_PRIVATE_KEY_PATH |
Path to RS256 private key |
JWT_PUBLIC_KEY_PATH |
Path to RS256 public key |
SSH_KEY_ENCRYPTION_SECRET |
Secret used to encrypt stored SSH private keys |
BACKUP_STORAGE_PATH |
Directory where backup archives are stored |
CORS_ALLOWED_ORIGINS |
Comma-separated list of allowed origins |
| Role | Capabilities |
|---|---|
admin |
Full access — servers, users, backup, deploy, all settings |
operator |
Client and certificate management; read server config |
viewer |
Read-only access to all resources |
vpn_user |
Download their own .ovpn config only — no access to management UI |
OpenVPN Manager can authenticate VPN clients against Active Directory (or any LDAP-compatible directory). The flow involves three components:
- LDAP config — stored in the database; bind credentials encrypted with AES-256-GCM
- Auth plugin — a Python script deployed to
/etc/openvpn/scripts/ldap_auth.pyon the VPN server - Per-instance config — a JSON file at
/etc/openvpn/ldap-auth-{instance_id}.jsonpassed asargv[1]to the script at connect time
The ldap3 Python library must be installed on the VPN server (not the manager host):
apt install python3-ldap3
# or
pip3 install ldap3Navigate to Active Directory in the sidebar.
Click Add Configuration and fill in:
| Field | Example | Notes |
|---|---|---|
| Name | Corporate AD |
Label shown in the UI |
| Server URL | ldap://dc.example.com:389 |
Use ldaps:// for port 636 |
| Backup Server URL | ldap://dc2.example.com:389 |
Tried if primary is unreachable |
| Bind DN | CN=svc-vpn,OU=ServiceAccounts,DC=example,DC=com |
Service account with read access |
| Bind Password | … |
Encrypted at rest with AES-256-GCM |
| User Search Base | OU=Users,DC=example,DC=com |
DN under which users are searched |
| User Filter | (objectClass=person) |
Narrows which objects count as users |
| Username Attribute | sAMAccountName |
uid for OpenLDAP |
| Group Search Base | OU=Groups,DC=example,DC=com |
Used to enumerate group members for sync |
| Group Member Attribute | member |
Attribute on the group listing its members |
| Use STARTTLS | off | Upgrade a plain LDAP connection to TLS |
| Verify TLS cert | off | Enable for production with a valid CA cert |
Click Test Connection to verify the bind credentials before saving.
Still on the Active Directory page, expand the configuration and add group → role mappings.
Each mapping says "members of this AD group get this application role":
| Group DN | Role |
|---|---|
CN=VPN-Admins,OU=Groups,DC=example,DC=com |
admin |
CN=VPN-Operators,OU=Groups,DC=example,DC=com |
operator |
CN=VPN-Users,OU=Groups,DC=example,DC=com |
vpn_user |
When a user from multiple mapped groups logs in they receive the highest-priority role (admin > operator > viewer > vpn_user). Users with no matching group receive vpn_user by default.
Open the VPN instance → Settings tab → Active Directory / LDAP Authentication section.
- Tick Enable LDAP authentication on this instance
- Select the LDAP configuration created in Step 1
- Add one or more VPN User Groups — the AD groups whose members are allowed to connect:
CN=VPN-Users,OU=Groups,DC=example,DC=com
Multiple groups can be added; any member of any listed group is permitted.
Click Sync Users from AD. For each member found in the configured groups the manager will:
- Create a local
Userrecord (role from group mappings,auth_source=ldap) - Create a
VpnClientrecord for the VPN instance - Issue a PKI certificate via Easy-RSA (if a CA passphrase is stored on the instance)
Sync can be re-run at any time; existing clients are skipped.
Click Deploy Auth Plugin. The manager:
- Writes
/etc/openvpn/scripts/ldap_auth.pyto the VPN server - Writes
/etc/openvpn/ldap-auth-{instance_id}.jsoncontaining bind credentials, group DNs, and enforcement flags (mode600, readable by root only)
The deploy result shows the two directives to add to the server config. Open the Config Editor tab and add them:
script-security 2
auth-user-pass-verify "/etc/openvpn/scripts/ldap_auth.py /etc/openvpn/ldap-auth-4.json" via-file
Save Config and restart the OpenVPN service from the Service tab.
Note: OpenVPN only allows one
auth-user-pass-verifydirective. The LDAP script handles both password verification and CN=username enforcement (controlled by theenforce_cn_usernametoggle on the instance). Do not use the separate CN verify script alongside the LDAP plugin.
vpn_user accounts can log in to the OpenVPN Manager UI and see only the My VPN Config page, where they can download their personal .ovpn file. No other management pages are accessible.
When a client connects OpenVPN calls the script via via-file:
auth-user-pass-verify "/etc/openvpn/scripts/ldap_auth.py /etc/openvpn/ldap-auth-4.json" via-file
via-file means OpenVPN writes username\npassword\n to a mode-600 temp file and appends its path as an extra argument, so the script receives:
argv[1]— path to the JSON config (ldap-auth-4.json)argv[2]— path to the temp credentials file (created and deleted by OpenVPN)$common_name— certificate CN, always set as an environment variable
The script (ldap_auth.py):
- Reads the JSON config from
argv[1] - Reads username and password from the temp file at
argv[2], then zeros the password from memory immediately - If
enforce_cn_usernameistrue, rejects if cert CN ≠ username - Binds the service account and searches for the user by
username_attr - Binds as the found user DN to verify the password
- If
vpn_groupsis non-empty, checks that the user DN appears in thememberattribute of at least one listed group - Logs result to syslog (
/var/log/auth.log, facilityauth, tagopenvpn-ldap-auth) - Exits
0(accept) or1(reject)
Using via-file instead of via-env means the password is never visible in /proc/<pid>/environ, which is readable by any process running as root.
If the primary LDAP server is unreachable, server_url_backup is tried automatically.
Any change to the LDAP config (new group, updated password, changed search base) requires clicking Deploy Auth Plugin again to regenerate the JSON file on the server. User sync does not happen automatically — run it manually whenever the AD group membership changes.
On the Users page, set Auth Source to AD / LDAP when creating a user manually. No password is required; the user authenticates against AD at login time. Select the LDAP configuration to associate with the account.
Alternatively, users are provisioned automatically (JIT) on first login if they authenticate successfully against any active LDAP configuration.
All endpoints are under /api/v1/. Interactive docs available at http://localhost:8000/docs when running in development mode.
# Backend
cd backend && uv run pytest
# Frontend unit tests
cd frontend && npm run test:unit
# Frontend e2e tests
cd frontend && npm run test:e2eEach tier has a single source of truth for its version:
- Backend —
backend/pyproject.tomlversion(read at runtime from package metadata and returned byGET /api/v1/system/info). - Frontend —
frontend/package.jsonversion(baked into the bundle at build time).
To cut a release, bump both to the same semver value and tag the commit. The running versions are shown in the UI: the frontend version on the login page, and both the frontend (UI) and backend (API) versions in the sidebar footer (hover for the git commit).
For precise build identification, pass the git commit at build time so it appears alongside the version:
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_TIME=$(date -u +%FT%TZ) \
docker compose up -d --buildIf unset, the commit/build-time simply read unknown — the semver versions still display.