Skip to content

About

DataGateOpenVpnManager is a Dockerized OpenVPN server with a built-in .NET management API and Easy-RSA integration. It automates PKI initialization, certificate management, OpenVPN configuration, and VPN lifecycle operations while running everything inside a single container.

Resources

Stars

2 stars

Watchers

1 watching

Forks

Repository files navigation

DataGateOpenVpnManager + OpenVPN Docker Image

OpenVPN sidecar for DataGate Monitor. In the monorepo this is the openvpn/ submodule (DataGateMonitor). Standalone clone: DataGateOpenVpnManager.

Links

Resource Link
DataGate datagateapp.com
Download datagateapp.com/download
Telegram channel @datagateapp

This project builds a Docker image containing:

  • A .NET 9 application: DataGateOpenVpnManager
  • A fully functional OpenVPN server
  • Integrated Easy-RSA certificate management

It uses a custom entrypoint to initialize PKI, generate default configuration, and run both OpenVPN and the .NET app in the same container.


📥 Clone the Repository

git clone https://github.com/IMKolganov/DataGateOpenVpnManager.git
cd DataGateOpenVpnManager

🛠 Build

docker build --build-arg TARGETARCH=$(uname -m) -t datagate-openvpn .

You must pass TARGETARCH. Common values:

  • x64 or amd64
  • arm64

Optional:

docker build --build-arg TARGETARCH=amd64 --build-arg BUILD_CONFIGURATION=Debug -t datagate-openvpn .

🚀 Run

docker run \
  --cap-add=NET_ADMIN \
  -e PORT=1194 \
  -e PROTO=udp \
  -e DATA_DIR=/mnt \
  -v /path/to/data:/mnt \
  -p 1194:1194/udp \
  datagate-openvpn

Environment variables:

Variable Description Default
PORT OpenVPN port 1194
PROTO Protocol (udp or tcp) udp
DATA_DIR Data directory for config/logs/pki /mnt
DNS1, DNS2 Pushed DNS servers 8.8.8.8, 8.8.4.4
CIPHER Data-channel cipher; rewritten into issued .ovpn and /api/info AES-128-GCM if DCO, else AES-256-CBC
DATA_CIPHERS OpenVPN data-ciphers list; rewritten into issued .ovpn / /api/info GCM/ChaCha list if DCO, else unset
DCO Enable OpenVPN DCO (true/1/yes); exposed on /api/info false
AUTH HMAC digest (auth); server.conf + issued .ovpn / /api/info SHA256
TLS_VERSION_MIN tls-version-min on server and issued .ovpn / /api/info 1.2
CLIENT_VERB Suggested client .ovpn verb (not server log verb) / /api/info 3
MSSFIX Optional push "mssfix N" to clients; exposed on /api/info (not duplicated in .ovpn) (unset)
TUN_DEV Fixed tun device name (stable ufw/NAT) _(unset → dev tun)
TUN_IF Extra FORWARD by iface (optional) (unset)
WAN_IF WAN iface for MASQUERADE eth0 (auto if missing)
VPN_SUBNET, VPN_NETMASK VPN subnet config 10.51.28.0/24
OpenVpnManagement__Port OpenVPN management interface port 5092
OpenVpnProxy__ByteDebug Compare proxy vs management bytes (WSS debug) false
OpenVpnProxy__ByteDebugIntervalSeconds Periodic byte comparison while connected (0 = on disconnect only) 0
OpenVpnProxy__CloseZombieAfterMissingSeconds Close WSS when OpenVPN peer missing from management (0 = off) 0
OpenVpnProxy__ZombieCheckIntervalSeconds How often to check for zombie proxy sessions 30
OpenVpnProxy__TlsLogEnrichmentEnabled Tag tls-crypt errors as external probe vs app client (for Wazuh) true
PROXY_BYTE_DEBUG Legacy alias for OpenVpnProxy__ByteDebug (1 / true) (unset)
PiHole__Enabled Collect VPN DNS queries from Pi-hole API false
PiHole__BaseUrl Pi-hole FTL API base URL (same network namespace) http://127.0.0.1:8080
PiHole__AppPassword Pi-hole application password for /api/auth (empty)
PiHole__PollIntervalSeconds How often to poll Pi-hole query log 60
PiHole__BatchSize Max queries per poll 200
PIHOLE_ENABLED Legacy env alias for PiHole__Enabled (unset)
PIHOLE_BASE_URL Legacy env alias for PiHole__BaseUrl (unset)
PIHOLE_APP_PASSWORD Legacy env alias for PiHole__AppPassword (unset)
PIHOLE_POLL_INTERVAL_SEC Legacy env alias for PiHole__PollIntervalSeconds (unset)

