OpenVPN sidecar for DataGate Monitor. In the monorepo this is the openvpn/ submodule (DataGateMonitor). Standalone clone: DataGateOpenVpnManager.
Links
| Resource | Link |
|---|---|
| datagateapp.com | |
| datagateapp.com/download | |
| @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.
git clone https://github.com/IMKolganov/DataGateOpenVpnManager.git
cd DataGateOpenVpnManagerdocker build --build-arg TARGETARCH=$(uname -m) -t datagate-openvpn .You must pass TARGETARCH. Common values:
x64oramd64arm64
Optional:
docker build --build-arg TARGETARCH=amd64 --build-arg BUILD_CONFIGURATION=Debug -t datagate-openvpn .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-openvpnEnvironment 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.
Do not rely on tunN names in UFW — they change on every OpenVPN recreate. Use:
- Image ≥ 1.2.5.94 with
TUN_DEV=ovpn-udp(unique per stack) — entrypoint writesdev ovpn-udp/dev-type tun, deletes orphans that still hold this subnet’s.1, and installs FORWARD/NAT by CIDR. - 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- 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.
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:
- Pin TCP:
TUN_DEV: tun-tcpandTUN_IF: tun-tcp(do not turn DCO off if it already worked). - 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- Pi-hole FTL v6:
dns.interfaceis one name.BIND+tun-tcp,ovpn-udp,br-…(comma list) or a customentrypointwrappingstart.shcan leave:53open with no answers (Recv-Qgrowing, nolistening on … port 53in FTL log). Working pattern:
# pi-hole compose — no custom entrypoint
FTLCONF_dns_listeningMode: 'BIND'
FTLCONF_dns_interface: 'tun-tcp' # TCP .1 onlyExtra 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.1network_mode: container:openvpn-tcp-wssstores the container ID. Recreate OpenVPN → Pi-hole staysExited(No such container: <old id>). Recreate Pi-hole after TCP is Up. Optional systemd oneshot (After=docker.service) that waits foropenvpn-tcp-wss+openvpn-udp-wssthendocker compose up -d --force-recreatein~/pi-hole.- 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"| 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 |
- Copies and initializes Easy-RSA in
$DATA_DIR/easy-rsa - Generates CA, server cert/key, and
ta.keyif missing - Creates
server.confif missing - Configures
iptablesto allow VPN traffic - Runs OpenVPN and .NET app in parallel
Log files in $DATA_DIR:
openvpn.logopenvpn-status.logpihole-query-cursor.txt— last Pi-hole query log cursor (pre-created by entrypoint, mode644)pihole-runtime-config.json— Pi-hole collector config from dashboard Save & apply (survives container restart; mode600, 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.
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~OpenVpnProxyUdpDeliveryTestsIssue 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--serviceaddspull-filter ignore "redirect-gateway" | "dhcp-option" | "block-outside-dns": only VPN-internal traffic uses the tunnel.revoke/getgo by serial number, not CN (easy-rsa allows the same CN several times inindex.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: hostevery process reaches 127.0.0.1 (and withOPENVPN_WSS_UDP_PROXY=rustall 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 viaGET /api/node-identitywith its JWT; if the node is unreachable (NAT), trust the pending key viaPOST /api/open-vpn-servers/{id}/node-key/trust {"fingerprint": "..."}after comparing withnode-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.
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.
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~ServiceClientLookupDockerIntegrationTestsSet 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- Requires
--cap-add=NET_ADMINto allow iptables setup - Container exposes OpenVPN on configured port/protocol
- Designed to be used with external volume (
-v) for persistence
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/"Created by Ivan Kolganov based on Kyle Manna’s OpenVPN concepts.
| Contact | Link |
|---|---|
| IMKolganov/DataGateOpenVpnManager | |
| linkedin.com/in/imkolganov | |
| @KolganovIvan | |
| buymeacoffee.com/imkolganov |
MIT License .