Changelog
All notable changes to TunGuard are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]⚓
[2.4.18] - 2026-10-14⚓
Added⚓
- A mapped domain can now be a public, login-free DNS resolver. Add a
mapping with
domain add dns.example.com --dnsand TunGuard serves AdGuard Home as DNS-over-HTTPS athttps://dns.example.com/dns-query(on the domain HTTPS port) and DNS-over-TLS ondns.example.com:853(DOMAIN_DOT_PORT), so phones can point their Private DNS at it. Only the/dns-querypath is proxied to AdGuard; the root shows a TunGuard landing page and every other path returns 404, so AdGuard's admin UI is never exposed through the mapping. Works in both built-in mode and when nginx, Apache, Caddy or Traefik is detected. Requires AdGuard Home to be enabled. - The dashboard signs in with a real HTML page instead of the browser's Basic
auth popup. A successful sign-in gets a 12-hour
HttpOnly,SameSite=Laxsession cookie, and changing the login rotates every session. On a server still on the shippedadmin/tanguardlogin, signing in with the defaults forces the "set your dashboard login" step before the dashboard opens. HTTP Basic auth and API keys keep working for scripts, the SSH gateway and curl.
Changed⚓
- The dashboard login page no longer triggers the native HTTP Basic challenge
(no
WWW-Authenticate), and the dashboard login / key endpoints answer 401 with a JSON body rather than a Basic challenge.
[2.4.17] - 2026-10-11⚓
Added⚓
- AdGuard now protects a phone the moment it is moved into an AdGuard group —
no re-import needed. Previously the filter only applied when a client
profile was generated with AdGuard as its DNS server, so a group move did not
change the DNS an already-installed phone used and ads kept showing (and
moving back out kept the filtered DNS). The server now intercepts the DNS of
peers whose policy group allows AdGuard: a
TUNGARD_DNSiptables chain in the nat table redirects each such peer's port-53 queries (udp and tcp) to the AdGuard listener. Rules are rebuilt after any peer or policy group change, on AdGuard start/stop, after a restore, and at startup, and removed again on shutdown. One limitation: Android's "Private DNS" (DNS-over-HTTPS/TLS) bypasses port-53 interception.
[2.4.16] - 2026-10-11⚓
Fixed⚓
- AdGuard DNS is now reachable through the VPN. AdGuard was bound to
127.0.0.1:5353, and client WireGuard profiles were handed that same loopback address as theirDNS =value. On a phone127.0.0.1means the phone itself, so DNS queries never reached the server and filtering silently did nothing for any device with WireGuard access. The DNS listener now defaults to the server's tunnel address on port 53 (10.100.0.1:53, derived fromWG_ADDRESS), and the client profile is handed that bare tunnel address. - The DNS listener is re-applied on every start. Previously the AdGuard
config's DNS block was only written on first run, so an existing
AdGuardHome.yamlkept its old loopback listener. Host and port are now corrected on each start while the rest of the block (upstreams, blocklists, cache) is preserved. When the tunnel address is not present (mesh-only mode), it falls back to loopback so the filter still starts.
[2.4.9] - 2026-10-10⚓
Fixed⚓
- Detect the web server actually holding ports 80/443. Detection now keys off whoever owns the public ports first, then falls back to any running supported server. Previously it only scanned running processes and could pick a server that was not the one listening (e.g. nginx installed but idle while apache served), so the mapping was written to the wrong config.
- Manage config across server switches. When the active web server changes (apache → nginx, nginx → caddy, ...), the generated config of the previous server is removed — vhost files and sites-enabled symlinks, the Caddyfile import with its site file, or the traefik dynamic file — so stale routes are never left behind.
[2.4.11] - 2026-10-11⚓
Added⚓
- AdGuard Home integration. Optional AdGuard Home DNS filtering service.
Enable with
ADGUARD_ENABLED=true. TunGuard can download and manage AdGuard Home automatically, generates a basic config (DNS/listen/web UI), and exposes start/stop/status via API/UI. Policy groups gain an "AdGuard DNS filtering" toggle - when enabled, generated client WireGuard configs use AdGuard as the DNS server so clients receive filtered DNS. Defaults follow sensible values (ADGUARD_DNS_LISTEN=10.100.0.1:53) and do not affect existing TRP/domains behavior.
[2.4.10] - 2026-10-10⚓
Fixed⚓
- Don't emit SSL vhosts until the certificate exists. Nginx/Apache writers
now only write the
443/TLS blocks when the certificate is on disk. Writingssl_certificatefor missing files madenginx -t/apachectl configtestfail, which prevented the whole vhost (including the port-80 ACME challenge proxy) from loading. The port-80 block is always emitted so HTTP-01 validation can proceed and the cert is issued first. - Write nginx config where nginx actually includes it. The server detects
whether
/etc/nginx/nginx.confincludesconf.d/orsites-enabled/(Debian layout) and writes to the matching directory, creating symlinks intosites-enabledwhen needed. Stale configs are cleaned from both locations. - Filter bogus mesh device IDs. The mesh control plane now rejects
obviously junk device IDs (very long strings containing debug/HTTP headers like
version,systemtype,clienttype,user-agent) instead of creating ghost nodes. Bogus device IDs are also filtered from listings and pruned on startup.
[2.4.8] - 2026-10-10⚓
Changed⚓
- The domain proxy is now on by default. It no longer requires
DOMAIN_ENABLED=truejust to see the Domains page — a fresh install shows the feature live. It stays completely dormant until the first mapping is added: no public port is bound and no ACME account call is made until then, so enabling it by default costs nothing on servers that do not use domains. SetDOMAIN_ENABLED=falseto turn it off.
[2.4.7] - 2026-10-10⚓
Added⚓
- Domain mapping with automatic HTTPS. A public domain can now be mapped
to an existing TRP port mapping or to a service reachable over the
WireGuard tunnel, and TunGuard terminates TLS with a Let's Encrypt
certificate obtained over HTTP-01. When nginx, Apache, Caddy or Traefik is
detected the route is written into that server's config and reloaded;
otherwise a built-in reverse proxy listens on ports 80/443. Managed by
domain list|add|update|removein the console,GET /api/domains,POST /api/domain/add|update|remove, and a new Domains WebUI page. Enabled withDOMAIN_ENABLED=true; ports are configurable withDOMAIN_HTTP_PORT,DOMAIN_HTTPS_PORTandDOMAIN_CHALLENGE_PORT.
[2.4.6] - 2026-10-09⚓
Added⚓
- Built-in
sshclient. The Terminal and SSH gateway prompt can now open sessions to WireGuard peers (by device label or tunnel address) or any host the server can reach —ssh [-p port] [-l user] [-i key] [--password pass] [user@]host[:port] [command], with key and password auth, interactive PTYs with live resize, one-shot remote commands and remote exit status.Ctrl-]detaches from an interactive session. - Interactive foreground jobs and terminal sizing. Foreground jobs can now
receive keystrokes from the console, and the server tracks the client's
terminal size from WebSocket
resizemessages and SSHpty-req/window-changerequests. - Full color theme for CLI output: prompts, status tables, peer/mesh/TRP states, policy rule flags and success/error messages.
[2.4.5] - 2026-10-08⚓
Added⚓
- Terminal/SSH console. The API server now exposes an interactive CLI
over an SSH gateway and a browser terminal (
/api/ws/console), plus the WebUI pages for both. The console wraps the full dashboard surface —peer,mesh,trp,policy,backup,update,logs— as commands, with a line editor, history, tab completion andconfirm-style prompts. System utilities (ping,mtr,ss,tcpdump,curl,dig,nmap,nslookup,tracepath,iperf3) run live as foreground jobs. - Server-side log viewer. Log lines are kept in a bounded ring buffer and
exposed by
GET /api/logswithafter,searchandlimitparameters, thelogsconsole command, and a WebUI page.logs -ftails live and stops on input end or Ctrl-C. - Console documentation in
commands.mdcovering every command verb, its sub-verbs and its flags.
Changed⚓
- The update endpoint restarts the process in place and no longer holds a builder lock across the request.
[2.4.4] - 2026-10-07⚓
Changed⚓
- The dashboard is now a WinBox-style desktop. The whole UI was restyled after MikroTik's WinBox 4: a dark theme by default with a light theme beside it (toggle in the top bar), a compact sidebar and top bar, and Font Awesome icons in place of the inline SVGs.
Added⚓
- Pages open as floating windows. On a wide screen the sidebar opens each page — Dashboard, Peers, P2P, TRP, Policy, Settings — as a window on the desktop: drag its title bar to move it, pull any edge or corner to resize, and use the title-bar buttons to maximize, minimize or close. Windows stack by focus, the sidebar highlights the focused one, and each window remembers its size and position. The dashboard opens first, and links inside a window (for example "Add peer") open a window rather than navigating away.
- Narrow screens keep the old behaviour: below 860px the pages navigate normally as plain pages, and a page opened on its own still shows its own sidebar and top bar. The first-login setup page from 2.4.2 is styled to match.
[2.4.2] - 2026-10-04⚓
Added⚓
- Links are now tested, not just punched. A completed hole punch only means
one packet arrived once, so
directstayed set on links that had since broken: a peer that changed network, was remapped, or began dropping what it received kept looking healthy on the dashboard. Each client now sends a small packet to its peer every few seconds and times the echo, so a link reports the round trip it actually measures and shows no answer when the peer stops replying. The result rides in bits that older servers ignore and older clients leave unset, so both directions of the upgrade work without a flag day.
Fixed⚓
- Devices in the default group could reach devices in a private group. The inter-device rule tested the two groups' switches independently, so a device that nobody had moved — which puts it in the permissive default group — could reach into a restricted group whenever both switches were on. A group is a boundary, not a set of extra permissions: device-to-device traffic now requires both devices to resolve to the same group and that group to allow inter-device traffic. The default group reaches only the default group, and a private group only itself, in either direction and with no combination of switches able to undo it.
Membership alone decides, so moving a device takes effect immediately with no restart, exactly as before. - The automatic P2P mesh ignored the group boundary. A direct link is built between the two clients and never passes through the server, so the packet filter never saw that traffic: two devices in different policy groups could still find each other through the relay even though their relayed traffic was dropped. The relay no longer offers a cross-group peer's endpoint, the periodic punch sweep skips the pair, and a manual connect is refused with an explanation rather than silently building a link the filter would deny. A device the hub cannot identify keeps the permissive answer, so a deployment that has not used policy groups is unaffected.
Changed⚓
- The first login is a page, not a popup. A server still on the shipped
admin/tanguardlogin redirects tologin.htmlinstead of covering whichever page was asked for with a modal. It carries the same form as the Settings change-password card, and states the running version and its creator in the footer.
[2.4.1] - 2026-10-04⚓
Fixed⚓
- Tun-client devices were invisible to policy groups, and could not be
restricted. A device that only uses the client has no WireGuard peer, so it
was missing from the policy page entirely and, because enforcement resolved a
device id through the peer list, no group could deny it anything: it was still
allowed to join the mesh and to be the target of a port mapping. The device
list now returns both kinds of device, and a client device is grouped by its
device id through the new
/api/policy/group/assign-deviceand/api/policy/group/unassign-deviceendpoints.
A device that is both a peer and a tun client is still grouped by its public
key, and its device id is refused by assign-device, so one device is never
in two groups at once.
[2.4.0] - 2026-10-03⚓
Added⚓
- Policy groups (
policy.go,policy_filter.go,policy_routes.go): an administrative filter over connections that already exist. A group carries four switches — allow devices in the group to talk to each other, allow P2P automatic mesh features, allow TRP proxy port mapping features, and allow standard WireGuard internet access — and a device is in exactly one group. - Nothing about a connection changes. Peers keep the same configuration, stay authenticated and see no error: a denied packet is discarded silently, so a blocked device times out as if the target were unreachable. The tunnel and reaching the server always work, so a misconfigured group can always be fixed.
- The Default Global Group holds every peer, has all four switches on, and cannot be edited or deleted. A server that has never had a policy group filters nothing, so existing deployments see no change in behaviour. Creating a group is deny-by-default, and moving a device is explicit.
- Enforcement is live. WireGuard traffic is filtered on a TUN wrapper
(
ReadandWrite), P2P punching and relay routing consult the policy on every decision, and TRP checks on creation and per connection. Changing a rule or moving a device takes effect on the next packet, with no restart and no change to the device. IPv4 only; IPv6 and unrecognised traffic pass through. - Policy Groups dashboard page (
webui/policy.html,webui/policy.js) with rule switches, device assignment, and drop counters, plus a full API under/api/policy/*documented in the README. - Policy groups are included in backups.
policy_groups.jsonis written atomically on shutdown, validated on restore, and is added to the backup archive; groups and memberships survive a restore without a restart.
Fixed⚓
- The mesh-only dashboard used to panic on every page load. In
-mesh-onlymode there is noWgServer, and/api/statusand/api/peersdereferenced it unconditionally, so both returned no response and logged a stack trace.GetStatusandPublicKeyare now nil-safe and report an empty device. - Restoring a backup in mesh-only mode used to panic. An archive from a full
server carries a
server_private.key, and the restore path calledConfigureandPublicKeyon the nil server, killing the request with no response. The restore now skips the WireGuard steps when there is no server and still applies peers, policy groups and credentials. - A policy
applythat declared the default group reported a confusingduplicate group id "default"instead of naming the real problem, because the duplicate check ran before the reserved-id check. - Policy drop counters could over-report. The TUN
Writepath decided and then rebuilt the batch, so a denied packet was counted twice and logged twice. It is now a single pass, and an unfiltered batch is handed to the device without being copied. - The policy page could panic on a hand-edited
peers.json.peers.jsonis not validated on load, and building the response sliced the first 8 bytes of the public key; a shorter key crashed the handler. It now uses the existing length-safeshortKeyhelper. - Deleting a device in P2P left its TRP ports bound. Removing a node did not
touch the reverse-proxy table, so every port that device had mapped stayed in
LISTEN. The mapping had become useless — its target node was gone — but nothing in the UI could release it, and the port could not be re-mapped. Removing a node now releases its mappings and frees the ports, and the release is persisted so the mappings do not come back on restart. Previously this behaviour was documented as intentional.
[2.3.2] - 2026-09-29⚓
Fixed⚓
- Removed an unreferenced DDNS client (
ddns.go) and itsDDNS_*config options. It was committed to v2.3.0 by accident, was never started bymain.go, and had no tests. It contained an untested path that downloads a backup over the network and applies it, which should not ship disabled-but- present. Nothing referenced it, so this changes no behavior.
Added⚓
- P2P and TRP API reference in the README covering every mesh and TRP
endpoint, with the authentication and error contract and a runnable
end-to-end example. Backed by tests that pin the documented behavior:
401without credentials,405onGETof aPOST-only route, the400validation errors,503when the control plane is down, and the TRP add/list/remove lifecycle.
[2.3.1] - 2026-09-29⚓
Fixed⚓
- Releases now report their own version.
versionwas a hardcoded constant, so every build claimed to be2.2.1— including the v2.3.0 release. The update checker compared that string against the real latest tag, so a user who installed the newest release was still told an update was available, and installing it again never resolved it. The value is now stamped at build time with-X main.version, and an untagged local build reports2.3.0-devinstead of a release number.
[2.3.0] - 2026-09-29⚓
Added⚓
- P2P mesh. Nodes that share a PSK discover each other over a rendezvous
port and are punched to a direct path, falling back to a relay when NAT
prevents it. Control plane on
CONTROL_LISTEN(default:7000), rendezvous and relay onRELAY_LISTEN(default:7001). - TRP port mapping. The hub listens on a local port and forwards each
connection to
target_ip:target_porton the target node over its mesh path. An emptybind_portauto-assigns a free port. -mesh-onlymode (main.go) runs the control plane, TRP and dashboard with WireGuard disabled, so a mesh can be stood up on a host that cannot create a tun device.- Mesh and TRP dashboard pages (
webui/p2p.html,webui/trp.html) plusMESH_*,CONTROL_LISTENandRELAY_LISTENconfiguration. - CI workflow for documentation deployment (
.github/workflows/docs.yml).
Fixed⚓
- Mesh control-payload target address was written into a copy.
applyTargettook an[8]byteby value, so every node was told to punch a zero address and no direct path ever completed. It now writes through a pointer, as the other call sites already did. - Relay advertisements with an unspecified address were unusable. A node
that learned
0.0.0.0as its relay endpoint advertised it verbatim, so peers could not reach it.hubAddrFornow substitutes the local address observed on the live path. - Node persistence could interleave and lose records.
saveNodeswrotenodes.jsonin place, so concurrent saves could produce a truncated file. It now writes to a unique temporary file and renames into position; covered byTestSaveNodesConcurrent. - E2E tests raced the server under test. The client shutdown path could run more than once, and tests reused persistence ports and listeners. Tests now pin an idempotent shutdown and allocate their own ports.
[2.2.1] - 2026-08-18⚓
Fixed⚓
- Peers page "last handshake" time now shows second precision. The
timeAgodisplay previously dropped seconds for durations between 1–59 minutes (e.g. showing "1m ago" at 97 seconds). It now shows the exact time like "1m37s ago", matching theSTALE_97Smonitor events.
[2.2.0] - 2026-08-17⚓
Added⚓
- TLS/HTTPS support. Set
TLS_CERT_FILEandTLS_KEY_FILEenvironment variables to serve the API and web dashboard over HTTPS. When unset, the server falls back to plain HTTP.
Changed⚓
- CPU percent is now sampled in a background goroutine every 3 seconds
instead of blocking the request handler for up to 250 ms on every
/api/systemcall. - Dashboard status polling is conditional. The sidebar status interval is
only active on non-dashboard pages; the dashboard's own
loadDashboard()reads the same cached status directly, eliminating duplicate/api/statusrequests. - HTTP server hardened with timeouts:
ReadTimeout: 15s,WriteTimeout: 60s,IdleTimeout: 120s. - Request body size capped at 1 MB via
limitedDecoder()applied to all JSON-decoded API endpoints. - Network data size limits: GitHub API responses capped at 1 MB and update
downloads capped at 256 MB via
io.LimitReader. - Version refresh (
?refresh=1) is now synchronous — the existingcheckingguard prevents duplicate GitHub API calls.
Fixed⚓
- WebSocket write race in SSH terminal. All
conn.WriteJSONcalls are serialized through a per-connectionsync.Mutex(wsWritehelper). - SSH client leak on reconnect.
startSSHSessionnow tracks the*ssh.Clientin the outer scope and closes the previous client alongside the session and stdin when a new connection is established. - WebSocket read buffer overflow. Added
conn.SetReadLimit(64 KB)to prevent unbounded memory growth from malicious input. readFirstLinedouble conversion. Fixed redundantstring()wrapping ofos.ReadFileoutput instats.go.
[2.1.3] - 2026-08-17⚓
Changed⚓
- Clicking the version badge now triggers a live GitHub release check instead of only reading from the background cache.
[2.1.2] - 2026-08-16⚓
Fixed⚓
- Web SSH terminal input dead after login. The password prompt handler was
overwriting
term.onDataand never restoring it, so shell input after authentication went nowhere.
[2.1.1] - 2026-08-16⚓
Added⚓
- Network traffic graph on the dashboard. The System card now shows a real-time Chart.js line graph tracking RX/TX rates for tun0, wgo, and eth0 interfaces, with up to 60 data points (10 minutes at 10-second intervals).
- Network interfaces are now filtered to show only tun0, wgo, and eth0 instead of every interface on the host.
- In-app update checker. The topbar shows a version badge that auto-checks GitHub releases every hour in a background goroutine with zero request-path latency. Clicking the badge opens a modal with release notes and a one-click install button that downloads the correct architecture binary, replaces the running executable, and restarts the server.
Fixed⚓
- Dashboard no longer spams "unauthorized" error toasts. Removed the
WWW-Authenticateheader from API 401 responses, which was causing the browser to clear its cached Basic Auth credentials on transient failures, creating an auth-loop.fetchAPInow silently returnsnullon non-200 responses; all callers already handle this gracefully. - SSH direct-tcpip now handles IPv6 addresses correctly. The connection
target is formatted with
net.JoinHostPortinstead offmt.Sprintf("%s:%d"), which produced invalid addresses for IPv6 hosts. - SSH WebSocket reconnect no longer leaks sessions. Starting a new SSH session via the WebSocket terminal now properly closes the previous session and stdin pipe before creating new ones.
[2.1.0] - 2026-08-16⚓
Added⚓
- API key authentication. Every
/api/*endpoint (except/api/health) now requires either the dashboard login (HTTP Basic Auth) or a valid API key sent as theX-API-Keyheader (orAuthorization: Bearer). Previously the API was completely open. - API key management in the dashboard (Settings → API Key): view, copy,
and regenerate the key. The key is stored in
api_key.jsoninsideDATA_DIR(mode 0600) and regenerating immediately invalidates the old one. - The API key is included in backup & restore, so a restored backup also restores the key in use at backup time.
- The PHP helper (
tanguard_api.php) now sends the API key via theTUNGARD_API_KEYenvironment variable or theTunGuardAPIconstructor argument.
Changed⚓
- The SSH gateway now authenticates with the same login as the web
dashboard. Changing the username or password under Settings → Dashboard
Login immediately applies to SSH jump-host access too. The
SSH_USERandSSH_PASSWORDenvironment variables are no longer used.
[2.0.0] - 2026-08-14⚓
Added⚓
- Completely redesigned web dashboard with a custom Material Design-style
theme. The Bootstrap, AdminLTE, jQuery, and Font Awesome CDN dependencies are
gone — the dashboard now works fully offline, styled with a single bundled
style.css. - Live server status panel in the sidebar showing listen port, subnet, and peer counts (total + online), with a peer-count badge on the Peers nav item. It refreshes automatically every 10 seconds.
- Type-to-confirm peer removal. Removing a peer now requires typing the device name before the removal button becomes available, preventing accidental deletions.
- XSS hardening in the dashboard: all user-supplied values (device names, public keys, endpoints) are HTML-escaped before rendering.
Changed⚓
- Peers are now applied to the WireGuard device incrementally via
IpcSetinstead of a fullreplace_peersreconfiguration, so peer changes no longer rebind the listening socket. - Peer add/remove and config generation are serialized on a mutex, and the API now rejects duplicate public keys and already-assigned IPs with clear errors instead of silently overwriting state.
Fixed⚓
- Adding or removing a peer no longer resets the session of every connected device. Existing devices stay connected when a new device joins.
- Duplicate client IPs are now rejected on peer add, and auto-assigned IPs are allocated atomically, so a new device can no longer take over an IP that is already in use by an older device.
[1.3.0] - 2026-08-07⚓
Added⚓
- Backup & Restore from the web dashboard (Settings → Backup & Restore):
GET /api/backup/downloadreturns a.tar.gzofpeers.json,server_private.key,web_credentials.json, andssh_host_key.POST /api/backup/restorevalidates the archive, replaces the state files inDATA_DIR, and applies the restored key and peers to the running server without a restart.- The installer now makes a safety backup of
DATA_DIRto/var/backups/before each install.
Changed⚓
- Updates no longer touch user state. The installer keeps an existing
/etc/systemd/system/tanguard.servicebyte-for-byte and only replaces the binary, so custom ports, subnet, and credentials survive an update. - The server private key is never regenerated on a read error — only when it is genuinely missing.
peers.jsonis never overwritten with an empty file if it failed to load; duplicate peer adds no longer clobber the existing record.
[1.2.1] - 2026-08-04⚓
Fixed⚓
- Peers page no longer shows "No peers connected" when peers exist:
loadPeers()referenced apeer-countelement that only existed on the dashboard page, causing a TypeError that aborted rendering before the table was populated. The peers page now has its own peer-count badge, and the JS update is null-safe.
[1.2.0] - 2026-08-03⚓
Added⚓
- Web dashboard split into separate pages: Dashboard (
index.html), Peers (peers.html), and Settings (settings.html). - QR library bundled into the binary and served locally — no external CDN dependency.
Fixed⚓
- QR code generation in the web dashboard now renders correctly (the QR library was loaded from a non-existent CDN path, and the canvas element was passed to
QRCode.toCanvasinstead of a real<canvas>).
[1.1.0] - 2026-08-03⚓
Added⚓
- Web dashboard credential management:
- Change the dashboard username and password from Settings → Dashboard Login.
- First login with the default
admin/tanguardcredentials now forces you to set a new login before using the dashboard. - Credentials are stored hashed (bcrypt) in
web_credentials.jsoninsideDATA_DIR. ./tanguard --resetrestores the default web login (admin/tanguard) if the password is forgotten.
Changed⚓
- The web dashboard is now disabled by default. Enable it explicitly with
-weborWEB_ENABLED=true; runningtanguardalone only serves the API. - The dashboard password is no longer printed in the startup logs.
[1.0.0] - 2026-07-01⚓
Added⚓
- Userspace WireGuard VPN server (no kernel modules required).
- Web dashboard for managing peers and generating client configs with QR codes.
- SSH gateway / jump host (
-ssh). - Peer persistence via
peers.json. - NAT setup for VPN clients.