Skip to content

WireGuard

etemenanki-app can use a WireGuard peer as an outbound: every flow that routing sends to it leaves through an encrypted tunnel and appears on the internet with the peer’s address. Use it to give some or all of your traffic the exit IP of a VPN service or of a WireGuard server you run elsewhere.

The tunnel runs entirely inside the process. etemenanki-app pairs a WireGuard implementation (boringtun) with a userspace TCP/IP stack (smoltcp), so it creates no network interface, adds no routes to the host and needs no root privileges or kernel module. It carries both TCP and UDP.

WireGuard is outbound only. An [[inbound]] with protocol = "wireguard" fails the build with wireguard cannot be used as an inbound (no server implementation). To accept traffic from devices at the IP layer, see the TUN inbound.

flowchart LR
  C["Client"] --> I["Inbound"]
  I --> R{"Router"}
  R -->|"outbound = wg"| S["Userspace TCP/IP stack"]
  S --> B["WireGuard encryption"]
  B -->|"UDP to endpoint"| P["WireGuard peer"]
  P --> T["Destination"]

For each flow, the outbound opens a TCP connection or a UDP association inside the tunnel, sourced from one of your tunnel addresses (address). The stack turns it into IP packets, WireGuard encrypts them, and one UDP socket sends them to the peer’s endpoint. Replies take the same path back. All flows routed to one outbound share that single tunnel and UDP socket, but each flow has its own bounded buffers, so a flow that stops moving holds up only itself (see One slow flow).

This config runs a local SOCKS proxy on port 1080 and sends everything through one WireGuard peer.

wireguard.toml
# A local SOCKS5 proxy whose traffic all leaves through one WireGuard peer.
[[inbound]]
tag = "socks-in"
protocol = "socks"
listen = "127.0.0.1"
port = 1080
[[outbound]]
tag = "wg"
protocol = "wireguard"
[outbound.settings]
# Placeholders. Create a real key pair with: wg genkey | tee private.key | wg pubkey
private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
peer_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
endpoint = "vpn.example.com:51820"
address = ["10.0.0.2"]
keepalive = 25
[route]
default = "wg"

The key values are placeholders that the parser accepts. For a real tunnel, wg genkey creates your private key and wg pubkey derives the public key you register with the peer. More often the service gives you a complete wg-quick file; From a wg-quick file shows how to carry it over.

To send only some traffic through the tunnel and the rest directly, add a freedom outbound and route rules; see Routing and the WireGuard egress recipe.

The WireGuard keys go under [outbound.settings]. Unknown keys are refused, so a key copied from Xray’s or wg-quick’s vocabulary (secretKey, allowed_ips) fails with unknown field instead of being silently dropped.

KeyTypeRequiredDefaultDescription
private_keystringyes—Your own Curve25519 private key, the PrivateKey line of a wg-quick file. 32 bytes, written as standard padded base64 (what wg genkey prints) or as 64 hex digits. Unpadded base64 is refused. Error: invalid wireguard private_key.
peer_public_keystringyes—The peer's public key, the PublicKey line of the [Peer] section. Same encodings as private_key. Error: invalid wireguard peer_public_key.
preshared_keystringno—Optional pre-shared key mixed into the handshake, the PresharedKey line. Same encodings as private_key. Set it only when the peer has one for you; a mismatch means the handshake never completes. Error: invalid wireguard preshared_key.
endpointstringyes—The peer's UDP address as host:port, split at the last :. The host is an IP address or a name; a name is resolved with the system resolver (not [dns]) when the tunnel starts, and the first answer is used. Write IPv6 without brackets (2001:db8::1:51820): brackets are not stripped, so [2001:db8::1]:51820 passes --test and then fails to resolve. Errors: wireguard endpoint must be host:port, invalid wireguard endpoint port.
addressarray of IPsyes—The tunnel-local addresses the peer assigned to you, as bare IPs without a prefix length: ["10.0.0.2", "2001:db8:a::2"]. A CIDR such as 10.0.0.2/32 is refused with invalid IP address syntax. They decide which destination families the tunnel can reach: without an IPv6 address, IPv6 destinations are unreachable. An empty list passes --test, but every connection then fails with wireguard: no tunnel-local addresses configured.
mtuintegerno1420Largest IP packet inside the tunnel, in bytes; the userspace TCP stack sizes its segments from it. Not range-checked. Lower it (for example to 1280) when small requests work but large transfers stall.
keepaliveu16no—Persistent keepalive interval in seconds, the PersistentKeepalive line. Absent or 0 turns it off. Set it (commonly 25) when this host is behind NAT or a stateful firewall and connections may sit idle.
reservedarray of integersno—Exactly three bytes ([0, 0, 0] to [255, 255, 255]) written into header bytes 1 to 3 of every outgoing WireGuard packet, as Xray's reserved does. Incoming packets have these bytes cleared before decoding whether or not reserved is set. Only set it when your service tells you to. Any other length fails with invalid length …, expected an array of length 3.