Pi-hole config priority (highest wins): PIHOLE_* / PiHole__* env vars → dashboard Save & apply ($DATA_DIR/pihole-runtime-config.json) → appsettings defaults. Env overrides only the fields that are set.

Multi-stack host (fixed tun + UFW by subnet)

Do not rely on tunN names in UFW — they change on every OpenVPN recreate. Use:

  1. Image ≥ 1.2.5.94 with TUN_DEV=ovpn-udp (unique per stack) — entrypoint writes dev ovpn-udp / dev-type tun, deletes orphans that still hold this subnet’s .1, and installs FORWARD/NAT by CIDR.
  2. Host UFW once per subnet (survives reboot and tun renames):
sudo chmod +x scripts/host-ufw-vpn-subnet.sh
# UDP pool → internet + DNS via Pi-hole on TCP tun (10.51.30.1):
sudo ./scripts/host-ufw-vpn-subnet.sh 10.51.32.0/24 10.51.30.1
  1. Compose env for UDP WSS:
TUN_DEV: ovpn-udp
VPN_SUBNET: "10.51.32.0"
DNS1: "10.51.30.1"
DNS2: "10.51.30.1"
DCO: "true"
CIPHER: "AES-128-GCM"

Keep DNS push on Pi-hole (10.51.30.1 or this stack’s own .1), not public 1.1.1.1.

TCP stack + shared Pi-hole (host netns)

Same host usually has one Pi-hole in network_mode: "container:openvpn-tcp-wss" (host netns). OpenVPN TCP uses dev tun unless TUN_DEV is set — after reboot/recreate the kernel picks tun0/tun1, while UFW still allows 53 on the old name. Symptom: dashboard Pi-hole pipeline green (HTTP :8080), VPN connected, no internet (block-outside-dns + DNS timeout). UDP often still works if its iface name is stable (TUN_DEV=ovpn-udp) or UFW allows that subnet → Pi-hole .1.

Do this:

  1. Pin TCP: TUN_DEV: tun-tcp and TUN_IF: tun-tcp (do not turn DCO off if it already worked).
  2. Prefer UFW by destination, not iface name (survives tun0 → tun-tcp):
sudo ufw allow in on tun-tcp proto udp to any port 53 comment 'Pi-hole DNS tun-tcp'
sudo ufw allow in on tun-tcp proto tcp to any port 53 comment 'Pi-hole DNS tun-tcp'
# even better, already used on some hosts:
sudo ufw allow from 10.51.15.0/24 to 10.51.15.1 port 53
sudo ufw allow from 10.51.16.0/24 to 10.51.16.1 port 53
  1. Pi-hole FTL v6: dns.interface is one name. BIND + tun-tcp,ovpn-udp,br-… (comma list) or a custom entrypoint wrapping start.sh can leave :53 open with no answers (Recv-Q growing, no listening on … port 53 in FTL log). Working pattern:
# pi-hole compose — no custom entrypoint
FTLCONF_dns_listeningMode: 'BIND'
FTLCONF_dns_interface: 'tun-tcp'   # TCP .1 only

Extra listen IPs (UDP .1, docker-bridge for Xray) via /etc/dnsmasq.d/ after FTL is healthy:

printf '%s\n' 'listen-address=10.51.16.1' 'listen-address=172.20.0.1' \
  > ~/pi-hole/etc-dnsmasq.d/99-extra-listen.conf
