A web interface for managing an OpenVPN server, with added support for pluggable log archive storage, an audit log browser, GeoIP enrichment, and an improved map view.
This is a fork of d3vilh/openvpn-ui. All credit for the original work goes to the upstream author. This fork extends it with production-oriented features specific to our deployment.
Audit log browser (/logs/browse)
- Reads archived OpenVPN session logs from the configured storage backend
- Filters by date and by user CN
- Shows connect time, disconnect time, session duration, and source location
- Exports filtered results as CSV
Automatic log archiving
- A host cron job collects OpenVPN journal lines into a rolling master log
- A second job compresses the master log and stores the archive in the configured backend daily at 23:59 UTC, or earlier if the log exceeds 10 MB
- Archives are named
openvpn-logs-YYYY-MM-DD-HHmmss.log.gz
Pluggable archive storage
local(default) — stores.log.gzarchives on the VM filesystem. Zero credentials, works out of the box. Default path is/var/log/openvpn-ui/archives(override with--local-log-dir). Disk usage is negligible in practice — ~14 kB/day in real-world use.oss— uploads archives to an Alibaba Cloud OSS bucket. Requires a RAM user (see permissions below).s3— uploads archives to an AWS S3 bucket. Requires an IAM user/role (seehost/aws-s3-iam-policy.json).gcs— uploads archives to a GCP Cloud Storage bucket. Requires a service account with a bucket-scoped role (seehost/gcs-bucket-role.yaml).- Backend is selected via
--storage-provider local|oss|s3|gcsinsetup.sh, or by editingStorageProviderinapp.confand restarting the container. The audit log browser and the rotation script both honor this setting.
GeoIP enrichment
- Audit log sessions show city and country resolved from the client source IP
- Map view shows the same for connected clients
- Powered by the MaxMind GeoLite2-City database
Improved map view (/map)
- Clients that disconnected within the last 4 hours appear as faded markers alongside currently connected clients
- Clicking a faded marker shows the CN, location, disconnect time, and duration
Monitoring API (/api/v1/metrics/)
- Read-only JSON + Prometheus exposition for centralized monitoring
- Endpoints:
summary,clients,disconnects,portforwards,certificates,prometheus - Token-gated (
Authorization: Bearer …); default-deny returns 404 when no token is set - See Monitoring API below
TCP port forwarding (host/scripts/port-forward.sh + web UI at /portforward)
- Forwards TCP traffic on a chosen listen port of the VPN VM to an internal
<ip>:<port>, useful when VPN clients need access to an intranet service that isn't reachable any other way - Persistent across reboots (iptables-persistent +
/etc/sysctl.d) - Multiple rules supported, identified by listen port for idempotency
- Three management surfaces, all backed by the same script:
- During setup via
setup.sh --port-forward(repeatable) - On the VM via
port-forward.sh add|list|remove - In the browser at
Configuration → Port forwarding(the web UI shells out toport-forward.shso CLI and UI never disagree)
- During setup via
Setting up on a specific cloud? Follow the end-to-end provisioning walkthrough for your provider instead of the generic steps below:
- SETUP-ALIBABA.md — Alibaba Cloud ECS
- SETUP-GCP.md — Google Cloud Compute Engine
The steps below are the generic, provider-agnostic path for a VM you already have (bare-metal, on-prem, or any other cloud).
On the VM (your VPN server):
- A fresh Ubuntu 22.04 or 24.04 server (1–2 vCPU, 1 GB RAM is enough to run)
- Root or sudo access
On your local machine:
- Docker installed (to build the image — the VM doesn't have enough RAM to build)
- Git
- SSH access to the VM
Optional, for extra features:
- A free MaxMind account — enables the map view
- A cloud-bucket store for log archives — only if you want cloud-backed log archive storage; otherwise the local backend is used and needs nothing. Supported: Alibaba Cloud OSS, AWS S3, or GCP Cloud Storage.
- A domain name pointed at the VM — enables HTTPS
Do this on both the VM and your local machine.
git clone https://github.com/aliAljaffer/openvpn-ui.git
cd openvpn-uiSSH into your VM and run angristan/openvpn-install:
curl -O https://raw.githubusercontent.com/angristan/openvpn-install/master/openvpn-install.sh
chmod +x openvpn-install.sh
sudo bash openvpn-install.sh install \
--port 1194 \
--client-cert-days 60 \
--server-cert-days 730 \
--tls-sig crypt-v2 \
--no-clientWhy these flags?
--port 1194— sets the VPN port explicitly. You will need to open this port (UDP) in your firewall or cloud security group for clients to connect.--client-cert-days 60/--server-cert-days 730— the installer defaults to 10-year certificates. These give 60 days for client certs and 2 years for the server cert.--tls-sig crypt-v2— required for per-client tls-crypt-v2 keys, which our client scripts generate and embed in each.ovpnfile.--no-client— skip the initial test client; openvpn-ui manages clients.
When the installer finishes, OpenVPN will be running as a systemd service.
Note: The installer configures the management interface as a Unix socket by default.
setup.sh(next step) detects this and switches it to TCP automatically.
Still on the VM, inside the cloned repository:
cd openvpn-ui
sudo ./host/setup.sh initThis is an interactive guided setup. It will ask you:
- Admin username and password — for logging into the web UI
- Map view — whether to enable it (requires a MaxMind account)
- Log archive storage backend —
local(default, no credentials),oss(Alibaba Cloud OSS),s3(AWS S3), orgcs(GCP Cloud Storage). All four feed the same audit log browser. - Client creation — whether direct creation is enabled in the UI, or whether the Create button is replaced by a link to an external request form
- Port forwarding — optional TCP port-forward rules from the VPN VM to
internal
<ip>:<port>destinations - HTTPS — whether to enable it (requires a domain name pointed at the VM)
Everything is optional except the password. If you skip the map view those
features are simply disabled — nothing else is affected. The log archive
backend always has a working default (local).
If you enable HTTPS:
setup.shuses Let's Encrypt to issue the certificate. Let's Encrypt validates domain ownership by making an HTTP request to your VM on port 80. Open port 80 in your firewall or cloud security group before runningsetup.sh, then close it again once the certificate has been issued. Port 80 is only needed during initial issuance and renewals — not for normal UI operation.
When it finishes, you will see a message telling you to run deploy.sh from
your local machine. Exit the SSH session.
If you prefer non-interactive setup, use
sudo ./host/setup.sh install --helpto see all available flags.
Back on your local machine, inside the cloned repository:
./host/deploy.sh --vm-ip YOUR_VM_IPIf your VM uses a non-default SSH key:
./host/deploy.sh --vm-ip YOUR_VM_IP --vm-ssh-key ~/.ssh/your_keyThis will:
- Build the Docker image for
linux/amd64 - Compress and upload it to the VM
- Load it and start the container
The first build takes a few minutes. Subsequent deploys are the same process —
just run deploy.sh again after making changes.
Before navigating to the UI, make sure the relevant port is open on the VM.
If using a cloud provider security group (Alibaba Cloud, AWS, GCP, etc.), add an inbound rule for the port in your console.
If using ufw on the VM:
# HTTP only
sudo ufw allow 8080/tcp
# HTTPS only (if you configured a domain in Step 3)
sudo ufw allow 8443/tcpOpen only one — whichever matches your setup:
| Setup | Open | Keep closed |
|---|---|---|
| HTTP only | 8080 | 8443 |
| HTTPS | 8443 | 8080 |
Note: The container also binds an internal port (8088) used by the Beego framework. Never open this externally.
Then navigate to:
- HTTP:
http://YOUR_VM_IP:8080 - HTTPS:
https://YOUR_DOMAIN:8443
Log in with the admin credentials you set in Step 3.
To verify the container is running:
ssh root@YOUR_VM_IP 'sudo docker logs openvpn-ui --tail 20'Located at /opt/openvpn-ui/conf/app.conf on the VM. Written by setup.sh —
you can edit it manually and restart the container to apply changes.
AppName = openvpn-ui
HttpPort = 8080
RunMode = prod
EnableGzip = true
EnableAdmin = true
SessionOn = true
CopyRequestBody = true
DbPath = "./db/data.db"
AuthType = "password"
EasyRsaPath = "/usr/share/easy-rsa"
OpenVpnPath = "/etc/openvpn"
OpenVpnManagementAddress = "127.0.0.1:2080"
OpenVpnManagementNetwork = "tcp"
GeoipDbPath = /usr/share/GeoIP/GeoLite2-City.mmdb
OVClientsDir = "/root"
StorageProvider = local
LocalLogDir = /var/log/openvpn-ui/archives
OSSLogBucket =
OSSEndpoint = oss-me-central-1.aliyuncs.com
S3LogBucket =
S3Region =
GCSLogBucket =
GCSProjectID =
GCSServiceAccountKeyFile =
MetricsAuthToken =
MetricsCacheSeconds = 5
DisconnectsWindowH = 24
MetricsHashClientNames = falseKey settings:
GeoipDbPath— leave empty to disable the map view and geo lookup in audit logsStorageProvider—local,oss,s3, orgcs. Selects where rotated.log.gzarchives are written and where the audit log browser reads from. If unset andOSSLogBucketis non-empty, defaults toossfor backwards compatibility; otherwise defaults tolocal.LocalLogDir— archive directory used by thelocalbackendOSSLogBucket/OSSEndpoint— used by theossbackendS3LogBucket/S3Region— used by thes3backend. Credentials are read from the standard AWS chain (/root/.aws/credentialson the VM, mounted read-only into the container).GCSLogBucket/GCSServiceAccountKeyFile— used by thegcsbackend. The key file is mounted read-only into the container at the same path.GCSProjectIDis informational (the Go SDK derives the project from the service-account JSON).OpenVpnManagementAddress— must match themanagementline inserver.confMetricsAuthToken— empty disables the monitoring API (default-deny: the whole/api/v1/metrics/namespace returns 404). Set to a 64-character hex string to enable; clients then authenticate withAuthorization: Bearer …. Usesetup.sh --generate-metrics-tokento mint one.MetricsCacheSeconds— in-process cache window for/summaryand/clients(the only endpoints that hit the OpenVPN management socket). 0 disables.DisconnectsWindowH— default lookback for/disconnects(overridable per request with?window=Nh).MetricsHashClientNames— whentrue, CNs in metrics responses (including Prometheus labels) are replaced with an 8-byte SHA-256 prefix.
Optional TLS settings (added by setup.sh when a domain is configured):
EnableHTTPS = true
HTTPSPort = 8443
HTTPSCertFile = /etc/letsencrypt/live/vpn.example.com/fullchain.pem
HTTPSKeyFile = /etc/letsencrypt/live/vpn.example.com/privkey.pemLocated at /opt/openvpn-ui/docker-compose.yml. Written by setup.sh.
Angristan's OpenVPN config lives under /etc/openvpn/server/, which is
bind-mounted into the container as /etc/openvpn/:
services:
openvpn-ui:
image: openvpn-ui-local:latest
container_name: openvpn-ui
environment:
- OPENVPN_ADMIN_USERNAME=admin
- OPENVPN_ADMIN_PASSWORD=yourpassword
network_mode: host
volumes:
- /etc/openvpn/server:/etc/openvpn
- /etc/openvpn/server/easy-rsa:/usr/share/easy-rsa
- /opt/openvpn-ui/db:/opt/openvpn-ui/db
- /opt/openvpn-ui/conf:/opt/openvpn-ui/conf
- /opt/scripts:/opt/scripts
- /root:/root
# Storage backend (exactly one is mounted, chosen by StorageProvider):
- /var/log/openvpn-ui/archives:/var/log/openvpn-ui/archives # local
- /root/.ossutilconfig:/root/.ossutilconfig:ro # oss
- /root/.aws:/root/.aws:ro # s3
- /etc/openvpn-ui/gcs-sa-key.json:/etc/openvpn-ui/gcs-sa-key.json:ro # gcs
- /usr/share/GeoIP:/usr/share/GeoIP:ro # if map view enabled
restart: alwaysThe RAM user supplied to setup.sh needs the following policy attached,
scoped to your bucket. Create it in the Alibaba Cloud console under
RAM → Policies → Create Policy (JSON tab), then attach it to the RAM user.
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": [
"oss:ListObjects"
],
"Resource": [
"acs:oss:*:*:your-bucket-name"
]
},
{
"Effect": "Allow",
"Action": [
"oss:PutObject",
"oss:GetObject",
"oss:DeleteObject"
],
"Resource": [
"acs:oss:*:*:your-bucket-name/*"
]
}
]
}Replace your-bucket-name with your actual bucket name.
| Permission | Used by |
|---|---|
oss:ListObjects |
Log browser — lists available archives |
oss:GetObject |
Log browser — downloads an archive to parse it |
oss:PutObject |
Log rotation cron — uploads compressed log archives |
oss:DeleteObject |
OSS SDK smoke test (go run ./cmd/osstest) |
oss:DeleteObject is only needed if you run the smoke test. It can be omitted
from the production policy if preferred.
The IAM user/role supplied to setup.sh needs the following policy attached,
scoped to your bucket. A ready-to-paste version with placeholders is shipped at
host/aws-s3-iam-policy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "OpenvpnUiBucketScopedListing",
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": "arn:aws:s3:::YOUR_BUCKET_NAME"
},
{
"Sid": "OpenvpnUiObjectIO",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*"
}
]
}Replace YOUR_BUCKET_NAME with your actual bucket name.
| Permission | Used by |
|---|---|
s3:ListBucket |
Log browser — lists available archives |
s3:GetBucketLocation |
AWS SDK — region discovery |
s3:GetObject |
Log browser — downloads an archive to parse it |
s3:PutObject |
Log rotation cron — uploads compressed log archives |
s3:DeleteObject |
Only needed if you keep a smoke-test command around |
s3:DeleteObject can be omitted from the production policy if you don't run
a delete-style smoke test.
The service account whose JSON key file is supplied to setup.sh needs
object-level access on the bucket. The shipped role definition at
host/gcs-bucket-role.yaml defines a custom role with exactly the
permissions needed. The simpler shortcut is to grant the predefined role
roles/storage.objectAdmin to the service account, scoped to the bucket
(not project-wide).
Custom role (recommended):
gcloud iam roles create openvpnUiLogArchive \
--project=YOUR_GCP_PROJECT_ID \
--file=host/gcs-bucket-role.yaml
gcloud storage buckets add-iam-policy-binding gs://YOUR_BUCKET_NAME \
--member=serviceAccount:openvpn-ui@YOUR_GCP_PROJECT_ID.iam.gserviceaccount.com \
--role=projects/YOUR_GCP_PROJECT_ID/roles/openvpnUiLogArchivePermissions included:
| Permission | Used by |
|---|---|
storage.objects.list |
Log browser — lists available archives |
storage.objects.get |
Log browser — downloads an archive to parse it |
storage.objects.create |
Log rotation cron — uploads compressed log archives |
storage.objects.delete |
Only needed for a delete-style smoke test |
/root/.ossutilconfig on the VM (written by setup.sh, mode 600).
Read by both the container (for the audit log browser) and the host cron job
(for log uploads):
[Credentials]
language=EN
accessKeyID=YOUR_ACCESS_KEY_ID
accessKeySecret=YOUR_ACCESS_KEY_SECRET
endpoint=oss-me-central-1.aliyuncs.comsetup.sh installs these automatically.
/etc/cron.d/openvpn-logs:
* * * * * root /opt/scripts/ovpn-log-collect.sh
*/5 * * * * root /opt/scripts/ovpn-log-rotate.sh
59 23 * * * root /opt/scripts/ovpn-log-rotate.sh --eod
59 23 * * * root [ -f /run/openvpn-restart-pending ] && rm -f /run/openvpn-restart-pending && systemctl restart openvpn-server@server
Per-user crontab (for the Logs page):
* * * * * journalctl -n 300 -xeu openvpn-server@server.service --no-pager > /opt/scripts/ovpn-logs.txt 2>&1
The last cron entry in /etc/cron.d/openvpn-logs runs a pending OpenVPN
restart at 23:59 if a certificate was revoked during the day. This deferred
approach avoids disconnecting other active sessions mid-day.
Audit log browser — no events found
If an archive shows "no events found", verify the log was collected with
journalctl -o short-iso. The parser expects lines in this format:
2026-04-15T14:49:28+0800 hostname openvpn[pid]: message
Recently disconnected map markers
The map reads /opt/scripts/ovpn-master.log for sessions that ended within
the last 4 hours. If the master log is empty after a rotation, restore the
most recent archive to repopulate it:
ossutil cp oss://your-bucket/openvpn-logs-YYYY-MM-DD-HHmmss.log.gz /tmp/r.log.gz \
--endpoint oss-me-central-1.aliyuncs.com -f
zcat /tmp/r.log.gz > /opt/scripts/ovpn-master.logOSS SDK smoke test
cmd/osstest/main.go verifies upload, list, download, and delete against
your real bucket using credentials from /root/.ossutilconfig:
go run ./cmd/osstestPort forwarding
You can configure rules either during initial VM setup or at any later point.
During setup — pass --port-forward <listen-port>:<dest-ip>:<dest-port> to
setup.sh install (repeatable), or answer the port-forwarding question when
running setup.sh init:
sudo ./host/setup.sh install --admin-password ... \
--port-forward 8443:10.0.1.5:443 \
--port-forward 9090:10.0.1.6:9090After setup — run the management script directly on the VM:
sudo /opt/scripts/port-forward.sh add 8443 10.0.1.5 443
sudo /opt/scripts/port-forward.sh list
sudo /opt/scripts/port-forward.sh remove 8443…or use the web UI at Configuration → Port forwarding for the same operations
in the browser. The web UI shells out to port-forward.sh so the script remains
the single source of truth — adding a rule via the UI and listing it on the VM
will always agree.
The iptables NAT rules persist across reboots via iptables-persistent, and
IPv4 forwarding is persisted in /etc/sysctl.d/99-openvpn-forward.conf. To make
the web UI able to mutate iptables, the openvpn-ui container is granted
cap_add: NET_ADMIN and bind-mounts /etc/iptables and /etc/sysctl.d from
the host (configured automatically by setup.sh). The container does not
run privileged.
Cloud security group: these rules act on packets after they reach the VM, so they only matter once the listen port is allowed in. Open the listen port in your cloud provider's security group / firewall before adding a port-forward rule.
Reserved listen ports in the UI: the web form refuses listen ports below 2000 (well-known/system range), the SSH port (22), the openvpn-ui ports (8080, 8443), and the OpenVPN server port. The CLI is intentionally permissive — these guardrails apply only when adding a rule from the browser.
A read-only HTTP API on the openvpn-ui app that publishes live VPN telemetry
for a centralized monitoring system. Same :8443 HTTPS listener as the UI,
under /api/v1/metrics/.
Default-deny. With MetricsAuthToken = "" in app.conf the whole
namespace returns 404 Not Found so the surface is invisible until you opt in.
Once a token is configured, requests must present
Authorization: Bearer <token>. The session cookie that authenticates the UI
does not grant access to this namespace.
Enable it during install:
# Auto-generate a token (printed in the post-install summary):
sudo ./host/setup.sh install ... --generate-metrics-token
# Or supply your own:
sudo ./host/setup.sh install ... --metrics-token "$(openssl rand -hex 32)"| Endpoint | Returns |
|---|---|
GET /api/v1/metrics/summary |
Scalar counts: n_connected, n_recent_disconnects, n_port_forwards, cert counts |
GET /api/v1/metrics/clients |
Connected clients with CN, real IP (geo-enriched if MaxMind set), virtual IP, bytes, connected_since |
GET /api/v1/metrics/disconnects |
Sessions disconnected within ?window=Nh (default DisconnectsWindowH) |
GET /api/v1/metrics/portforwards |
Current NAT rules — proxied from port-forward.sh list --format json |
GET /api/v1/metrics/certificates |
{active, revoked, expiring_30d, expired, by_cn: [...]} |
GET /api/v1/metrics/prometheus |
Same data as Prometheus text exposition (text/plain; version=0.0.4) |
JSON endpoints wrap their payload as {"generated_at": "<RFC3339>", "data": …}
so consumers can detect cache staleness.
scrape_configs:
- job_name: openvpn
scheme: https
metrics_path: /api/v1/metrics/prometheus
authorization:
type: Bearer
credentials: <token from setup.sh output>
static_configs:
- targets: ["vpn.example.com:8443"]Recommend pairing with cloud security-group / firewall rules so only your
central monitor's IP can reach :8443. See MetricsAuthToken,
MetricsCacheSeconds, DisconnectsWindowH, and MetricsHashClientNames
in the conf/app.conf reference for tuning knobs.
Cloud provider startup script (user-data) support
Cloud providers (Alibaba Cloud ECS, AWS EC2, GCP Compute Engine) all support
passing an initialization script that runs automatically on the instance's first
boot via user-data / cloud-init. The plan is to produce a version of host/setup.sh
that can be embedded directly as a startup script, so the VM configures itself
on first boot with no SSH step required. The Docker image would still be built
locally and transferred after the instance is ready, keeping the RAM constraint
in check. This would allow fully hands-off provisioning: spin up the VM, wait
for it to signal readiness, then run deploy.sh.
# For the host architecture (development and testing):
go build ./...
# For the VM (linux/amd64) — done automatically by deploy.sh:
docker build --platform linux/amd64 -t openvpn-ui-local:latest .Dependencies are managed with Go modules. The vendor/ directory is excluded
from git — Docker recreates it from go.mod and go.sum at build time.