Errors in this table read outbound <tag>: …. Errors from the TOML parser, such as a CIDR in address, read outbound <tag>: invalid settings: … and name the key on the next line:

configuration invalid: outbound wg: invalid settings: invalid IP address syntax
in `address`

All three keys are 32 bytes. Each one may be written as standard base64 with its = padding, which is what wg genkey, wg pubkey and wg genpsk print, or as 64 hex digits, which Xray also accepts. Surrounding whitespace is ignored.

Only the encoding is checked when the config is built. A key that decodes but is wrong, for example the peer’s key pasted into private_key, passes --test and fails later: the peer never answers the handshake.

endpoint is host:port. The outbound resolves it when the tunnel starts, not at --test time:

  • An IP address is used as is. Write IPv6 without brackets, 2001:db8::1:51820. The outbound splits at the last colon and does not strip brackets, so [2001:db8::1]:51820 passes --test and then fails at the first connection with wireguard: tunnel start failed: failed to lookup address information: ….
  • A name goes through the system resolver (getaddrinfo), not through the [dns] resolver, and the first answer wins, whatever its family. address_family does not affect this lookup. If the name has both A and AAAA records and this host cannot reach the peer over the family the resolver returns first, write the IP address instead.
  • The name is looked up again only when the tunnel is rebuilt (see Tunnel lifecycle). A running tunnel keeps the address it started with.

The UDP socket to the peer binds an ephemeral port on the wildcard address of the endpoint’s family, so there is no ListenPort to configure.

address lists the addresses the peer assigned to you, bare, without a prefix length. They matter in two ways:

  • Source address. A connection to an IPv4 destination uses the first IPv4 entry, and an IPv6 destination the first IPv6 entry.

  • Reachable families. A family with no entry cannot be reached through the tunnel. The outbound drops those addresses from a name’s DNS answers before it tries any. When nothing is left, the connection fails with an error like this one, logged at debug level:

    wireguard: no usable auto destination address for 2001:db8::5:80 (local address supports IPv4 only)

The common outbound key address_family (see Outbounds) sets policy on top of that. For WireGuard it applies to destinations inside the tunnel, never to the endpoint:

address_family Destinations tried Requirement on address
auto (default) Every family address covers, in resolver order none
prefer_ipv4 / prefer_ipv6 Both families address covers, the preferred one first none
ipv4_only IPv4 only at least one IPv4 entry
ipv6_only IPv6 only at least one IPv6 entry

The *_only requirement is checked when the config is built, so a mismatch fails --test:

outbound wg: wireguard address_family ipv6_only needs an IPv6 address
outbound wg: wireguard address_family ipv4_only needs an IPv4 address

With a TCP flow, the outbound tries each remaining address in turn and gives each one 10 seconds. A UDP datagram to a name goes to the first usable address; each UDP association looks a name up once and reuses the answer, and a name that does not resolve drops its datagrams. A UDP datagram addressed to an IP address is sent as is: neither address_family nor the family check above applies to it.

