Managed WireGuard - Kheopsian/Hydranos GitHub Wiki
Applies to Hydranos 4.4.
For anyone who wants each engine inside a VPN without building the tunnels themselves. You upload the WireGuard .conf your provider gives you, assign one to each engine, and Hydranos brings the tunnel up at startup, pins the engine to it, asks the provider for an incoming port and resolves tracker names through it. The host keeps its own default route. The other ways out (Direct, Gluetun, SOCKS5), the kill switch and the daemon's own requests are on VPN and Egress.
-
One tunnel per engine, named
wg-<engine>(wg-race,wg-hoard, …). Two engines can use two providers, or two servers of one provider. A Linux interface name is at most 15 characters: an engine id of up to 12 characters getswg-<id>; a longer id is not refused, its device is shortened towg-, the id's first 7 characters,-and 4 hex characters derived from the whole id (proton-europe-1→wg-proton--+ 4 hex), the same at every start. -
wg-quickis never run. The.confis parsed, and Hydranos runsipandwgitself.AllowedIPs = 0.0.0.0/0in a provider file therefore never becomes the host's default route: the routes go into a routing table of the tunnel's own.Table,PreUp,PostUpand the otherwg-quickkeys are not applied. -
The engine is pinned to the device (
bind_interface = wg-<engine>, set by Hydranos in memory at each start and never written to the file, so an extra engine of the same role does not inherit it): listener, uTP, dials, announces, DHT, LSD and webseeds all use the tunnel. A tunnel that is down means no network for that engine, never the default route. - The incoming port is asked of the tunnel's gateway by NAT-PMP (Proton VPN, or any provider whose gateway answers NAT-PMP), or typed by hand for providers that assign it on their website.
-
Tracker names are resolved by the
DNS =server of the.conf, through the tunnel.
| Needed | Why | Refusal when missing |
|---|---|---|
| Linux | Device pinning (SO_BINDTODEVICE) and policy routing |
Managed WireGuard needs Linux. On this system, bring the tunnel up yourself and pick its interface in Direct mode. |
CAP_NET_ADMIN in the process's effective set |
ip link add, ip rule, wg setconf
|
Managed WireGuard needs the NET_ADMIN capability, and Hydranos does not have it. In Docker, add cap_add: [NET_ADMIN] (docker run --cap-add=NET_ADMIN); with PUID/PGID, also set HYDRANOS_CAP_NET_ADMIN=1. |
ip (iproute2) and wg (wireguard-tools) in the PATH
|
The commands Hydranos runs | Managed WireGuard needs the ip (iproute2) and wg (wireguard-tools) commands, and one of them is not installed. |
The check is made when the Network tab loads, when tunnels are assigned, and at startup. Where it fails, the WireGuard, managed by Hydranos card is greyed with the sentence on it and cannot be picked, POST /api/network/mode with "mode": "wireguard" and a tunnel assignment both answer 409 with the sentence, and GET /api/network/mode carries wireguard: {"supported": false, "reason": …}.
The image ships iproute2 and wireguard-tools. The container needs the capability, and the sysctl if you want IPv6 in the tunnel:
services:
hydranos:
image: ghcr.io/kheopsian/hydranos:v4.4.1
cap_add:
- NET_ADMIN
sysctls:
net.ipv6.conf.default.disable_ipv6: 0
environment:
PUID: "1000"
PGID: "1000"
HYDRANOS_CAP_NET_ADMIN: "1" # only with PUID/PGID-
cap_add: NET_ADMIN(docker run --cap-add=NET_ADMIN) grants the capability to the container. The shippeddocker-compose.ymlhas these lines commented out. -
HYDRANOS_CAP_NET_ADMIN=1matters only withPUID/PGID: the entrypoint then keepsCAP_NET_ADMINas an ambient capability across the privilege drop, soipandwginherit it. If the container was not granted the capability, the entrypoint saysCAP_NET_ADMIN not available, add --cap-add=NET_ADMIN for managed WireGuard and fwmark routingand starts without it. Running as root (noPUID/PGID),cap_addalone is enough. - The sysctl keeps IPv6 on the new device. Without it Docker creates interfaces with IPv6 off and the tunnel carries IPv4 only (see IPv6 below).
-
Bridge network (the default) is the recommended place: the tunnels live in the container's own namespace. Under
--network hostthey are created on the host (see Running under --network host). - Peer ports published on the host (16171, 16172) are not used by a tunnelled engine: its peers arrive through the tunnel.
The service needs CAP_NET_ADMIN and the two commands installed. The shipped unit runs as User=hydranos without capabilities, so the mode is refused there until you grant one (for example AmbientCapabilities=CAP_NET_ADMIN in a drop-in; not tested by the maintainer). See Install on Linux.
On Config › Advanced settings (default.toml) › Network:
- Pick the card WireGuard, managed by Hydranos. The WireGuard block appears.
- Under Configuration files, Add a file: choose the
.conffrom your provider and click Upload. It answers "Stored. Pick it for an engine below, then save the tunnels." Repeat for each file (one per engine). - For each engine (blocks titled
local-<engine>):-
Way out: a
.conffile, Direct (default interface) (the host's route, on purpose), or Not assigned (blocked by the kill switch). - Provider: the provider the file comes from. Its note says how the incoming port is obtained.
- Port given by the provider: shown only for providers that assign the port on their website (AirVPN, Private Internet Access, Windscribe).
-
Way out: a
- Click Save the tunnels. Assigning at least one tunnel writes
[network] mode = "wireguard"and removes the keys of the other modes, as saving a mode does. - Restart (Apply and restart in the banner). The tunnels come up at startup, before the engines start.
Rules checked at save time (400 with the reason): the file must exist and parse, a provider must be picked, and one file per engine (two engines on one file would share one address and one port). Warnings, saved anyway: a manual-port provider with no port typed, a provider with no port forwarding ("This engine takes no incoming connection"), and an engine left Not assigned (blocked by the kill switch) ("The kill switch keeps this engine off the network until it is given one").
The Ports section then hides the listen port of the engines whose port comes from the tunnel: a NAT-PMP provider (unless wireguard_port_forward says manual or off), or a tunnel this process tried to bring up that carries a port (a port typed by hand included, even if the tunnel failed). That port is shown on the tunnel's line.
[network]
mode = "wireguard"
[race]
wireguard_enabled = true
wireguard_config = "proton-nl-12.conf" # a file name in <data_dir>/wireguard
wireguard_provider = "proton"
wireguard_port = 0 # the provider's port, for manual providers
wireguard_port_forward = "" # empty: what the provider does
[hoard]
allow_direct = true # "Direct (default interface)"
wireguard_config = ""
wireguard_provider = "proton"
wireguard_port = 0
wireguard_port_forward = ""- An engine Not assigned (blocked by the kill switch) has neither
wireguard_enablednorallow_direct. -
wireguard_port_forwardoverrides the provider's way:manual,natpmp, oroff/none. The Network tab always writes it empty; set it by hand only to override. - An extra engine does not inherit its role's tunnel: assign it its own.
- A file saved while the WireGuard block had a tick box per engine (
wireguard_enabled = falsefor an engine left unticked) is read, in WireGuard mode, as Direct. - Saving any other mode removes every
wireguard_*key, and takes the running tunnels down at once (tunnels_downin the answer): the engines pinned to them reach nobody until the restart that save asks for.
- Stored in
<data_dir>/wireguard(/config/wireguardin Docker), directory mode 0700, each file 0600 from its first byte. - Name: letters, digits,
.,-and_, at most 96 characters, ending in.conf. At most 64 KiB. - Parsed before it is stored: it must have a
PrivateKey, anAddressin[Interface]and at least one[Peer]with aPublicKey; otherwise the upload is refused with the reason. -
Never sent back. The API lists the name, the address, the first peer's endpoint and public key; the private key stays on disk. The key is fed to
wg setconfon standard input, never on a command line. - Remove deletes the file. An engine still assigned to it does not come up at the next start until another file is chosen.
-
<data_dir>/wireguard/managed.listrecords the devices Hydranos created, so that a tunnel no longer wanted (engine removed, mode changed while stopped) is taken down at the next start, without touching awg-…device the host made itself.
At startup, before any engine joins the network:
sequenceDiagram
participant D as Hydranos startup
participant K as kernel (ip, wg)
participant G as tunnel gateway
participant E as engine
D->>K: take down devices in managed.list no longer wanted
D->>D: check Linux, NET_ADMIN, ip and wg, then read the .conf
D->>K: delete old rules and device wg-engine
D->>K: ip link add wg-engine type wireguard
D->>K: wg setconf (keys and peers on stdin)
D->>K: addresses, MTU (1420 if unset), link up
D->>K: AllowedIPs routes into the tunnel's own table
D->>K: ip rule oif wg-engine lookup that table
D->>K: ip rule from tunnel address lookup that table
D->>D: DNS servers of the .conf registered for wg-engine
D->>D: kill switch verdicts, blocked engines skipped
D->>G: NAT-PMP map TCP and UDP (NAT-PMP providers, 2 tries)
G-->>D: external port, or no answer yet
D->>E: start, pinned to wg-engine, announces held until a port
loop every half lease (60 s lease), 15 s after a failure
D->>G: renew TCP and UDP on the same internal port
G-->>D: external port, announced to trackers
end
-
Routing. Each tunnel gets table
0x48590000plus its slot (the engine's position in the list of local engines:race0,hoard1, then the local extra engines), anoifrule at preference5100plus slot, and afromrule at6100plus slot. Slots are recomputed at every start: remove an engine from the middle of the list and the engines after it move down one slot at the next start. A tunnel'soifrules are removed by device name, and itsfromrule replaces whatever sat at its new slot; afromrule left at a slot no tunnel takes any more is the case of the known bug below. Theoifrule catches every socket bound to the device. Thefromrule sends back through the tunnel what the kernel emits on its own with the tunnel's address (TCP resets, ICMP replies), which would otherwise leave by the default route. Nothing else inip routeorip rulechanges. - Idempotent. Each start first removes what a previous run left on that device.
-
A failure (bad file, refused command, missing capability) is logged as
wireguard: the tunnel did not come up; the engine stays pinned to it and reaches nobody (no direct fallback), the half-built device is removed, and the engine starts pinned to a device that does not exist. -
At shutdown the tunnels are taken down after the resume flush (
wireguard: tunnels taken down). - With
HYDRANOS_ENGINE_NET=0no tunnel is brought up.
Known bug in 4.4: taking a tunnel down (at shutdown, when another mode is saved, after a failed start, or at a start where that engine no longer has a tunnel) removes its
oifrules and its device, not itsfromrule (preference6100plus slot). That rule points to a table left empty, so it changes no route while that table stays empty. It is replaced only when a tunnel comes up again at that slot with an address of the same family (and no address clash with another interface); when a tunnel comes up at that slot but the rule is not replaced because of an address clash, the table holds routes again and the old rule sends packets from the old address through that tunnel. Otherwise it stays (under--network host, in the host'sip rulelist) until the namespace or the host goes away.
| Provider (as listed) | How the port is obtained |
|---|---|
| AirVPN | Manual: create the port in the client area, type it in Port given by the provider |
| Mullvad | None: no incoming connections |
| Other (NAT-PMP capable) | NAT-PMP, gateway = the first IPv4 DNS = address of the file |
| Other / none | None (type a port by hand if the provider forwards one) |
| Private Internet Access | Manual (PIA's own port API is not automated) |
| Proton VPN | NAT-PMP, gateway 10.2.0.1 (use a server marked P2P) |
| Windscribe | Manual: the port from its web panel |
-
NAT-PMP maps TCP and UDP on the same port. The lease asked for is 60 s, renewed at half its granted lifetime (at least every 5 s), and retried every 15 s after a failure. A refused UDP mapping costs only uTP and is logged (
UDP not forwarded, uTP peers cannot reach this engine: …). -
The port is translated, not moved. The engine keeps listening on its
listen_port; trackers (and peers, in the extension handshake) are told the external port the gateway granted. Renewals always ask for the same internal port, so the external port stays stable as long as the gateway allows. -
Announces are held until the first port arrives:
wireguard port forward: announces held until the gateway gives a port. If none comes at startup the log sayswireguard: no port yet; the engine starts with its announces held, and the engine announces nothing until one does. -
Manual port:
wireguard_portbecomes the engine'slisten_port(the provider forwards the same number). Port 0 means no incoming connections. -
GET /api/port-forwardreports such an engine withforward.by = "wireguard". The home router is never asked for a tunnelled engine's port, and the live listen-port routes answer 409 for an engine that follows its tunnel's port.
The DNS = addresses of the .conf are registered for the device before the engine starts. The engine's HTTP announces, UDP announces, webseeds and DHT bootstrap resolve names there: A and AAAA over UDP, from a socket pinned to the tunnel, 2 s per try and 2 tries per server, answers cached 5 to 60 s. If the tunnel's server does not answer, the lookup fails: it never asks the host instead.
A .conf without DNS = still works, but tracker names are then resolved by the host's resolver: the swarm sees only the tunnel, the host's DNS provider sees which trackers the engine talks to. It is logged (wireguard: the file has no DNS line …), shown as tracker names resolved by the host's DNS (name leak) on the tunnel's line, and listed as a gap by the kill switch. Fix: add a DNS = line with the provider's resolver.
The daemon's own requests use the tunnel's DNS too when [daemon] egress borrows a tunnel (see below).
In WireGuard mode the kill switch is armed by default, and only the three Way out choices count:
| Way out | Verdict |
|---|---|
a .conf
|
covered: its tunnel (a tunnel that is down means no network) |
| Direct (default interface) | direct on purpose |
| Not assigned (blocked by the kill switch) | BLOCKED: no listener, dial, announce, DHT or LSD |
An interface typed for an engine in another mode is not a tunnel here. With [daemon] egress left on auto, the daemon's own requests (lists, update check, webhooks, .torrent URLs) go through race's tunnel, else the first tunnelled engine's; with no tunnel to borrow and the kill switch armed, they are refused. hydranos-update finds the same device. Details: VPN and Egress.
The WireGuard block shows Live tunnels, one line per tunnel (local-<engine>, device, provider):
| Shown | Meaning |
|---|---|
| carrying traffic | Newest handshake less than 180 s ago |
| no recent handshake | Device present, no handshake for 180 s or more |
| down: this engine reaches nobody | Device not created, or gone |
port N / no incoming port
|
The forwarded port, if any |
handshake … ago, endpoint |
From wg show <device> latest-handshakes and endpoints
|
| IPv4 only | IPv6 was dropped (hover for the reason) |
DNS … through the tunnel / tracker names resolved by the host's DNS (name leak)
|
Where names are resolved |
| last attempt failed | The last bring-up or port request failed (hover for the error) |
| Route | Does |
|---|---|
GET /api/network/wireguard |
configs (no keys), directory, engines (per engine: assignment = tunnel/direct/none, config_file, provider, manual_port, port_forward, device), providers, supported, unsupported_reason, tunnels (null when none; each with created, present, up, handshake_age_seconds, endpoint, forwarded_port, degraded, last_error, dns, dns_leak) |
POST /api/network/wireguard/configs |
Store a file: multipart field file, or a raw body with ?name=provider.conf. Accepted even where tunnels cannot be brought up |
DELETE /api/network/wireguard/configs/:name |
Remove a file; 404 if there is none |
POST /api/network/wireguard/engines |
{"engines":[{"engine_id","assignment","config_file","provider","manual_port","port_forward"}]}; answers restart_required, warnings, note. 409 with the reason on a host that cannot bring tunnels up |
All need the API key, like the rest of the REST API (REST API).
A dual-stack .conf gets both families. If the IPv6 address or route cannot be added (Docker without the sysctl above), the tunnel carries on in IPv4 only and says why: IPv6 is disabled on new interfaces here; the tunnel carries IPv4 only. To keep both, start the container with --sysctl net.ipv6.conf.default.disable_ipv6=0 (or set that sysctl on the host under --network host).
The tunnels are then created in the host's namespace, next to the host's own interfaces. Only the devices recorded in managed.list are taken down when no longer wanted, and the routing tables Hydranos uses are far from wg-quick's default (51820), so a tunnel the host runs itself is left alone (do not name one of yours wg-<engine>). If another interface already holds the tunnel's address (a provider that gives every client the same address, with the host running its own tunnel), the from rule is not posed, and the log warns: the kernel's own replies on that address may leave by the default route. Run Hydranos in its own network namespace (a bridge network) to close this.
Managed WireGuard is Linux-only. On Windows and macOS the card is greyed with Managed WireGuard needs Linux. …. Run the VPN client system-wide instead (see Windows and macOS on VPN and Egress and Install on Windows).
| Symptom | Cause | Fix |
|---|---|---|
| The card is greyed | One of the three requirements is missing; the card names it | Add cap_add: NET_ADMIN (and HYDRANOS_CAP_NET_ADMIN=1 with PUID/PGID), install iproute2 and wireguard-tools, or use Linux |
| down: this engine reaches nobody after a restart | The tunnel did not come up: see wireguard: the tunnel did not come up … and its error in the log, or last attempt failed
|
Fix the cause (file removed, capability lost), then restart. The engine never falls back to the default route |
| no recent handshake | The provider's server does not answer: wrong or revoked keys, server down, UDP to the endpoint blocked | Download a fresh .conf, try another server |
Tunnel up, no incoming port
|
Provider without forwarding (Mullvad, Other / none), manual port not typed, or no NAT-PMP answer (Proton: a server not marked P2P) | Pick a P2P server, or type the provider's port |
| Torrents never announce | Announces are held until the gateway gives a port | Same as above; or set wireguard_port_forward = "off" for that engine to stop waiting |
| An engine is BLOCKED | Way out left on Not assigned (blocked by the kill switch) | Give it a file or Direct (default interface), save, restart |
| tracker names resolved by the host's DNS (name leak) | The .conf has no DNS = line |
Add the provider's DNS server to the file and upload it again |
| IPv4 only | IPv6 disabled on new interfaces (Docker) | Add the net.ipv6.conf.default.disable_ipv6: 0 sysctl |
| The daemon's requests are refused |
egress = auto, kill switch armed, and no engine has a tunnel to lend |
Assign a tunnel to race, or set [daemon] egress (see VPN and Egress) |
More network symptoms: Troubleshooting.