Skip to content

TUN

A TUN inbound (protocol = "tun") makes etemenanki-app create a virtual network interface on the host. The kernel routes packets for the networks you list into that interface, and etemenanki-app terminates them in a userspace IP stack: every TCP connection and every UDP flow becomes a proxied flow, routed through your [route] rules to an outbound, like a flow from any other inbound.

Use it to proxy programs that know nothing about proxies. A program connects to an address in a captured network as usual, and the connection leaves through the outbound, with no SOCKS or HTTP settings in the program. The price is that the inbound works on the host’s routing table, which you share with the proxy itself: read Keep the proxy’s own traffic off the device before you choose routes.

TUN inbound
What it creates A layer-3 (IP) interface, one per inbound
Carries TCP, and UDP while udp = true (the default). ICMP and every other protocol are dropped
listen, port Must be absent
[inbound.stream] Not used. A network other than "tcp" or a security other than "none" is refused
Authentication None. The device is local, and every flow is attributed to the inbound
Privilege CAP_NET_ADMIN or root to start. --test needs none
Routes Installed on Linux when the device is created, and removed with it. On other Unix systems you add them yourself
Direction Inbound only; there is no TUN outbound
flowchart LR
  P["Program on the host"] -->|"connect 203.0.113.7:443"| K["Kernel routing table"]
  K -->|"203.0.113.0/24 dev etm0"| D["TUN device etm0"]
  D --> S["Userspace IP stack"]
  S -->|"TCP connection"| R["Router"]
  S -->|"UDP flows, grouped per source"| R
  R --> O["Outbound, such as socks"]
  O -->|"dial on the real uplink"| N["Network"]
  1. At start, etemenanki-app creates the interface, assigns every address, and installs every entry of routes as a route through the device.
  2. The kernel sends each packet whose destination matches one of those routes (or a subnet an address prefix covers) into the device.
  3. The userspace stack answers the TCP handshake itself and hands each connection over as a stream. It groups UDP by the client’s source address and port.
  4. Each flow is routed on its destination IP address and port, its network (tcp or udp), the client’s source IP and the inbound’s tag, plus a sniffed domain for TCP. The chosen outbound dials the destination.
  5. When etemenanki-app stops or reloads, the device closes, and the kernel removes it together with its addresses and routes.

This config captures 203.0.113.0/24 on the host running etemenanki-app and sends its TCP and UDP traffic through an upstream SOCKS5 proxy at 192.0.2.10. The upstream is outside the captured network, which is what keeps the setup free of loops. UDP port 443 (QUIC) goes to a blackhole, so that browsers fall back to TCP, where sniffing can recover a domain.

tun.toml
# A TUN inbound that captures 203.0.113.0/24 on this host and sends its TCP
# and UDP traffic through an upstream SOCKS5 proxy. Starting it needs
# CAP_NET_ADMIN (or root); `--test` does not.
[log]
level = "info"
[[inbound]]
tag = "tun-in"
protocol = "tun"
# No listen and no port: the inbound owns a network interface instead.
[inbound.settings]
name = "etm0"
# A /32 gives the interface an address without routing a subnet into it.
address = ["10.77.0.1/32"]
# Only these networks enter the device. Host bits must be zero.
routes = ["203.0.113.0/24"]
mtu = 1500
udp = true
udp_idle_timeout = 60
max_flows = 65536
# The upstream proxy. Its address must stay outside `routes`, or the
# outbound's own connection would be routed back into the device.
[[outbound]]
tag = "upstream"
protocol = "socks"
server = "192.0.2.10"
port = 1080
[[outbound]]
tag = "block"
protocol = "blackhole"
[route]
default = "upstream"
# QUIC (UDP 443) cannot be sniffed for a domain. Dropping it makes browsers
# fall back to TCP and TLS, which domain rules can see.
[[route.rule]]
network = "udp"
port = ["443"]
outbound = "block"
  1. Check the config. --test validates the device settings as a start would, but creates nothing, so it needs no privilege:

    Terminal window
    etemenanki-app --test -c tun.toml

    It prints Configuration OK. or the first error.

  2. Give the process CAP_NET_ADMIN, as described in Privileges, and start it. The start-up log names the device:

    inbound tun-in owns tun device etm0
    inbound tun-in listening on tun etm0
  3. Look at what the kernel now has:

    Terminal window
    ip address show dev etm0
    ip route show dev etm0

    The second command lists 203.0.113.0/24. It disappears when etemenanki-app stops.

  4. Test with TCP, not ping. ICMP is dropped, so ping 203.0.113.7 never gets a reply even when everything works:

    Terminal window
    curl -v http://203.0.113.7/

    The request reaches 203.0.113.7 through the upstream proxy.