mtu is the largest IP packet inside the tunnel, 1420 bytes by default. The userspace TCP stack sizes its segments from it. Each tunnelled packet grows by WireGuard’s overhead on the way to the peer, and if the result does not fit the path, large packets are lost while small ones get through. The typical symptom is that a TLS handshake or a small page works and a large download stalls. Use the MTU your service specifies. If it gives none and you see that symptom, try 1280.

--test does not range-check mtu.

With keepalive unset or 0, the tunnel sends packets only when it has traffic. If this host is behind NAT or a stateful firewall, the mapping for the UDP socket can expire while a connection is idle, and the peer’s next packet is then lost. A keepalive of 25 seconds keeps the mapping open, the interval the wg(8) manual suggests for most firewalls.

WireGuard packets carry three reserved bytes after the message type, normally zero. Some services put a client identifier there, and Xray exposes it as reserved. When reserved is set, the outbound writes it into bytes 1 to 3 of every packet it sends. The outbound clears those bytes in every packet it receives before decoding, whether or not reserved is set, so a peer that fills them in its replies still works. Leave reserved unset unless your service gives you a value.

reserved = [12, 34, 56] # exactly three integers, each 0–255
Key What happens
server, port Accepted and ignored. The peer’s address is settings.endpoint.
[outbound.stream] Only network and security are checked: a network other than tcp, or a security other than none, fails with protocol wireguard does not support stream network "…" (or stream security). Anything else in the block is ignored. WireGuard carries itself over UDP.

Services usually hand out a wg-quick file with an [Interface] and a [Peer] section. The two tabs below describe the same tunnel.