docker exec datagate-pihole pihole-FTL --config misc.etc_dnsmasq_d true
docker restart datagate-pihole
# log must show: listening on 10.51.16.1 and 172.20.0.1
  1. network_mode: container:openvpn-tcp-wss stores the container ID. Recreate OpenVPN → Pi-hole stays Exited (No such container: <old id>). Recreate Pi-hole after TCP is Up. Optional systemd oneshot (After=docker.service) that waits for openvpn-tcp-wss + openvpn-udp-wss then docker compose up -d --force-recreate in ~/pi-hole.
  2. Host reboot does not fix a wedged FTL. Check docker logs datagate-pihole | grep 'listening on'. Dashboard step 4 (API :8080) ≠ DNS :53.

Compose env for TCP WSS:

TUN_DEV: tun-tcp
TUN_IF: tun-tcp
VPN_SUBNET: "10.51.15.0"   # example
DNS1: "10.51.15.1"
DNS2: "10.51.15.1"

📁 Directory Layout Inside Container

Path Purpose
/etc/openvpn Final OpenVPN config/cert destination
/usr/share/easy-rsa EasyRSA installation
$DATA_DIR (default /mnt) Mounted volume for logs, config, PKI

📦 What This Does

  1. Copies and initializes Easy-RSA in $DATA_DIR/easy-rsa
  2. Generates CA, server cert/key, and ta.key if missing
  3. Creates server.conf if missing
  4. Configures iptables to allow VPN traffic
  5. Runs OpenVPN and .NET app in parallel

📝 Logs

Log files in $DATA_DIR:

  • openvpn.log
  • openvpn-status.log
  • pihole-query-cursor.txt — last Pi-hole query log cursor (pre-created by entrypoint, mode 644)
  • pihole-runtime-config.json — Pi-hole collector config from dashboard Save & apply (survives container restart; mode 600, re-applied on each container start)

Raw tls-crypt unwrapping failed lines from internet scanners are filtered out of Docker stdout; the .NET app re-emits them with an origin tag:

Log prefix Meaning Suggested Wazuh level
[OpenVpnTlsExternalProbe] Direct probe to OpenVPN port (not our WSS app) ignore / low
[OpenVpnTlsAppClient] WSS proxy path (127.0.0.1:localPort) with clientRef / User-Agent alert
[OpenVpnTlsLocalUnknown] Loopback without matched proxy session investigate

Wazuh rule 100913 should match [OpenVpnTlsAppClient] instead of the raw TLS Error: tls-crypt string.


UDP WSS delivery

The .NET UDP→WebSocket proxy groups already queued datagrams into messages with a 48 KiB batching target. A lone datagram is sent immediately, without waiting for another packet. The four-message send queue waits for space when WebSocket sends are slow, instead of dropping new messages. Queued buffers are returned to the pool when a session is cancelled or a send fails.

The wire format remains [u16_be length][datagram]...; existing clients already decode multiple datagrams per WebSocket message. Socket buffers still request 4 MiB, so the host/network namespace must allow that size via net.core.rmem_max and net.core.wmem_max. Finite kernel buffers can still overflow if an upstream sender continuously exceeds the client's capacity.

Delivery regression tests use real loopback UDP sockets and a controlled WebSocket sender to check bursts, batching, isolated packets, cancellation and send failures:

dotnet test DataGateOpenVpnManager.Tests --filter FullyQualifiedName~OpenVpnProxyUdpDeliveryTests

🔐 Local profile issuance (service profiles)

Issue profiles on the node itself (no dashboard request) — e.g. a home hub handing service profiles to servers that must reach a host inside the VPN at boot. The node reports every local issue/revoke to the backend, so the dashboard lists them (labels Issued locally / Service) and can revoke them.

All of it is off by default; a node without these variables renders the same server.conf as before.

