WireGuard egress
This recipe gives a few sites the exit address of a WireGuard peer, such as a VPN service or a WireGuard server you run in another region, while all other traffic leaves from this host as usual. You end up with a local SOCKS5 proxy, a freedom outbound as the default, and a wireguard outbound that route rules pick for chosen domains and geosite lists.
Follow it when your WireGuard service hands you a wg-quick file (wg0.conf) and you want to use it from etemenanki-app. The WireGuard protocol page is the full reference for the outbound; this page walks one realistic setup from the file to a verified exit.
What you build
Section titled “What you build”flowchart LR
C["Browser or app"] -->|"SOCKS5, 127.0.0.1:1080"| I["socks-in"]
I --> R{"route rules"}
R -->|"example.com, geosite netflix"| W["wg outbound"]
R -->|"no rule matches"| D["direct outbound"]
W -->|"encrypted UDP"| P["WireGuard peer"]
P --> T1["Site, sees the peer's exit address"]
D --> T2["Site, sees this host's address"]
- The SOCKS inbound listens on loopback, so only programs on this machine can use it.
- The router tries the rules from top to bottom. A flow that matches the WireGuard rule enters the tunnel; every other flow falls through to
[route].default. - The WireGuard outbound runs the tunnel inside the process, with a userspace TCP/IP stack. It creates no network interface, installs no host routes and needs no root. It carries TCP and UDP.
The complete config
Section titled “The complete config”# A local SOCKS5 proxy that sends a few sites out through a WireGuard tunnel# and everything else directly. The WireGuard values are placeholders: copy# yours from the wg-quick file your WireGuard service gives you.
[[inbound]]tag = "socks-in"protocol = "socks"listen = "127.0.0.1"port = 1080# sniffing = true is the default: a flow addressed by IP is still matched by# its TLS SNI or HTTP Host, so the domain rules below apply to it too.
# Listed first, so it is also the implicit default. [route].default names it# explicitly anyway.[[outbound]]tag = "direct"protocol = "freedom"
[[outbound]]tag = "wg"protocol = "wireguard"# An IPv4-only tunnel: never try a destination's IPv6 addresses, even if an# IPv6 entry is added to `address` later, and make --test fail if the IPv4# address below is ever removed.address_family = "ipv4_only"
[outbound.settings]# [Interface] PrivateKey. Generate your own pair with:# wg genkey | tee private.key | wg pubkeyprivate_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="# [Peer] PublicKeypeer_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="# [Peer] Endpointendpoint = "203.0.113.10:51820"# [Interface] Address, without the /32address = ["10.2.0.2"]# Conservative MTU for an IPv4-only tunnel; use your service's value if it# gives one.mtu = 1280# [Peer] PersistentKeepalivekeepalive = 25# Only if your service asks for it:# reserved = [0, 0, 0]
[route]default = "direct"# Read because the rule below uses a geosite matcher.geosite = "/etc/etemenanki/geosite.dat"
# Rules are tried from top to bottom; the first match wins. Inside one rule# the matchers are alternatives: any one of them is enough.[[route.rule]]outbound = "wg"domain_suffix = ["example.com"]geosite = ["netflix"]The keys and the endpoint are placeholders that the parser accepts. The next section fills them in from your wg-quick file. For the geosite matcher, download the v2fly domain list (published as dlc.dat, see Split routing) and save it at the path in [route].geosite. If you route by domain only, drop both the geosite matcher and the geosite path: --test reads the file and fails when it is missing.
Translate a wg-quick file
Section titled “Translate a wg-quick file”Services hand out a file like this one. The comments in the TOML above name the wg-quick line each value came from.
[Interface]PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=Address = 10.2.0.2/32DNS = 192.0.2.53
[Peer]PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=AllowedIPs = 0.0.0.0/0Endpoint = 203.0.113.10:51820PersistentKeepalive = 25-
Add the outbound. Give it a tag your rules will name. Do not add
server,portor an[outbound.stream]table: the peer’s address lives insettings.endpoint, and WireGuard carries itself over UDP. A WireGuard outbound ignoresserverandport, and refuses a stream table.[[outbound]]tag = "wg"protocol = "wireguard"[outbound.settings] -
Copy the keys.
PrivateKeybecomesprivate_key, and the[Peer]section’sPublicKeybecomespeer_public_key. If the file has aPresharedKeyline, copy it topreshared_key; otherwise leave that key out.private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="peer_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="Paste the values exactly, including the trailing
=. Each key is 32 bytes in padded base64 (whatwg genkeyandwg pubkeyprint) or 64 hex digits.--testchecks only the encoding: a key that decodes but belongs to someone else passes, and the peer then never answers. -
Copy the endpoint.
Endpointbecomesendpoint, ashost:port.endpoint = "203.0.113.10:51820"The value is split at the last
:. Write an IPv6 endpoint without brackets,2001:db8::1:51820: brackets are not stripped, so[2001:db8::1]:51820passes--testand then fails when the tunnel starts. A host name is resolved with the system resolver when the tunnel starts, not with[dns]and not during--test, and the first answer is used. -
Copy the address, without the prefix length.
Address = 10.2.0.2/32becomesaddress = ["10.2.0.2"]. Each address is a separate string; a list such as10.2.0.2/32, 2001:db8:a::2/128becomes["10.2.0.2", "2001:db8:a::2"].address = ["10.2.0.2"]A prefix fails the build with
invalid IP address syntax/in `address`. The families you list here are the families the tunnel can reach: without an IPv6 entry, IPv6 destinations are skipped whateveraddress_familysays. -
Copy the keepalive.
PersistentKeepalivebecomeskeepalive, in seconds (at most65535). Keep it when this host sits behind NAT or a stateful firewall: it stops the UDP mapping to the peer from expiring while a connection is idle. Leaving the key out, or writing0, turns keepalives off.keepalive = 25 -
Set the MTU, and pin the family if the tunnel is IPv4-only. If the file has an
MTUline, copy it tomtu; the default is1420. When the service gives you an IPv4 tunnel only (one IPv4Address, or an IPv6 address that the service does not actually route), add these two lines:# on the [[outbound]] table, next to protocoladdress_family = "ipv4_only"# in [outbound.settings]mtu = 1280address_family = "ipv4_only"makes the outbound use only the IPv4 addresses of a destination. With a single IPv4 entry inaddressthe tunnel already skips IPv6 destinations, so here the setting is a guard:--testchecks it. Ifaddresshas no IPv4 entry, the build fails withoutbound wg: wireguard address_family ipv4_only needs an IPv4 address.- If the service also lists an IPv6 address that it does not route, and you copy it into
address, the outbound still never tries a destination’s IPv6 addresses. With the defaultautoit would try them in the resolver’s order, each attempt waiting up to 10 seconds before it moves on.
mtu = 1280is a conservative value, the smallest MTU IPv6 allows, that fits paths narrower than the usual 1500 bytes. If the service specifies an MTU, use that value instead. -
Add
reservedonly if the service asks for it. WireGuard packets carry three reserved header bytes, normally zero. A few services use them as a client identifier. If yours gives you three numbers, write them as a list:reserved = [1, 2, 3]If it gives a short base64 string instead (four characters, for example
AQID), decode it to three numbers first:Terminal window printf '%s' 'AQID' | base64 -d | od -An -tu1# 1 2 3etemenanki-app writes these bytes into every packet it sends and clears them in every packet it receives. Without an instruction from the service, leave
reservedout: a standard WireGuard peer reads these bytes as part of the message type, so non-zero values make it drop your packets. -
Leave the rest behind. These wg-quick lines have no counterpart in the outbound:
wg-quick line Where it goes instead AllowedIPsThe route rules decide which flows enter the tunnel (next section). DNSNothing changes on the host. Destinations reached through the tunnel are resolved on this host with the [dns]resolver.ListenPortThe UDP socket to the peer uses an ephemeral port. Table,PreUp,PostUp,PreDown,PostDownNo interface or host routes exist to manage. A key copied by mistake fails loudly:
invalid settings: unknown field `allowed_ips`, followed by the list of valid keys. -
Check the file.
Terminal window etemenanki-app --test -c wireguard-egress.tomlIt prints
Configuration OK.when the syntax, the key encodings and the routing are valid. It does not contact the peer.
The result, side by side:
[Interface]PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=Address = 10.2.0.2/32DNS = 192.0.2.53
[Peer]PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=AllowedIPs = 0.0.0.0/0Endpoint = 203.0.113.10:51820PersistentKeepalive = 25[[outbound]]tag = "wg"protocol = "wireguard"address_family = "ipv4_only"
[outbound.settings]private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="peer_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="endpoint = "203.0.113.10:51820"address = ["10.2.0.2"]mtu = 1280keepalive = 25katana nodes can carry the same tunnel too, with katana’s own field names (for example local_address, which does accept a prefix). The katana outbound documentation covers that format.
Choose what goes through the tunnel
Section titled “Choose what goes through the tunnel”In wg-quick, AllowedIPs = 0.0.0.0/0 sends the whole machine through the tunnel. Here, only flows that a route rule sends to wg enter it. The example uses one rule:
[route]default = "direct"geosite = "/etc/etemenanki/geosite.dat"
[[route.rule]]outbound = "wg"domain_suffix = ["example.com"]geosite = ["netflix"]- Matchers inside a rule are alternatives. A flow to
example.com, to any subdomain of it, or to any domain on thenetflixgeosite list goes towg. - The first matching rule wins. Put a
blackholerule for ads above this one if you want those blocked even for tunnelled sites; put this rule above any broad rule that would otherwise claim the same flows. - Everything else uses
default. Name it explicitly. Without the line, the first[[outbound]]in the file is the default, which isdirecthere only because it is listed first. - To tunnel everything instead, set
default = "wg"and route the exceptions todirect. - By address. A
cidrorgeoipmatcher sends flows by destination IP. It never matches a destination that the client gave as a domain.
Domain rules work best when the client passes names to the proxy rather than resolving them itself: socks5h:// in curl and most tools, “proxy DNS when using SOCKS v5” in browsers. When a client sends a bare IP address, sniffing still recovers the name from a TLS or HTTP request so the rule can match, but the flow is then dialled to that IP. With ipv4_only, an IPv6 address sent this way cannot enter the tunnel. The routing page covers every matcher.
Verify the exit before production
Section titled “Verify the exit before production”A config that passes --test has correct syntax. It has not yet proved that the peer forwards your traffic. Check that with a throwaway process before any real traffic depends on the tunnel. The check shares nothing with a running proxy: separate process, separate file, separate port.
-
Make a check copy. In an empty directory, copy the config and change three things: the port (any free one, here
10808),default = "wg"so that every request goes through the tunnel, and, to keep the check self-contained, remove the[[route.rule]]block and thegeositepath.wg-check.toml [[inbound]]tag = "check"protocol = "socks"listen = "127.0.0.1"port = 10808[[outbound]]tag = "direct"protocol = "freedom"[[outbound]]tag = "wg"protocol = "wireguard"address_family = "ipv4_only"[outbound.settings]private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="peer_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="endpoint = "203.0.113.10:51820"address = ["10.2.0.2"]mtu = 1280keepalive = 25[route]default = "wg" -
Validate it.
Terminal window etemenanki-app --test -c wg-check.toml -
Start it in the foreground with WireGuard logging.
RUST_LOGoverrides[log].level.Terminal window RUST_LOG=info,boringtun=debug,etemenanki_app=debug etemenanki-app -c wg-check.toml -
Note this host’s own address. In a second terminal, ask an IP echo service without the proxy:
Terminal window curl -m 30 https://ifconfig.me -
Ask again through the tunnel.
Terminal window curl -m 30 --socks5-hostname 127.0.0.1:10808 https://ifconfig.meThe answer must be the peer’s exit address, different from step 4. Any IP echo service works; one that also reports the address’s location confirms the exit region.
-
If the first request fails or times out, run it once more before you conclude anything. The tunnel starts only when the first flow arrives, so the first connection waits while the tunnel comes up and completes its handshake, and it can fail even when the configuration is right. A failure on the second attempt is a real failure.
-
Stop the process with Ctrl-C. It leaves nothing behind except the check file.
curl exercises TCP only. The outbound also carries UDP, which needs a client that speaks SOCKS5 UDP ASSOCIATE to test; the SOCKS inbound allows it by default (udp = true).
Confirm the split in the real config
Section titled “Confirm the split in the real config”Once the real config runs, check both paths through port 1080:
curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.mereturns this host’s address, because the echo service matches no rule and usesdirect.- To see the tunnel path, temporarily add the echo service to the WireGuard rule, for example
domain_full = ["ifconfig.me"], save the file and repeat the request. It now returns the exit address. Remove the line afterwards.
etemenanki-app reloads the file on save. A reload replaces every outbound, so it drops open connections and the tunnel starts again with a fresh handshake on the next flow; see Editing a live config safely.
How the tunnel behaves
Section titled “How the tunnel behaves”| Situation | What happens |
|---|---|
Startup and --test |
Nothing is opened. The first flow routed to wg resolves endpoint, binds the UDP socket and starts the tunnel; the handshake follows with its first packet. |
| Many flows | All flows routed to one outbound share one tunnel and one UDP socket. Each flow has its own bounded buffers inside the tunnel, so one slow flow does not hold up the others. |
| A destination that stops reading | Only that flow’s sender waits. Its data fills the flow’s 64 KiB socket buffer, then a queue of 256 pending writes, and then the proxy stops reading from that client until the destination reads again. The other flows on the tunnel keep moving. |
| A UDP datagram that cannot be sent | A datagram larger than the association’s whole 64 KiB send buffer, or one whose destination the tunnel cannot address, is dropped. Later datagrams on the same association still go out. A datagram that only finds the buffer full is not dropped: it waits until the buffer drains. |
| A TCP destination with several addresses | The outbound tries each allowed address in turn, 10 seconds each. |
| A UDP datagram to a domain | Goes to the first allowed address; a name that does not resolve drops the datagram. |
| The peer stops answering | The tunnel stays up and is not rebuilt, because a silent peer is not a socket error. Each new TCP connection gives up after 10 seconds per address with wireguard: tunnel TCP connect failed for all resolved addresses (…: timed out). |
| The tunnel’s socket fails | 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. After a failed start, or a tunnel that died within 10 seconds, the outbound waits 2 seconds before the next attempt, doubling up to 30, and flows fail meanwhile with wireguard: tunnel is down, waiting before the next attempt. |
Tunnel lifecycle on the protocol page has the details.
All WireGuard settings
Section titled “All WireGuard settings”Every key under [outbound.settings] for protocol = "wireguard". Unknown keys are refused. The common outbound key address_family sits on the [[outbound]] table itself; for WireGuard it applies to destinations inside the tunnel, never to endpoint.
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
private_key | string | yes | — | 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_key | string | yes | — | The peer's public key, the PublicKey line of the [Peer] section. Same encodings as private_key. Error: invalid wireguard peer_public_key. |
preshared_key | string | no | — | 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. |
endpoint | string | yes | — | 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. |
address | array of IPs | yes | — | 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. |
mtu | integer | no | 1420 | Largest 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. |
keepalive | u16 | no | — | 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. |
reserved | array of integers | no | — | 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. |
address_family accepts auto (the default), ipv4_only, ipv6_only, prefer_ipv4 and prefer_ipv6, case-insensitive, with - counted as _ and short aliases such as ipv4 and v4. An unrecognised value fails with outbound wg: invalid address_family "…". See Outbounds for the full list.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause and fix |
|---|---|
invalid IP address syntax / in `address` |
A prefix in address. Write "10.2.0.2", not "10.2.0.2/32". |
outbound wg: invalid wireguard private_key (or peer_public_key, preshared_key) |
A truncated paste, a missing = or a stray character. |
invalid length 2, expected an array of length 3 / in `reserved` |
reserved needs exactly three numbers from 0 to 255; a larger number fails with invalid value: integer `300`, expected u8. A base64 string fails with invalid type: string "…", expected an array of length 3; decode it as in step 7. |
wireguard: tunnel start failed: … in the log |
The tunnel could not start, for example because a host name or a bracketed IPv6 address in endpoint did not resolve. The error follows the colon. |
wireguard address_family ipv4_only needs an IPv4 address |
address_family names a family that address does not cover. |
geosite code not found: … |
The code is not in your geosite.dat. |
The log repeats Sending handshake_initiation, never a response |
The peer does not answer: check endpoint, peer_public_key, preshared_key, reserved, and whether outbound UDP is blocked. |
| Handshake succeeds, every request times out | The peer does not forward for this key. Retry once; if it persists, suspect an expired or revoked key or account first, and re-issue the config before you chase MTU or routing. |
| Small pages load, large downloads stall | The MTU is too large for the path. Lower mtu, for example to 1280. |
wireguard: no usable ipv4_only destination address for … |
The destination has no IPv4 address, or the client sent an IPv6 address. Only IPv4 fits this tunnel. |
| A site you listed still shows this host’s address | The flow did not match the rule: check the domain, and make the client send names (socks5h), or an earlier rule claims the flow. |
The protocol page explains more of the WireGuard log lines.