[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.0.0.2/32, 2001:db8:a::2/128
DNS = 192.0.2.53
MTU = 1280
[Peer]
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
PresharedKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = 203.0.113.10:51820
PersistentKeepalive = 25
wg-quick etemenanki-app Notes
[Interface] PrivateKey private_key Copy as is.
[Interface] Address address One string per address, without /32 or /128.
[Interface] MTU mtu Defaults to 1420, the value wg-quick usually computes when MTU is absent.
[Interface] DNS none See below.
[Interface] ListenPort none The UDP socket uses an ephemeral port.
[Interface] Table, PreUp, PostUp, PreDown, PostDown none No interface or host routes exist to manage.
[Peer] PublicKey peer_public_key
[Peer] PresharedKey preshared_key
[Peer] Endpoint endpoint IPv6 without brackets: 2001:db8::1:51820.
[Peer] PersistentKeepalive keepalive
[Peer] AllowedIPs none See below.
none reserved Xray-specific; not part of wg-quick.

AllowedIPs has no equivalent. In wg-quick it does two jobs: it chooses which traffic enters the tunnel, and it filters which source addresses the peer may send back. In etemenanki-app the first job belongs to routing: a flow enters the tunnel because a route rule, or [route].default, names this outbound. The second is not needed here: nothing listens on the tunnel addresses except the connections and UDP associations the outbound itself opened. A config with AllowedIPs = 0.0.0.0/0, ::/0 corresponds to routing everything to the outbound; a narrower list corresponds to route rules with cidr matches. A cidr rule only matches a destination that arrives as an IP address; the router does not resolve names to match it.

DNS has no equivalent either. wg-quick uses it to reconfigure the host’s resolver while the interface is up. etemenanki-app changes nothing on the host. When a flow through the tunnel names its destination by domain, the outbound resolves that name with the [dns] resolver on this host, and the query itself does not travel through the tunnel. This outbound always resolves names on this host. If the lookup must not go out in plain text, configure an encrypted backend in DNS.

The katana node agent can also build a WireGuard outbound, with its own field names: the endpoint comes from server and port, the peer key is public_key, the pre-shared key is pre_shared_key, and tunnel addresses go in local_address, where a prefix length is allowed. The katana outbound documentation covers that format.

  • Lazy start. Building the config opens nothing. The first flow routed to the outbound starts the tunnel: it resolves endpoint, binds the UDP socket and starts the driver task. The WireGuard handshake follows when the first packet is sent, so the first connection waits for it.
  • One tunnel per outbound. Every flow routed to the outbound shares the tunnel. Two outbounds with the same key and peer are two separate tunnels, and the peer treats them as one client that keeps moving (see the caution under Check a new endpoint).
  • Handshakes and rekeying. WireGuard renews the session keys on its own schedule, while the tunnel carries traffic. When the peer stops answering, connections time out and the tunnel retries the handshake about every 5 seconds. After 90 seconds without an answer it gives up until new traffic arrives, which starts the next handshake.
  • Rebuild. The tunnel’s driver stops when its UDP socket reports an error. When a data packet is sent or a packet is received, errors that come from ICMP replies (connection refused or reset, host or network unreachable) are ignored and the packet is dropped. A timer packet, such as a handshake retry or a keepalive, that fails to send stops the driver whatever the error. After the driver stops, the next flow logs wireguard: tunnel driver stopped, rebuilding, builds a new tunnel and resolves endpoint again. A tunnel that had run for at least 10 seconds is rebuilt at once. If the previous tunnel died sooner, or a start failed, the outbound waits 2 seconds before the next attempt, doubling up to 30 seconds. Flows that arrive during the wait fail with wireguard: tunnel is down, waiting before the next attempt.
  • Hot reload. A successful reload builds every outbound anew. The old tunnel goes away with its connections, and the next flow starts a fresh one with a new handshake.

The flows on one tunnel share a single driver task, and each flow has its own bounded buffers between the application and the tunnel:

  • TCP uplink. When a flow’s remote, or the tunnel, stops taking data, that flow first fills its 64 KiB socket send buffer, then its channel of 256 writes, and then the next write from the inbound side waits. The driver takes at most one write ahead of each flow’s socket and reads the flow’s channel only while it holds none, so it queues nothing more for the stalled flow. Every other flow on the same tunnel keeps moving, and the waiting write goes through once the remote reads again.
  • Fair share. On each pass, the driver hands a flow’s socket at most one channel’s worth, 256 writes or datagrams, so one busy flow cannot keep the driver to itself.
  • Downlink. The driver reads a flow’s socket only while that flow’s channel to the application has room, so a client that reads slowly holds up only its own flow.
  • UDP. Each UDP association has a send ring of 64 KiB and 64 datagrams. A datagram that finds the ring full waits for the next pass, and the datagrams behind it wait in the association’s channel. A datagram that can never be sent is dropped instead of holding up the association and every datagram behind it: one larger than the whole ring, or one to an unaddressable destination such as port 0. The drop is logged only at trace level, as wireguard: dropping a N-byte datagram to <addr>: <error>, where the error is buffer full or unaddressable.
  • UDP close. When the application finishes with an association, the driver keeps it for one more pass, so the last datagrams it sent still go out.

Before you put a new WireGuard configuration in front of users, prove that it carries traffic. The safe way is a throwaway etemenanki-app process with one SOCKS inbound on loopback and this single outbound. It shares nothing with a running proxy: different process, different config file, different port.

  1. Write a config file in an empty directory, with the tunnel settings from your service:

    wg-check.toml
    # Throwaway check of one WireGuard outbound through a loopback SOCKS proxy.
    [[inbound]]
    tag = "check"
    protocol = "socks"
    listen = "127.0.0.1"
    port = 10808
    [inbound.settings]
    udp = false
    [[outbound]]
    tag = "wg"
    protocol = "wireguard"
    [outbound.settings]
    private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
    peer_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
    endpoint = "203.0.113.10:51820"
    address = ["10.0.0.2"]
    mtu = 1280
    keepalive = 25
    [route]
    default = "wg"

    Pick any free port. Keep listen on 127.0.0.1 so nothing else can use the proxy.

  2. Check the file:

    Terminal window
    etemenanki-app --test -c wg-check.toml

    It should print Configuration OK.. This proves the syntax and the key encodings, not that the peer accepts you.

  3. Start it in the foreground, with WireGuard’s own log lines turned on:

    Terminal window
    RUST_LOG=info,boringtun=debug,etemenanki_app=debug etemenanki-app -c wg-check.toml
  4. From a second terminal, ask an IP echo service which address it sees:

    Terminal window
    curl -m 30 --socks5-hostname 127.0.0.1:10808 https://ifconfig.me

    The answer should be the peer’s exit address, not this host’s own public address (compare with curl https://ifconfig.me without the proxy).

  5. If that first request fails or times out, run it once more before you conclude anything. The first connection after start has to wait while the tunnel comes up and completes its handshake, and it can fail even when the configuration is right.

  6. Stop the process with Ctrl-C. Nothing is left behind apart from the config file.

What the log tells you:

You see It means
Sending handshake_initiation, then HANDSHAKE(REKEY_TIMEOUT) about every 5 seconds, and CONNECTION_EXPIRED(REKEY_ATTEMPT_TIME) after 90 seconds The peer is not answering. Check endpoint and its port, peer_public_key, whether the peer has your public key registered, whether it requires reserved or a preshared_key, and whether outbound UDP is blocked.
Received handshake_response, New session, but requests time out The tunnel is up but the peer does not forward. Suspect an expired or revoked key or account first. If small requests work and large ones stall, lower mtu.
wireguard: tunnel start failed: … The tunnel could not start. Common causes: endpoint did not resolve with the system resolver, it is a bracketed IPv6 address, or address is empty (no tunnel-local addresses configured).
wireguard: tunnel driver stopped, rebuilding The tunnel’s UDP socket failed and the next flow is building a new tunnel (see Tunnel lifecycle).
… ended: wireguard: tunnel is down, waiting before the next attempt A recent start failed or the last tunnel died within 10 seconds, so the outbound is waiting out its backoff. Look for the first error above it.
socks connection from … ended: wireguard: tunnel TCP connect failed for all resolved addresses (…: timed out) No TCP answer through the tunnel within 10 seconds per address. Read it together with the WireGuard lines above.
… ended: wireguard: no usable auto destination address for … The destination has no address in a family that address covers, for example an IPv6-only site with only an IPv4 tunnel address.
wireguard: dropping a N-byte datagram to …: … Logged at trace level only: add etemenanki_protocols=trace to RUST_LOG, or set [log] level = "trace". A UDP datagram was larger than the association’s 64 KiB send ring, or its destination was unaddressable, so it was dropped (see One slow flow).

curl only exercises TCP. The outbound carries UDP too, but checking that needs a client that speaks SOCKS5 UDP ASSOCIATE; in that case, leave udp at its default (true) in the check config.

Error Cause and fix
wireguard cannot be used as an inbound (no server implementation) protocol = "wireguard" under [[inbound]]. WireGuard is outbound only.
outbound wg: invalid wireguard private_key (or peer_public_key, preshared_key) The value is not 32 bytes of padded base64 or hex. Check for a missing =, a truncated paste or a stray character.
invalid settings: invalid IP address syntax / in `address` A prefix length in address. Write "10.0.0.2", not "10.0.0.2/32".
invalid settings: missing field `address` address is required, even though an empty list is accepted.
wireguard endpoint must be host:port endpoint contains no :, so it has no port.
invalid wireguard endpoint port The text after the last : is not a number from 0 to 65535.
invalid settings: invalid length 2, expected an array of length 3 / in `reserved` reserved needs exactly three integers. A value above 255 fails with expected u8.
invalid settings: unknown field … A key that the outbound does not have, such as allowed_ips or dns. The message lists the valid ones.
wireguard address_family ipv6_only needs an IPv6 address address_family asks for a family that address does not cover.
protocol wireguard does not support stream network "…" Remove [outbound.stream].
wireguard: no tunnel-local addresses configured (at run time) address = []. List at least one address.