Skip to content

Latest commit

 

History

189 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Tunless icon

tunless

TUN-less transparent proxying. No fake IP, no route hijack, no second TCP stack.

CI License: MIT

You already run mihomo or sing-box. The nodes work, the rules are tuned, the subscription updates itself. The annoying part is getting your applications to actually use any of it.

Some of them read HTTPS_PROXY. Plenty never look, and those go straight out without telling you. So you turn on TUN mode, because it catches everything — and now your resolver hands out addresses from 198.18.0.0/15 that mean nothing to anyone but the proxy that minted them, your routing table has been rewritten by something that cannot put it back, and if that process dies you find out by losing the network.

tunless is the third option. It catches connections one layer higher, at the socket, before they have turned into packets. Up there the kernel still knows the hostname the application asked for and which program asked, so there is nothing to reconstruct and nothing to fake. It hands what it catches to the proxy you already run, as ordinary SOCKS5.

Three ways to send an unmodified application through a proxy. Asking each application to use proxy settings works only when it cooperates, and many do not. A TUN device catches all of them but must hand out fake addresses and rewrite the routing table. Catching the connection at the socket layer catches all of them without faking anything.

Why one layer higher changes everything

A TUN device is handed packets. By that point the name you typed is gone — all that is left is an address and a port. But your proxy's rules are written about names, so the name has to come back somehow, and there are only two ways to do that. Either answer DNS with a made-up address and remember which name you gave it out for, or read the TLS handshake on the way past and hope the hostname is still in the clear.

Both work most of the time. Both also explain the failures people actually hit: a made-up address that outlives the mapping behind it connects fine and then transfers nothing, and encrypted client hello is quietly removing the second option.

At the socket layer none of that is necessary, because nothing has been thrown away yet.

The operating system network stack with two capture points marked. At the socket layer the destination is still github.com:443 and the calling process is known. At the routing table only 140.82.116.4:443 remains and the process is unknown, so a TUN device must fabricate DNS answers or read the TLS handshake to recover the name.

That is the whole idea. The rest follows from it:

  • Names resolve to real addresses, so nothing leaks a fake one into a cache, a log, or an application that checks its own DNS.
  • The routing table is never touched, so there is nothing to roll back and nothing left pointing at a device that no longer exists.
  • Flows stay on the kernel's own sockets. There is no second TCP/IP stack in userspace reimplementing what the kernel already does.
  • If tunless stops, connections go out the normal way. The kernel owns the capture, so killing the process releases it. We test that with SIGKILL rather than claiming it.
  • Your proxy keeps its job. tunless replaces the inbound, not the rules.

If you want the longer argument, including what was rejected and why, that is in BLUEPRINT.md.

How it works

The path one connection takes. An unmodified application makes an ordinary socket call, tunless catches it at the socket layer, applies its filters and trusted DNS override, and hands it to an existing proxy as plain SOCKS5 with the hostname intact. If tunless stops running, the connection goes out normally instead.

An application calls connect() the way it always has. The capture point lives inside the operating system's own socket layer — eBPF cgroup hooks on Linux, a Network Extension on macOS, WFP on Windows — so the flow arrives with its real destination and its calling process still attached. tunless decides whether to take it, sends port-53 traffic to a resolver you chose, and emits the rest as plain SOCKS5. Your proxy sees a normal SOCKS5 client.

Platform How it captures Where it stands
Linux eBPF cgroup connect/sendmsg/recvmsg and sockops TCP and UDP over both address families, on the host and inside container namespaces. Unconnected UDP is single-destination on purpose; see the note under migration.
macOS NETransparentProxyProvider system extension Notarized builds pass the recorded live suites. Upstream degradation and Wi-Fi/hotspot changes retain fail-closed capture while transports rebuild. Clean-machine qualification of the exact candidate is still open.
Windows WFP ALE connect-redirect callout Source only. The driver has never been built by a WDK, loaded, or run under Driver Verifier, and it is not signed. Treat it as a design, not a download.

The three platforms ship at different maturities and the release says so on its face: in 0.3.0 Linux is generally available, macOS is beta, and Windows is source only. See what a release covers and where the project actually is.

The portable core does the SOCKS5 work for all three: TCP and UDP emission, HTTP CONNECT and SOCKS5 reference inbounds, capture filters, a DNS observer that watches real answers, and two optional ways to pass process identity downstream.

Capture costs about a quarter of a percent of throughput. On a steady link, 112.82 MB/s captured against 113.13 MB/s direct, at 1.35 CPU-seconds and about 10 MB of memory per gigabyte relayed. Every number in this repository is written down with the machine and the date it came from, in measurements and release gates.

