Managed WireGuard - Kheopsian/Hydranos GitHub Wiki

🇬🇧 English · 🇫🇷 Français

Managed WireGuard (one tunnel per engine)

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.

What it does

  • 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 gets wg-<id>; a longer id is not refused, its device is shortened to wg-, 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-quick is never run. The .conf is parsed, and Hydranos runs ip and wg itself. AllowedIPs = 0.0.0.0/0 in 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, PostUp and the other wg-quick keys 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.

Requirements

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": …}.

Docker

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 shipped docker-compose.yml has these lines commented out.
  • HYDRANOS_CAP_NET_ADMIN=1 matters only with PUID/PGID: the entrypoint then keeps CAP_NET_ADMIN as an ambient capability across the privilege drop, so ip and wg inherit it. If the container was not granted the capability, the entrypoint says CAP_NET_ADMIN not available, add --cap-add=NET_ADMIN for managed WireGuard and fwmark routing and starts without it. Running as root (no PUID/PGID), cap_add alone 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 host they 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.

Linux (bare metal)

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.

Setting it up

On Config › Advanced settings (default.toml) › Network:

  1. Pick the card WireGuard, managed by Hydranos. The WireGuard block appears.
  2. Under Configuration files, Add a file: choose the .conf from 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).
  3. For each engine (blocks titled local-<engine>):
    • Way out: a .conf file, 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).
  4. 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.
  5. 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.

What is written

[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_enabled nor allow_direct.
  • wireguard_port_forward overrides the provider's way: manual, natpmp, or off/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 = false for 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_down in the answer): the engines pinned to them reach nobody until the restart that save asks for.

The files

  • Stored in <data_dir>/wireguard (/config/wireguard in 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, an Address in [Interface] and at least one [Peer] with a PublicKey; 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 setconf on 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.list records 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 a wg-… device the host made itself.

How a tunnel comes up

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
Loading
  • Routing. Each tunnel gets table 0x48590000 plus its slot (the engine's position in the list of local engines: race 0, hoard 1, then the local extra engines), an oif rule at preference 5100 plus slot, and a from rule at 6100 plus 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's oif rules are removed by device name, and its from rule replaces whatever sat at its new slot; a from rule left at a slot no tunnel takes any more is the case of the known bug below. The oif rule catches every socket bound to the device. The from rule 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 in ip route or ip rule changes.
  • 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=0 no 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 oif rules and its device, not its from rule (preference 6100 plus 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's ip rule list) until the namespace or the host goes away.

The incoming port

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 says wireguard: no port yet; the engine starts with its announces held, and the engine announces nothing until one does.
  • Manual port: wireguard_port becomes the engine's listen_port (the provider forwards the same number). Port 0 means no incoming connections.
  • GET /api/port-forward reports such an engine with forward.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.

DNS in the tunnel

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).

Kill switch and the daemon's requests

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.

Live status

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)

API

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).

IPv6

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).

Running 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.

Not on Windows or macOS

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).

Troubleshooting

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.

⚠️ **GitHub.com Fallback** ⚠️