A TUN inbound uses the common [[inbound]] keys tag, protocol and sniffing from Inbounds. It has no listener, so listen and port must be absent: either one fails with tun owns a network interface and has no listener; remove listen/port. Its own keys go in [inbound.settings]:

KeyTypeRequiredDefaultDescription
namestringno—Name of the interface to create, for example "etm0". When absent, the kernel picks one (tun0, tun1, …) and the start-up log names it. Linux allows at most 15 bytes; a longer name passes --test and then fails at start with device name too long. Set a fixed name if firewall rules or scripts refer to the interface.
mtuu16no1500MTU of the interface and of the userspace IP stack behind it, in bytes. The minimum is 1280, the smallest MTU IPv6 allows; a lower value fails with tun mtu must be at least 1280. Values above 65535 fail to parse.
addressarray of stringsno[]Addresses assigned to the interface, each written as "address/prefix", IPv4 or IPv6, for example "10.77.0.1/32" or "2001:db8:77::1/64". Without a prefix, the address is a host address (/32 or /128). The prefix also makes the kernel route that whole subnet into the device. Host bits are allowed here. A malformed entry fails with bad tun address.
routesarray of stringsno[]Networks routed into the interface, in strict CIDR form such as "203.0.113.0/24": every host bit must be zero, so "203.0.113.5/24" fails with bad tun route "203.0.113.5/24": host part of address was not zero. Without a prefix, an entry is a host route. The routes are installed in the main table when the device is created and disappear with it. Installed only on Linux: on other systems a non-empty list fails with tun routes are installed only on Linux; add them with the OS route tool.
udpboolnotrueRelay UDP as well as TCP. When false, every UDP packet that reaches the device is dropped without a reply.
udp_idle_timeoutu64no60Seconds without traffic after which the userspace stack retires a UDP flow (one client address and port talking to one peer). An association polls all its flows together, so traffic on any of them keeps every flow alive. An association ends when its last flow is retired, and in any case after 300 seconds without a datagram in either direction. 0 is accepted but breaks UDP: a flow is retired right after its first datagram, before a reply can arrive.
max_flowsintegerno65536Upper bound on live flows across the device: each TCP connection counts as one, and so does each UDP association (all the UDP traffic of one client address and port). At the limit, a new TCP connection or association is dropped and logged at debug level. 0 is accepted and drops every flow.

Unknown keys in [inbound.settings] are rejected with invalid settings: unknown field, and the message lists the accepted keys. A misspelled routes therefore stops the config instead of starting a device that captures nothing.

Every value is checked while the config is built, so --test refuses the same values a start would. Some failures can only happen when the device is created, because --test creates nothing: missing privilege, a name longer than 15 bytes (device name too long), and a route the kernel refuses.

Both keys decide which packets enter the device, in different ways:

address routes
Purpose Gives the interface its own addresses Lists the networks to proxy
Format "10.77.0.1/32", "2001:db8:77::1/64", or a bare address (host prefix) Strict CIDR, "203.0.113.0/24", or a bare address (host route)
Host bits Allowed Must be zero
Traffic it captures The subnet its prefix covers, through the kernel’s own subnet route Exactly the listed networks
Platforms All Linux only

Both lists may mix IPv4 and IPv6 entries, and both may be empty. With routes empty, only an address subnet leads into the device; with both empty, the device exists but nothing reaches it until you add routes yourself.

A prefix on address is a route too. address = ["10.77.0.1/24"] sends all of 10.77.0.0/24 except 10.77.0.1 itself into the device, and packets to 10.77.0.1 stay local to the host. To give the interface an address without capturing a subnet, use a host prefix, /32 for IPv4 or /128 for IPv6, as the example does.

routes is strict because a network with host bits set is almost always a typo:

[inbound.settings]
routes = ["203.0.113.5/24"] # meant 203.0.113.0/24, or 203.0.113.5/32?
inbound tun-in: bad tun route "203.0.113.5/24": host part of address was not zero

Write "203.0.113.0/24" for the network or "203.0.113.5/32" (or "203.0.113.5") for the single host.

The routes go into the kernel’s main routing table as link-scope routes through the device (proto static), with no metric of their own, so the kernel’s default applies. etemenanki-app never removes them explicitly: they belong to the device, and the kernel deletes them when the device closes. etemenanki-app adds each route exclusively, never replacing one. If the main table already holds a route for the same prefix with the same metric, the kernel refuses the new one, and the start fails with route 203.0.113.0/24: Received a netlink error message File exists (os error 17).

Keep the proxy’s own traffic off the device

Section titled “Keep the proxy’s own traffic off the device”

Every connection an outbound makes is an ordinary socket on the host, and the kernel routes it with the same table the TUN routes are in. etemenanki-app does not mark its sockets or bind them to an interface. So if an outbound’s own destination falls inside routes, its connection is steered back into the device: the TUN inbound accepts it as a new flow, routes it to the same outbound, which dials again into the device. Nothing reaches the network. Each round of the loop holds another flow, so the flows pile up until max_flows (or the process’s file-descriptor limit) is reached, and from then on the inbound drops new flows, legitimate ones included.

flowchart LR
  P["Program"] -->|"to 203.0.113.7"| D["TUN inbound"]
  D --> O["socks outbound"]
  O -->|"to 192.0.2.10:1080"| K{"Is 192.0.2.10 inside routes?"}
  K -->|"no: default route"| U["Real uplink, upstream proxy"]
  K -->|"yes: route into etm0"| D

Before you write routes, list every address etemenanki-app itself connects to, and keep each one outside the captured networks:

What etemenanki-app connects to Where it comes from
The upstream server of every proxy outbound server of socks, http, shadowsocks, trojan, vless, vmess, hysteria2 outbounds
The UDP relay address a SOCKS5 upstream reports The upstream’s reply to UDP ASSOCIATE
A WireGuard peer endpoint of a wireguard outbound
DNS servers The resolver that turns outbound server names and domain destinations into addresses; see DNS
Every destination of a freedom outbound The flow’s own destination

The last row is the reason a TUN inbound and a freedom outbound do not mix. freedom dials the flow’s own destination, and every destination a TUN flow can have is inside a captured network by definition, so the dial goes straight back into the device. Route TUN flows to a proxy outbound or a blackhole, never to freedom. When other inbounds share the config and freedom is the default, put a rule with inbound_tag = ["tun-in"] and a proxy outbound before every other rule.

Creating an interface and installing routes need CAP_NET_ADMIN. Without it, --test still passes, but the start fails:

failed to start: inbound tun-in bind tun etm0 failed: Operation not permitted (os error 1)

Grant the capability in one of these ways:

Add the capability to the unit that runs etemenanki-app, so the process runs as an unprivileged user and holds only what it needs. Running etemenanki-app has a complete unit with a variant for a TUN inbound; these are the lines that matter here:

/etc/systemd/system/etemenanki.service (excerpt)
[Service]
User=etemenanki
AmbientCapabilities=CAP_NET_ADMIN
CapabilityBoundingSet=CAP_NET_ADMIN

Then run sudo systemctl daemon-reload and restart the service.

  • If another inbound listens on a port below 1024, list CAP_NET_BIND_SERVICE on both lines as well.
  • The device is created through /dev/net/tun. PrivateDevices=yes hides it from the service, so leave that option out.
  • Routes are installed over netlink. If the unit sets RestrictAddressFamilies=, include AF_NETLINK.

The userspace stack completes the TCP handshake itself, before any outbound is involved. A program therefore sees its connection open even when the outbound cannot reach the destination. What follows depends on the program’s first bytes:

Step What happens
Handshake Answered by the stack at once.
Waiting for the client Nothing is dialled until the program sends data. If it sends nothing for 10 seconds, the connection is closed.
Sniffing With sniffing = true (the default), the inbound holds the first bytes until it reads a TLS server name or HTTP Host from them, 4 KiB have arrived, or 300 ms have passed, whichever comes first. Traffic that is neither TLS nor HTTP therefore waits up to 300 ms before the dial.
Dial The routed outbound dials the destination IP and port. If the dial fails, the connection is closed.
Relay Bytes are copied both ways. The userspace stack has its own session timer, which etemenanki-app does not change: a connection that carries nothing in either direction for 60 seconds is reset. This comes before etemenanki-app’s 300-second relay idle limit.

Because nothing is dialled before the client speaks, a protocol in which the server speaks first, such as SMTP or MySQL, stalls through a TUN inbound and is closed after 10 seconds. Reach such services through another inbound, or keep them out of routes.

A sniffed domain only affects routing. The outbound still dials the IP address the program connected to.

Because of the 60-second session timer, a long-lived connection that can sit idle, such as an SSH session without keepalives, is reset through a TUN inbound. Enable application keepalives shorter than 60 seconds for such programs.

With udp = true, the inbound groups UDP by the client’s source address and port:

  • the first datagram from a source opens an association, which counts once against max_flows;
  • datagrams from the same source to other peers join the same association;
  • each datagram is routed on its own destination, so one association can reach different peers through different outbounds, and a datagram routed to blackhole is dropped while the rest keep flowing;
  • a reply is delivered only if it comes from exactly the address and port the client sent to. Replies from any other address or port, and replies that name a domain instead of an IP address, are dropped;
  • a payload larger than 4096 bytes is truncated to 4096 bytes, in either direction;
  • the userspace stack retires a flow (the client talking to one peer) after udp_idle_timeout seconds without traffic. The association polls all its flows together, so traffic on any of them keeps every flow alive. The association ends when its last flow is retired, and in any case after 300 seconds with no datagram in either direction.

UDP is never sniffed. Domain and geosite rules therefore never match UDP from a TUN inbound; route it by cidr, geoip, port or network. Which outbounds can carry UDP is listed on Outbounds; an outbound without UDP support drops the datagrams routed to it.

With udp = false, UDP packets are dropped without a reply. A program that uses UDP, including a DNS lookup to a server inside routes, then times out.

The stack has nowhere to send ICMP, and no other protocol than TCP and UDP is relayed. Those packets are dropped and logged at trace level. ping and traceroute to a captured address get no answer.

A TUN flow carries no domain of its own, only the IP address the program sent to. The routing matchers see these values:

Matcher What it sees for a TUN flow
inbound_tag The inbound’s tag
cidr, geoip The destination IP address
port The destination port
network tcp or udp
source_cidr The packet’s source IP. For programs on this host, that is usually an address of the device; for packets the host forwards from other machines, it is the original sender
domain_suffix, domain_keyword, domain_full, domain_regex, geosite The sniffed TLS server name or HTTP Host, for TCP with sniffing = true. Nothing otherwise

On every reload, including one caused by a comment-only edit, etemenanki-app stops the whole generation. The TUN inbound closes its device and waits up to 3 seconds for the descriptor to be released, so that the new generation can recreate an interface with the same name. Every connection through the device is dropped. See Hot reload.

If creating the device fails during a reload, the error is logged as inbound <tag> bind tun <name> failed: … and the other inbounds start without it. etemenanki-app does not retry: once the cause is fixed, save the config file again (any change to its bytes) or restart the service.

These fail --test and startup. The messages are prefixed with inbound <tag>:, and --test prints them after configuration invalid:.

Error Cause and fix
tun owns a network interface and has no listener; remove listen/port listen or port is set. Remove both.
tun mtu must be at least 1280 mtu is below the minimum. Use 1280 or more.
invalid settings: invalid value: integer `70000`, expected u16 mtu is above 65535.
bad tun route "203.0.113.5/24": host part of address was not zero A routes entry has host bits set. Write the network address, or a /32 or /128 host route.
bad tun address "10.77.0.1/33": invalid length for network: … An address entry is malformed or its prefix is too long for its family.
tun routes are installed only on Linux; add them with the OS route tool routes is not empty on a system other than Linux. Leave it empty and add the routes with the system’s tool.
protocol tun does not support stream network "ws", protocol tun does not support stream security "tls" [inbound.stream] sets a network other than tcp or a security other than none. Remove the block; a TUN inbound has no transport.
invalid settings: unknown field `…` A misspelled key in [inbound.settings]. The message lists the accepted keys.

These happen only when the device is created, at start or on a reload. At start they are reported as failed to start: inbound <tag> bind tun <name> failed: …, where <name> is auto when name is absent. On a reload the same text is logged without failed to start:.

Error Cause and fix
Operation not permitted (os error 1) The process lacks CAP_NET_ADMIN. See Privileges.
device name too long name is longer than 15 bytes.
route 203.0.113.0/24: Received a netlink error message … The kernel refused a route. File exists (os error 17) means the main table already has a route for that prefix with the same metric.

At run time the inbound logs these, at debug level unless noted:

Message Meaning
tun: dropping a flow; the flow limit is reached max_flows flows are live. A new TCP connection or UDP association was dropped. A routing loop also ends up here.
tun: tcp flow … ended: tun: the client never spoke A program opened a connection and sent nothing for 10 seconds.
tun: dropping a reply from …: the client never addressed it A UDP reply came from a peer the client had not sent to.
tun: device fd or N tcp flows still open after 3s warn: on shutdown or reload, the device took longer than 3 seconds to close. Recreating an interface with the same name may then fail.