Quick start (Linux)

You need cgroup v2, kernel 5.7 or newer, and enough privilege to load and attach BPF programs. Kernels 5.10, 6.1 and 6.8 have all been through the verifier and the live runtime suite here.

make
sudo make install
sudo systemctl enable --now tunless

The installed unit reads /etc/tunless.env if present and defaults to:

TUNLESS_UPSTREAM=127.0.0.1:7890
TUNLESS_CGROUP=/sys/fs/cgroup/user.slice
TUNLESS_DNS_UPSTREAM=1.1.1.1:53

Packages install this optional environment file as root-owned mode 0600 because an upstream URL may contain SOCKS credentials.

TUNLESS_UPSTREAM has to name the listener your proxy actually has. The 7890 above is mihomo's own default; Clash Verge Rev's mixed port is 7897. Prove it rather than assume it — --check runs the preflight and prints a machine-readable report without starting capture:

sudo tunless --check --upstream 127.0.0.1:7890

It reports SOCKS5 CONNECT and UDP ASSOCIATE separately, because an upstream that relays TCP while refusing UDP is usable and degraded rather than broken: captured UDP fails and applications fall back to TCP where they can. Knowing that before you enable capture is the difference between a known limitation and what looks like a failing network.

Run mihomo or sing-box as a system service, outside user.slice. The tunless unit lives in system.slice for the same reason. This is not an exception list you have to maintain — it is just keeping the proxy out of the scope that gets captured, so its own outbound connections do not come straight back to it.

For an isolated scope or development test:

sudo ./tunless --upstream 127.0.0.1:7890 \
  --backend linux --cgroup /sys/fs/cgroup/my-apps

Destination filters are evaluated in the BPF hook, so excluded flows stay direct instead of being accepted and then dropped:

sudo ./tunless --upstream 127.0.0.1:7890 \
  --cgroup /sys/fs/cgroup/my-apps \
  --include-destination 0.0.0.0/0 \
  --include-destination ::/0 \
  --exclude-destination 192.168.0.0/16 \
  --exclude-destination fc00::/7

--include-destination is an allowlist for both address families at once, so naming only IPv4 prefixes leaves IPv6 direct rather than capturing all of it, and a prefix covers a destination however the program reached it — a runtime that opens one dual-stack socket for both families is filtered the same as one that opens an IPv4 socket.

Some destinations stay direct no matter what the filters say: loopback, the unspecified address, link-local — which is where a cloud instance asks about itself — and multicast and broadcast. A proxy has no way to carry any of them, so capturing them loses the traffic rather than routing it, and --include-destination 0.0.0.0/0 above is wide enough to swallow all of them.

Captured queries whose original destination port is 53 are sent to the numeric --dns-upstream through SOCKS5 by default, with query IDs randomly translated per outstanding request. Use --disable-dns-override to retain each application's original resolver.

That default has two costs, and both are opt-in to buy back. A geographically aware name resolves from where the tunnel exits, so a nearby service hands the host a distant address. And an upstream outage does not degrade name resolution, it ends it — including for destinations you already excluded from capture, which stay reachable but unresolvable.

--dns-direct names a resolver reached without the proxy, and --dns-direct-prefix the addresses that make its answer credible. Both resolvers are asked; the direct one is believed only when it returns an address inside that set, and an answer outside it is never served — a good answer for a distant service and an injected one are the same message, so the trusted resolver decides, and if it cannot the query fails rather than being answered with something unverified. --direct-domain and --trusted-domain decide the same thing from the question instead, before anything leaves the host, and the two compose. Only answers from the trusted resolver are ever learned from for hostname recovery. With none of these set nothing changes: every captured query goes through the proxy exactly as before. See resolver selection.

Containers and virtual machines

Containers need no TUN device, policy route, NAT rule, proxy environment variable, or privileged process inside them. One command captures an existing container:

TUNLESS_UPSTREAM=127.0.0.1:7890 ./scripts/tunless-docker.sh my-dev-container

The same helper works on native Linux and macOS Docker Desktop; a PowerShell equivalent covers Windows Docker Desktop. Watch-mode variants attach to Dev Containers automatically, and rootful Podman, containerd, and CRI-O have their own helpers. See the container notes.

macOS and Windows

On macOS, notarized development builds of the tunless Network Extension and its small launcher app have passed the recorded live tests, but the exact release candidate still requires clean-machine qualification. Presets support coexistence with Clash Verge, including one running its own TUN device.