Variable Default Meaning
LOCAL_ISSUANCE_ENABLED false Admin socket, node key, event outbox + delivery to the backend.
LOCAL_ISSUANCE_DIR $DATA_DIR/local-issuance Private dir (0700): admin.sock, node-identity.key, outbox/, profiles.json, optional client-template.ovpn.
docker exec openvpn-udp datagate-profile create upcloud-db1 --service > upcloud-db1.ovpn
docker exec openvpn-udp datagate-profile list
docker exec openvpn-udp datagate-profile revoke upcloud-db1
docker exec openvpn-udp datagate-profile node-id     # key fingerprint to compare in the dashboard
  • --service adds pull-filter ignore "redirect-gateway" | "dhcp-option" | "block-outside-dns": only VPN-internal traffic uses the tunnel.
  • revoke/get go by serial number, not CN (easy-rsa allows the same CN several times in index.txt): a profile already revoked from the dashboard is only recorded, and if the CN was re-issued since, the newer certificate is never touched.
  • Channel: a Unix socket in the 0700 root-owned dir — not loopback, because with network_mode: host every process reaches 127.0.0.1 (and with OPENVPN_WSS_UDP_PROXY=rust all public traffic reaches Kestrel from loopback). /api/local-admin/* answers 404 on the TCP port, with or without a JWT.
  • Backend trust: the node signs events with its own ECDSA P-256 key (node-identity.key, never leaves the node). The public key is sent with the discovery announce and becomes trusted when an admin approves the node (the review page shows its fingerprint). For a node registered earlier, the backend fetches the key via GET /api/node-identity with its JWT; if the node is unreachable (NAT), trust the pending key via POST /api/open-vpn-servers/{id}/node-key/trust {"fingerprint": "..."} after comparing with node-id.
  • Delivery: events are fsync'ed to outbox/ and retried with backoff until the backend acknowledges them; container restarts lose nothing, and the backend is idempotent by certificate serial.

Service hub: VPN address to certificate CN

Enable SERVICE_CLIENT_LOOKUP=true only on the service hub, sharing its Docker network namespace with tang-gate (for example the gateway uses network_mode: service:openvpn). Leave it unset on ordinary nodes, especially network_mode: host nodes. It creates a separate HTTP listener at 127.0.0.1:5012 (SERVICE_CLIENT_LOOKUP_PORT overrides the port); do not publish/proxy this port. No JWT is required. The public API listener always returns 404 for this route, including requests proxied by local nginx.

GET http://127.0.0.1:5012/api/local/service-clients/by-virtual-ip/10.50.28.2 returns 200 with {commonName, serial, virtualAddress, realAddress, connectedSince} (UTC ISO time), or 404. Only active IsServiceProfile records in the local registry qualify; user profiles never appear. No client-list endpoint is exposed. The lookup only reads management, registry, index and CRL.

Every lookup awaits a fresh local management status 3, then checks the registry, index and CRL. It does not use openvpn-status.log or add/accelerate a background poll. Existing management refresh settings (OpenVpnProxy__ManagementStatusRefreshSeconds) and the backend's synchronization interval stay unchanged. The lookup sends no status snapshots to the backend and adds no database records.

After OpenVPN removes a session, the next successful lookup returns 404 without a stale-cache grace period. OpenVPN must detect/process the disconnect first: abrupt network loss follows its existing keepalive settings (keepalive 15 120 by default). CRL/index revocation is checked on every request, including when the VPN session is still present. A session predating a reissued CN and ambiguous certificate identities are hidden. Lookup work has a 2-second timeout; this is a limit, not a measured latency. Unavailable management, stale/invalid status or unreadable PKI/CRL produce 404.

Measured on 2026-10-29 using an isolated local OpenVPN 2.7.6 hub with two connected clients (service and user): 30 successful HTTP lookups had median 43.4 ms, p95 44.4 ms, range 42.5–49.5 ms; the first lookup took 18.2 ms. The first lookup after server-side disconnect took 43.3 ms (404), after revoke 42.4 ms (404), and with only the CRL marking the still-connected certificate revoked 43.2 ms (404). These are curl time_total measurements inside the hub, excluding Docker CLI startup. The disconnect test waited for OpenVPN's client-removal event; stopping the test client and observing removal took 5.2 seconds including Docker stop/inspection overhead. These measurements describe this local fixture, not a production SLA.

Existing outbox events containing the retired StaticIp field remain deliverable: the backend ignores unknown event fields. The applied issuance migration is preserved; a subsequent migration drops the column.

Reproducing the integration checks

The Docker check is part of DataGateOpenVpnManager.Tests and is skipped unless DATAGATE_LOOKUP_TEST_IMAGE names an image built from the current checkout. It requires Linux, Docker and /dev/net/tun. It starts fresh nodes and real VPN clients on an internal Docker network, uses a disposable auth-response stub instead of a real backend, and removes its containers, keys and network. It checks the actual configuration with lookup absent/enabled, the private port, shared/separate network namespaces, service/user filtering, disconnect, revoke and CRL-only revoke with live VPN sessions. It also removes ta.key temporarily and verifies that lookup neither needs nor regenerates that key.

docker build -t datagate-lookup-test .
DATAGATE_LOOKUP_TEST_IMAGE=datagate-lookup-test \
  dotnet test DataGateOpenVpnManager.Tests/DataGateOpenVpnManager.Tests.csproj \
  --filter FullyQualifiedName~ServiceClientLookupDockerIntegrationTests

Set DATAGATE_LOOKUP_TEST_REPORT to a local JSON file path to save timing results. No background interval override is needed for this test because each lookup requests current management data.

The backend's RemoveLocalProfileStaticIpPostgresTests is enabled by DATAGATE_MIGRATION_TEST_PG_CONNECTION, pointing to a disposable PostgreSQL server with permission to create databases. It creates its own database, executes EF-generated upgrade, downgrade and upgrade SQL, checks that both profile records survive and the column changes as expected, then drops that database. This checks the incremental DropColumn against a representative existing table, not the full historical fresh-database migration chain. No existing dev or production database is migrated by this check.

DATAGATE_MIGRATION_TEST_PG_CONNECTION='Host=127.0.0.1;Port=55432;Database=postgres;Username=postgres' \
  dotnet test ../backend/DataGateMonitor.Tests/DataGateMonitor.Tests.csproj \
  --filter FullyQualifiedName~RemoveLocalProfileStaticIpPostgresTests

⚠ Notes

  • Requires --cap-add=NET_ADMIN to allow iptables setup
  • Container exposes OpenVPN on configured port/protocol
  • Designed to be used with external volume (-v) for persistence

🧪 Example docker-compose.yml

services:
  openvpn_udp:
    image: imkolganov/datagate-monitor-openvpn:latest
    pull_policy: always
    container_name: openvpn_udp
    restart: unless-stopped
    networks:
      - backend_network
    cap_add:
      - NET_ADMIN
    devices:
      - "/dev/net/tun:/dev/net/tun"
    volumes:
      - openvpn_data_udp:/openvpn-udp
    ports:
      - "1194:1194/udp"
      - "5010:5010"
    environment:
      EASY_RSA_PATH: "/openvpn-udp/easy-rsa"
      DATA_DIR: /openvpn-udp
      PORT: "1194"
      API_PORT: 5010
      PROTO: udp
      # OPENVPN_WSS_UDP_PROXY: rust   # optional: native UDP WSS datapath (default: dotnet)
      OpenVpnManagement__Port: "5092"
      OpenVpnManagement__Host: "localhost"
      BACKEND__BASEURL: "http://backend:5581/"

👤 Maintainer

Created by Ivan Kolganov based on Kyle Manna’s OpenVPN concepts.

Contact Link
Repository IMKolganov/DataGateOpenVpnManager
LinkedIn linkedin.com/in/imkolganov
Telegram @KolganovIvan
Buy Me a Coffee buymeacoffee.com/imkolganov

📄 License

MIT License .

About

DataGateOpenVpnManager is a Dockerized OpenVPN server with a built-in .NET management API and Easy-RSA integration. It automates PKI initialization, certificate management, OpenVPN configuration, and VPN lifecycle operations while running everything inside a single container.

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages