zeptun

Project Url: Noisemux/zeptun
Introduction: High-performance tun2socks engine in Zig with TCP, UDP, ICMP, SOCKS5, io_uring, and C API.
More: Author   ReportBugs   
Tags:

ci

A userspace network engine for TUN devices, written in Zig with no dependencies. It turns the packets an operating system routes into a tunnel interface back into TCP, UDP and ICMP flows, and forwards them through a SOCKS5 proxy, directly, or to the embedding application.

Features

  • IPv4 and IPv6, dual stack, fragment reassembly.
  • TCP through a userspace terminator, through kernel NAT, or both at once.
  • UDP with full-cone NAT by default, plus address and address-port modes.
  • Segmentation offload in both directions: TSO, USO, UDP_SEGMENT and UDP_GRO, through SOCKS5 as well.
  • Multi-queue TUN with elastic workers: queues are attached and detached under load, and live connections migrate between them without loss.
  • SOCKS5 with a warm connection pool, pipelined handshakes, optimistic data, UDP ASSOCIATE and UDP over TCP.
  • Fake-IP DNS, DNS hijacking, and DNS handover to systemd-resolved.
  • Automatic routing: policy rules, prefix include and exclude lists, UID, interface and Android package rules, strict route, nftables auto-redirect.
  • ICMP echo forwarding, ICMP time exceeded for expired packets, and upstream ICMP errors translated back to the client.
  • The interface can live in its own network namespace while upstream sockets stay outside.
  • io_uring or epoll on Linux, kqueue on BSD and macOS, IOCP on Windows.
  • C ABI, TOML and JSON configuration, per-flow verdict callback.
  • Linux, Android, Windows, macOS, iOS and FreeBSD.

Benchmarks

Measured by the benchmark workflow on a GitHub-hosted runner, against four other engines over the same SOCKS5 server.

throughput cpu transactions memory

The second memory bar is read after the load stops: the packet pool hands idle pages back to the kernel instead of holding them for the life of the process.

Method, runner specification, latency, UDP, memory and the raw tables: docs/bench.

Build

Zig 0.16.0 is the only requirement.

Unix

git clone https://github.com/Noisemux/zeptun
cd zeptun
make
sudo make install

make install places zeptun in /usr/local/bin, libzeptun.a and zeptun.h beside it, conf/zeptun.toml in /etc/zeptun if no configuration exists yet, and a systemd unit in /usr/local/lib/systemd/system.

Android

ANDROID_NDK_HOME=/path/to/ndk sh scripts/build_android.sh

This builds libzeptun.so and libzeptun.a for armeabi-v7a, arm64-v8a, x86 and x86_64, then libzeptun-jni.so from src/jni/zeptun_jni.c. All load segments are aligned to 16 KB, as Android 15 and later require. Without the NDK the script stops after the Zig libraries.

iOS and macOS

sh scripts/make_xcframework.sh

This produces zig-out/Zeptun.xcframework with the iOS device, iOS simulator and macOS slices. Package.swift exposes it to SwiftPM.

Windows

zig build -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseFast
make wintun

The tunnel uses Wintun, which is vendored in third-part/wintun with its licence and header, so no download is needed: make wintun copies the right wintun.dll next to the executable (WINTUN_ARCH=arm64 for arm64). The released Windows archives already contain it.

Library

zig build -Doptimize=ReleaseFast

This writes zig-out/lib/libzeptun.a, the shared library and zig-out/include/zeptun.h. Other targets: make TARGET=aarch64-linux-musl, or zig build cross for the whole matrix.

Use

Configuration

preset = "desktop"
log_level = "warn"
stats_interval_s = 0

[tun]
name = "zeptun0"
mtu = 8500
queues = 0
address = ["172.19.0.1/30", "fdfe:dcba:9876::1/126"]

[stack]
mode = "userspace"
udp_nat = "endpoint_independent"
icmp = "auto"

[handler]
kind = "socks5"

[handler.socks5]
server = "127.0.0.1:1080"
udp_mode = "udp"
pool_size = 4

[route]
auto_route = true
exclude = ["192.168.0.0/16"]

[dns]
fake_ip = false
hijack = false
systemd_resolved = true

conf/zeptun.toml carries every key with its default. The same document is accepted as JSON. Unknown keys are rejected instead of ignored.

Section Keys
top level preset (desktop, server, mobile), log_level, log_file, pid_file, stats_interval_s, post_up_script, pre_down_script
[tun] name, fd, mtu, queues, offload, multi_queue, persist, napi, jumbo, txqueuelen, configure, netns, guid, address
[stack] mode (userspace, hybrid, system), tcp_rx_window, tcp_tx_buffer, tcp_mss_clamp, tcp_initial_cwnd, congestion, sack, timestamps, window_scaling, tcp_connect_timeout_ms, tcp_idle_timeout_ms, tcp_delayed_ack_ms, tcp_early_accept, udp_idle_timeout_ms, udp, udp_nat, icmp, max_tcp_sessions, max_udp_sessions, nat_port_base, nat_port_limit
[handler] kind (socks5, direct, passthrough), tcp_fastopen, preserve_dscp
[handler.socks5] server, username, password, udp, udp_mode (udp, tcp), udp_address, pipeline, optimistic_data, pool_size, pool_idle_ms
[handler.direct] fwmark, bind_interface
[route] auto_route, table, rule_priority, fwmark, include, exclude, include_file, exclude_file, strict, allow_app, auto_redirect, redirect_port, include_uid, exclude_uid, include_interface, exclude_interface, include_package, exclude_package, android_user
[io] backend (auto, io_uring, epoll), workers, elastic (auto, on, off), rx_parallel, tx_slots, multishot_rx, busy_poll_us, pin_cpus, monitor_network
[dns] fake_ip, fake_ranges, cache_size, ttl, address, hijack, upstream, systemd_resolved
[memory] budget_bytes, buffers_per_worker

Run

sudo zeptun run -c /etc/zeptun/zeptun.toml

Every key has a flag as well, so the file is optional:

sudo zeptun run --tun zeptun0 --mtu 8500 --socks5 127.0.0.1:1080 --auto-route

With --auto-route the engine installs the addresses, routes and policy rules itself and removes them when it stops, on Linux, macOS, FreeBSD and Windows. Upstream sockets carry a firewall mark, so proxy traffic never re-enters the tunnel. Without it nothing outside the interface is touched.

zeptun help lists every flag. The ones that matter most:

Flag Default Meaning
--stack userspace userspace, hybrid or system
--queues CPUs upper bound for TUN queues and worker threads
--elastic auto attach and detach queues by load; off keeps them all attached
--mtu 1500 use 8500 or more with offloads
--socks5 ADDR:PORT proxy address; also --socks5-user, --socks5-pass, --socks5-udp-mode, --socks5-pool
--handler socks5 socks5, direct or passthrough
--auto-route off install addresses, routes and policy rules
--auto-redirect off Linux: redirect TCP into a kernel socket with nftables
--strict-route off refuse traffic for an address family the tunnel does not carry
--exclude CIDR keep a prefix off the tunnel; also --route, --route-file, --exclude-file
--netns NAME create the interface inside a network namespace
--udp-nat MODE endpoint-independent address and address-port restrict the NAT
--fake-ip off answer A and AAAA from a private pool and dial by domain
--dns-hijack off capture DNS sent to any address
--systemd-resolved auto hand DNS to the tunnel through resolvectl
--icmp auto forward, local or drop echo requests
--io auto io_uring or epoll
--stats N 0 print counters every N seconds

Container

docker run --rm --device /dev/net/tun --cap-add NET_ADMIN --cap-add NET_RAW \
  -e SOCKS5_ADDR=172.17.0.1 -e SOCKS5_PORT=1080 ghcr.io/noisemux/zeptun
services:
  tun:
    image: ghcr.io/noisemux/zeptun
    cap_add:
      - NET_ADMIN
      - NET_RAW
    devices:
      - /dev/net/tun:/dev/net/tun
    environment:
      TUN: zeptun0
      MTU: 8500
      IPV4: 172.19.0.1/30
      IPV6: fdfe:dcba:9876::1/126
      HANDLER: socks5
      SOCKS5_ADDR: a.b.c.d
      SOCKS5_PORT: 1080
      SOCKS5_USERNAME: user
      SOCKS5_PASSWORD: pass
      SOCKS5_UDP_MODE: udp
      AUTO_ROUTE: 1
      EXCLUDED_ROUTES: a.b.c.d/32
      LOG_LEVEL: warn

  client:
    image: alpine
    tty: true
    network_mode: "service:tun"
    depends_on:
      - tun

docker/entrypoint.sh turns the environment into /run/zeptun.toml. Set CONFIG to point at your own file instead, or pass arguments to run any subcommand.

API

C

#include <zeptun.h>

ZeptunConfig config;
zeptun_config_init(&config, ZEPTUN_PRESET_DESKTOP);
config.handler_kind = ZEPTUN_HANDLER_SOCKS5;
config.auto_route = 1;
snprintf(config.socks5_server, sizeof config.socks5_server, "127.0.0.1:1080");

Zeptun *tun = NULL;
if (zeptun_create(&config, &tun) != ZEPTUN_OK) return 1;
zeptun_start(tun);
...
zeptun_stop(tun);
zeptun_destroy(tun);
Function Purpose
zeptun_config_init(config, preset) fill the configuration struct with the defaults of a preset
zeptun_create(config, out) create an engine from the struct
zeptun_create_from_toml(text, len, out) create an engine from a TOML document
zeptun_create_from_json(text, len, out) the same document as JSON
zeptun_start(tun) spawn the workers and return once the tunnel is up
zeptun_run(tun) run one worker on the calling thread until zeptun_stop
zeptun_stop(tun) stop the engine from any thread, including from a callback
zeptun_destroy(tun) join the workers, remove routes and free the engine
zeptun_set_device_fd(tun, fd) use an existing TUN file descriptor
zeptun_set_adapter_guid(tun, guid) pin the Wintun adapter GUID on Windows, so the adapter keeps one identity across reinstalls
zeptun_set_read_callback(tun, cb, ctx) receive the packets that leave the engine
zeptun_write_packets(tun, packets, count) inject packets into the engine
zeptun_set_protect_callback(tun, cb, ctx) approve every upstream socket before it connects
zeptun_set_flow_callback(tun, cb, ctx) decide per flow: proxy, direct, drop or reject
zeptun_stats(tun, stats) lock-free snapshot of the counters
zeptun_memory(tun, memory) packet pool usage and how much memory has been returned to the kernel
zeptun_network_changed(tun, index) tell the engine that the default route moved
zeptun_strerror(code) message for an error code

Callbacks run on worker threads and must not block. Every setter except zeptun_stop, zeptun_stats, zeptun_memory and the packet functions must be called before zeptun_start.

Kotlin

libzeptun-jni.so registers its methods on the class dev.zeptun.Zeptun:

package dev.zeptun

import android.net.VpnService

object Zeptun {
    external fun nativeStart(service: Any?, fd: Int, config: String?): Int
    external fun nativeStop()
    external fun nativeVersion(): String
    external fun nativeCounter(index: Int): Long

    init {
        System.loadLibrary("zeptun-jni")
    }
}

class ZeptunService : VpnService() {
    fun start(fd: Int, config: String) {
        val rc = Zeptun.nativeStart(this, fd, config)
        if (rc != 0) throw IllegalStateException("zeptun start failed: $rc")
    }

    override fun onDestroy() {
        Zeptun.nativeStop()
        super.onDestroy()
    }
}

nativeStart takes the descriptor from VpnService.Builder.establish() and an optional TOML document; null uses the mobile preset. The service object is held as a global reference and its protect(int) method is called for every upstream socket. Change ZEPTUN_JNI_CLASS in src/jni/zeptun_jni.c to register on another class.

Swift

import Zeptun

var config = ZeptunConfig()
zeptun_config_init(&config, UInt32(ZEPTUN_PRESET_MOBILE))
config.device_kind = UInt32(ZEPTUN_DEVICE_FD)
config.tun_fd = tunnelFileDescriptor

On iOS take the descriptor from NEPacketTunnelProvider, or use ZEPTUN_DEVICE_EXTERNAL with the packet callbacks to stay inside the NetworkExtension API.

Stacks

Mode TCP UDP Notes
userspace own terminator own sessions works everywhere, keeps no kernel state, required for elastic queues
system kernel sockets behind NAT kernel sockets Linux only, lowest CPU for bulk transfer
hybrid kernel NAT with a userspace fallback own sessions default where the system stack exists

Elastic queues start with a single worker and add a queue only while it raises throughput, then release it when the load drops. Connections and sessions move between workers without dropping a packet, so the idle footprint stays small on a busy machine.

Testing

Command Scope
zig build test unit tests and the deterministic TCP simulation
zig build test-netns unit tests inside a user and network namespace with real TUN devices
zig build test-ffi tests/ffi_smoke.c compiled with -Wall -Wextra -Werror against libzeptun.a
zig build test-integration 189 end-to-end checks through a real TUN device between two namespaces
zig build bench hot-path microbenchmarks with a regression gate

Full documentation lives in the wiki: Building, Configuration, Command line, Routing, Stacks, C API, Android, Apple, Container, Benchmarks, Testing.

The integration suite covers every stack mode, both io backends, one and four queues, direct and SOCKS5, offloads on and off, UDP over TCP, fake-IP, strict routing, auto-redirect, elastic queue rotation under load, and the network namespace mode. The TCP simulation drives the real terminator against a model peer over lossy, reordering links and verifies every byte, leaving no buffers, timers or completions behind.

Platform support

Platform Device Event loop Routing
Linux /dev/net/tun, multi-queue, vnet header io_uring or epoll rtnetlink, policy rules, nftables
Android VpnService descriptor, or /dev/tun with root epoll package and UID rules with root
Windows Wintun IOCP IP Helper, WFP filters
macOS, iOS utun, NetworkExtension kqueue route socket
FreeBSD tun kqueue route socket

License

MIT

Apps
About Me
GitHub: Trinea
Facebook: Dev Tools
AI Daily Digest