Installing it requires Apple Developer Program membership, and that is a gate rather than a preference: a system extension signed only locally cannot activate while SIP is enabled, so there is no build-it-and-run-it path here. You either sign with a Developer ID and notarize — CI does this, and signing and notarization does it by hand — or you disable SIP and use systemextensionsctl developer on, which is a development posture and not one to leave a machine in. Once installed, a working start is two commands: check, then start.

Capture there is accountable for the network it takes over. It refuses to start when the upstream cannot relay DNS, verifies resolution through the live datapath afterwards, and then keeps re-proving it. A degraded upstream remains fail-closed for eligible traffic; Wi-Fi, hotspot, sleep, and configuration changes invalidate stale transports and rebuild them without closing application-owned UDP flows. Only configured exclusions, reserved endpoints, resolver-loop prevention, split-horizon local DNS, and sockets an application explicitly bound to an interface are direct. The last category is a generic per-socket opt-out and can be disabled with --capture-bound-flows. See deploying without losing the network. On Windows, the WFP backend is implemented but not yet release-qualified — treat it as source, not a shippable driver. Loading a kernel driver on Windows 10 or later requires a Microsoft signature that this project does not obtain in the current phase, so Windows builds are test-signed only. Details: macOS notes · Windows notes.

Migrate from mihomo TUN

Keep mixed-port and your rules. Delete the TUN block, DNS hijack, fake-IP pool, and fake-IP filters. Current mihomo calls its real-answer mode redir-host.

 mixed-port: 7890
-tun:
-  enable: true
-  stack: mixed
-  auto-route: true
-  auto-redirect: true
-  auto-detect-interface: true
-  dns-hijack: ["any:53", "tcp://any:53"]
 dns:
   enable: true
-  enhanced-mode: fake-ip
-  fake-ip-range: 198.18.0.1/16
-  fake-ip-filter: [...]
+  enhanced-mode: redir-host

Then start the service with TUNLESS_UPSTREAM=127.0.0.1:7890. References: mihomo TUN and DNS.

Both halves of that diff matter. Removing the tun block without changing enhanced-mode leaves fake-IP answers being minted for anything that still reaches that resolver, and a fake address whose TUN is gone connects and then transfers nothing. If something stops working after the TUN comes down, the causes are enumerated in I turned the TUN off and some things stopped working.

Browsers are the case worth calling out, because they fail while curl on the same machine works. A browser resolves names with its own DNS client, so the operating system has no name to attach to the flow and your proxy is handed whatever address that lookup produced — which, on a network that answers DNS falsely, is somebody else's server. tunless closes this from both ends: the query is captured whatever the resolver's address is, and the answer is remembered so the connection that follows is emitted under its name. See why does a browser fail when curl works?

Migrate from sing-box TUN

Remove the tun inbound and any hijack-dns rules used solely by it. Keep or add a loopback mixed inbound for tunless:

 "inbounds": [
   {
-    "type": "tun",
-    "tag": "tun-in",
-    "address": ["172.18.0.1/30", "fdfe:dcba:9876::1/126"],
-    "auto_route": true,
-    "auto_redirect": true,
-    "strict_route": true,
-    "stack": "mixed"
+    "type": "mixed",
+    "tag": "tunless-in",
+    "listen": "127.0.0.1",
+    "listen_port": 7890,
+    "set_system_proxy": false
   }
 ]

Point TUNLESS_UPSTREAM at 127.0.0.1:7890. References: sing-box TUN and mixed inbound.

Downstream PROCESS-NAME rules see tunless, not the original application. Move capture-time process selection into the cgroup on Linux or process filters on macOS. Destination, domain, node, and subscription rules remain downstream.

Proxying only some applications

A fair question before installing any of this: does it cost you the routing you already set up? Mostly no.

tunless hands your proxy an ordinary SOCKS5 request with the hostname in it, so domain rules, rule-sets, GEOIP, node selection and subscriptions all match the way they always did. A TUN carrying a real address has only the address to work with, so if anything you get more precision here, not less.

The exception is a PROCESS-NAME rule. SOCKS5 has nowhere to put process identity, so every captured flow reaches the proxy looking like it came from tunless. You do not lose the ability to select by application, though — it moves to the place where the operating system still knows the answer:

# macOS: capture these two, leave everything else alone.
Tunless start --preset clash-verge --upstream 127.0.0.1:7897 \
  --include-process /usr/bin/curl \
  --include-process com.apple.Safari

Patterns match a signing identifier, an executable path, or just the file name, so --include-process '/opt/homebrew/*/xray' picks out one program even when its toolchain left it with a generic identifier that half the binaries on the machine share. On Linux the selection is the capture scope: put the applications you want proxied in one cgroup and point --cgroup at it.

Anything you did not select never reaches the proxy at all. No rule evaluation, no involvement, straight out. That is the real difference from TUN mode, where everything enters the proxy and gets sorted once it is inside.

Want different applications on different nodes? Run one tunless per group, each pointed at its own listener on the proxy, and keep the per-listener rules downstream where node selection already lives.

Linux unconnected UDP associations are deliberately single-destination. A second destination on the same socket fails with a permission error while the association is active, instead of risking a reply with the wrong apparent source; after the association closes, that socket may select a new destination. Use a connected socket or separate sockets when destinations must overlap. One edge case remains unsupported: simultaneous unconnected UDP6 sockets using SO_REUSEPORT to share the exact source endpoint are ambiguous at the redirect listener. Avoid shared-source SO_REUSEPORT for captured UDP6 workloads.

Where the project actually is

0.3.0 is a release, not a preview. Linux is generally available: capture is exercised against a live kernel on every pull request, the destination filters are demonstrated against an attached cgroup, artifacts rebuild byte-identically, and every performance claim in this file is backed by a dated measurement naming the host it ran on. macOS remains beta and Windows remains source only, for reasons given below rather than left for you to find.

That framing changed for a reason worth stating. 0.1.0 said the gap was duration of evidence rather than function, and the gap turned out to contain a real defect: running the macOS build on a live host for a day surfaced a resolver-lifetime bug that no test on this project would have caught, and 0.2.0 closes it. The lesson is kept rather than declared solved — the list below is what is still unproven.

Four things in particular are worth knowing before you install it:

  • The macOS fix in this release has not been qualified on a live host. It carries unit tests that stand a real SOCKS5 upstream up and kill it, and the defect it closes was diagnosed on a live machine — but a locally signed system extension cannot activate while SIP is enabled, so running the fixed build requires the notarized path, and that has not been done. This is why macOS stays beta.
  • The 48-hour soak has still not been completed. Almost every serious bug found here turned up by running the thing for hours rather than by running its tests, including this release's. The harnesses exist on both platforms and neither has been run to completion.
  • The kernel floor is unverified on this code. Capture is tested on current kernels continuously, but the 5.10 evidence predates this release's datapath changes.
  • Rootful Podman is not reliable. Podman commands against a container tunless is attached to — removing it, entering it — hang in roughly two runs in five on podman 5.8.4 with netavark. Docker and containerd do not show it. The cause is unknown. Use Docker for now.

Packages, SBOMs, checksums and OCI images are built by a manual workflow, described in the release process. Every gate, including the ones nobody has met yet, is written down in docs/MEASUREMENTS.md — read that table before deciding this belongs on your machine.

If you have a Windows machine, the driver there needs somebody who does — the open work is listed in the Windows notes.

Documentation

Document Contents
docs/FAQ.md The questions people actually ask: rules, app selection, TUN, crashes, DNS
BLUEPRINT.md Design of record: the thesis, the faults in TUN mode, rejected alternatives
docs/CONTAINERS.md Docker, Podman, containerd/CRI-O, Docker Desktop, VMs
docs/MACOS.md Network Extension build, signing, Clash Verge preset, recovery
docs/WINDOWS.md WFP driver design, build, and release gates
docs/OPERATIONS.md Preflight checks, health API, capacity, DNS override, metadata, recovery
docs/FLOW_ATTRIBUTION.md What the agent knows about the process behind a flow, and what it refuses to guess
docs/REDIRECT_BACKEND.md The netfilter fallback for kernels the eBPF backend cannot run on
docs/MEASUREMENTS.md Dated performance and gate evidence, including what is not demonstrated
docs/THREAT_MODEL.md Assets, trust boundaries, risks, and explicit non-goals
docs/RELEASING.md · docs/RELEASE_CHECKLIST.md · docs/RELEASE_NOTES.md Release procedure, review gates, and the notes for the version being prepared (maintainers)

Contributing

Please do. CONTRIBUTING.md has the setup — go test -race ./..., go vet, swift test, and the privileged integration suites — and what review looks like. Found a security problem? SECURITY.md has the private reporting path.

One thing worth being clear about before you file an issue: tunless captures sockets on the machine it runs on. It is not a VPN, not a rule engine, not a collection of proxy protocols, not a GUI, and not a mobile backend. It will not touch traffic passing through from other machines, or raw ICMP, ESP, GRE and friends. Those are not gaps waiting to be filled; they belong to the proxy downstream, or to something else entirely.

MIT licensed.

About

TUN-less transparent proxying: socket-layer flow capture that hands ordinary SOCKS5 to your existing proxy. No fake IP, no route hijack, no second TCP stack.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

103 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages