A zero-configuration, fully remote-orchestrated network client. It starts in the background
(double-fork daemon), opens a single outbound connection to a control server on TCP port
7000, identifies itself with a pre-shared key, and then does nothing until a command byte
arrives. It has no config files, no dependency on systemd, no logs, and no local state — it is a
"dumb pipe."
./tun <control-server-ip> <psk>Example:
./tun 156.232.88.212 secretkey99Started with no arguments, the client re-reads the server and PSK it saved last time (see Survives a reboot).
The client immediately forks into the background and returns control to your shell (Linux/macOS/Android;
on Windows keep it running yourself, e.g. as a scheduled task). It sends
<psk>\n<device_id>\n, and the control server then drives it with fixed 17-byte command frames:
[0] command
[1..8] command payload
[9..16] peer node id (8 ASCII hex chars, or '0' when the command names no peer)
| Byte 0 (command) | Meaning | Payload (8 bytes) |
|---|---|---|
0x00 |
Reset / Idle | (none) — kills active FRP, TRP, P2P & hub sessions |
0x01 |
FRP Reverse Proxy | local_port (2B, big-endian) + remote_port (2B, big-endian) |
0x02 |
P2P legacy single peer | target_ip (4 raw bytes) + target_port (2B) |
0x03 |
TRP Pull Reverse Proxy | target_ip (4 raw bytes) + service_port (2B) + public_port (2B) |
0x04 |
P2P add peer | target_ip (4 raw) + target_port (2B); peer id in the node-id field |
0x05 |
P2P drop peer | (none); peer id in the node-id field |
0x06 |
P2P clear all | (none) |
0x07 |
P2P hub / rendezvous | hub_ip (4 raw) + hub_port (2B); carries our own id |
- FRP: the client listens on
local_port; every incoming connection is forwarded over a new TCP socket to<control-server-ip>:<remote_port>. - TRP (pull): the server (on behalf of a user who pinned a public port on the VPS) tells the
client to dial back
<control-server-ip>:<public_port>and its own local service (target_ip:service_port,0.0.0.0→127.0.0.1), then pumps the two. One session per command. - P2P: every device gets a random 8-hex-char device id, generated once and kept on disk, so a shared PSK can tell nodes apart and a reconnecting node reclaims its own record. Peers are addressed by that id rather than by position, which is what the node-id field is for.
- P2P hub (0x07): the usual way the mesh comes up. The client opens one UDP socket to the hub,
sends a
TUNkeepalive every second so the server can register its mapped endpoint, and aP1Hrendezvous request every other second. TheP1Rreply lists every online peer sharing the PSK (id+ip+port), so devices discover each other with no dashboard interaction. Each peer then gets its own socket punched to that endpoint; receiving anything at all on a peer socket proves the direct path is open, because hub-bound probes never reach a peer socket. - Reset: tears down all active pipelines and returns to idle. Payload is ignored.
The client also reports upward every 5s, unprompted:
[0] 0xFE
[1] peer count
then count x { node id (8 ASCII hex), flags (bit 0 = direct path open) }
0xFE is never sent by the server, so a frame starting with it is unambiguously a report. This is
how the dashboard shows which links are genuinely direct rather than still relying on the hub.
The client is silent by design (no pidfile, no stdout, no logs), so check it 3 ways:
# 1. Process is alive
pgrep -laf tun
# or
ps -ef | grep [t]un
# 2. Live control connection to your server:7000
ss -tnp | grep tun # Linux
# or
netstat -tnp | grep tun
# macOS: lsof -iTCP -a -p <pid>
# Windows: netstat -ano | findstr <pid>The ESTABLISHED socket show the client is currently connected to the control server. Note:
if the control link drops, the client reconnects on its own every ~2s and every ~5s while the
server is unreachable — the process stays alive either way, so pgrep is the reliable health
check.
To stop it:
pkill -x tun # kill by exact process name
# or
kill <pid-from-pgrep>The process name is the binary name (
tun). If you renamed the binary, use that name.
The client remembers which control server it belongs to, so a machine that reboots comes back on its own instead of waiting for someone to re-run the binary.
Saved state. The client keeps everything it needs in one state directory, and started with no arguments it reads them back:
# connection (mode 0600 — holds the shared secret)
server=156.232.88.212
psk=secretkey99
# device_id (8 hex chars — this node's identity in the P2P mesh)
6bb8a291This is the same "no local state" design as everything else: the files hold only the two values you
already typed on the command line plus an id that has to survive a reboot or the node would reappear
as a stranger. Delete the directory to wipe it. The directory is $HOME/.tun by default;
TUN_STATE_DIR=/path/to/dir ./tun or a third argument ./tun <ip> <psk> /path/to/dir puts it
somewhere else (handy for read-only install dirs). Missing directories are created, including
nested ones.
Auto-start. When the state is saved successfully and the process is root/admin, it also installs a boot hook. Each backend is best effort and silently skipped when it does not apply:
| Platform | Boot hook |
|---|---|
| Linux | /etc/systemd/system/tun.service + systemctl enable (only if systemd is present) |
| Windows | HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\tun (needs an elevated shell) |
| Android | /data/adb/service.d/tun.sh (Magisk runs this as root at boot) |
The hook records the absolute path of the binary and the state directory, because it re-runs the client with no arguments and could not otherwise find a non-default state directory.
# check what got installed
systemctl status tun # Linux
reg query "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /v tun # Windows
ls -l /data/adb/service.d/tun.sh # AndroidForeground mode. Boot hooks supervise the process directly, so when TUN_FOREGROUND=1 is set the
client skips the double-fork and stays in the foreground. That is what the systemd unit sets; a
manual ./tun <ip> <psk> still backgrounds as before.
Re-running
./tun <ip> <psk>re-points the client at a new server and rewrites both the state files and the boot hook. The connect loop itself already retries forever, so a reboot only needs the process to be started again.
| Component | What the operator sees |
|---|---|
| Control link | One outbound TCP connection from the node to server:7000. |
| FRP session | Node listens on local_port (check ss -tlnp | grep tun). |
| P2P session | Node holds a UDP socket; constant keepalive traffic to the target. |
| After Reset | All listeners/sockets from FRP and P2P are gone; node back to idle. |
Toggling is seamless: sending a new 0x01/0x02 replaces the previous FRP or P2P session
automatically, and a 0x00 always stops everything.
make builds tun for your host machine. The Makefile also exposes per-platform targets:
make linux, make windows, make macos, make android, or make release for all of them.
The repo ships .github/workflows/release.yml. Push a tag and GitHub Actions builds every
platform, attaching each binary plus its .sha256 checksum to the Release:
git tag v1.0.0
git push origin v1.0.0Or run the workflow manually (Actions tab → "release" → Run workflow): everything is uploaded as build artifacts, and attaching to a Release requires a tag push.
Supported binaries:
Linux: tun_linux_x86_64 tun_linux_arm64
macOS: tun_macos_x86_64 tun_macos_arm64
Windows: tun_windows_x86_64.exe tun_windows_i686.exe
Android: tun_android_arm64-v8a tun_android_armeabi-v7a tun_android_x86_64
On Windows, run via tun.exe <control-server-ip> <psk>. A statically linked x86_64 Linux build
runs on any x86_64 Linux; tun_linux_arm64 is for ARM64 Linux machines and phones.
Not routers. Consumer routers (RouterOS, OpenWrt, and friends) are deliberately not a target:
this client is a plain socket relay and never creates a tun0 interface, and sideloading a binary
onto locked-down router firmware is not something that can be automated. Point a router at the
control server from a supported host instead.
Android builds go through the NDK, so the binary links against bionic — never glibc. CI
unpacks NDK r27c (SDK version 27.2.12479018) straight from dl.google.com — no sdkmanager,
no JDK, no licence prompt — and calls the same make android target you can run locally:
export ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/27.2.12479018
make androidABIs are arm64-v8a, armeabi-v7a and x86_64, all at minSdk 21 (Android 5.0+). Keep the target
API at or below the oldest OS version you intend to support, or you will reference bionic symbols
that device simply does not have.
The binaries are PIE and dynamically linked against the device's own libc.so. That is deliberate:
bionic refuses to run statically linked executables on ARM/ARM64 below API 29
(executable's TLS segment is underaligned), and every device already ships the loader
(/system/bin/linker64) and libc, so there is nothing extra to bundle.
Android's W^X and SELinux policies mean a binary cannot be executed from the SD card or shared
storage (/sdcard, /storage/emulated). It has to live in app-private storage or a dedicated
environment like Termux.
Via adb (developers):
adb push tun_android_arm64-v8a /data/local/tmp/tun
adb shell chmod +x /data/local/tmp/tun
adb shell /data/local/tmp/tun <control-server-ip> <psk>Via Termux: download the binary, move it into ~/, then chmod +x tun and ./tun <ip> <psk>.
For general consumers the Play Store will not take a raw binary — it has to be wrapped in an app.
Put the matching ABI's binary in src/main/jniLibs/<abi>/ (or in assets/), then at first launch
copy it out to context.getFilesDir(), chmod +x it, and start it with ProcessBuilder or
fork()/exec() through JNI. A rooted device gets the same result more simply: the client installs
/data/adb/service.d/tun.sh for Magisk on its first run.
Simulate a control server, an echo service, and drive the client — no real network needed:
import socket, struct, threading, time, subprocess
def serve_echo():
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 9001)); s.listen(5)
c, _ = s.accept(); data = c.recv(1024); c.sendall(b"ECHO:" + data); c.close()
threading.Thread(target=serve_echo, daemon=True).start()
proc = subprocess.Popen(["./tun", "127.0.0.1", "secretkey99"])
ctrl = socket.socket(); ctrl.bind(("127.0.0.1", 7000)); ctrl.listen(1)
conn, _ = ctrl.accept()
print("psk received:", conn.recv(64))
# FRP: local port 9000 -> server port 9001
conn.sendall(struct.pack("B", 0x01) + struct.pack(">H", 9000) + struct.pack(">H", 9001) + b"\x00\x00\x00\x00")
time.sleep(1)
s = socket.create_connection(("127.0.0.1", 9000), timeout=5)
s.sendall(b"ping")
print("reply:", s.recv(1024)) # expect: b"ECHO:ping"- Auth: the PSK is sent in cleartext over the control channel. On hostile networks, wrap the
control link in a VPN or encrypt at a higher layer. The connection is always outbound, so NAT /
firewalls don't block the initial handshake. It is also stored on disk in the state directory
(as
connection, mode0600) so the client can restart itself after a reboot — see Survives a reboot. - Linux / macOS / Windows / Android. Linux binaries are statically linked (musl); macOS and
Windows use their native toolchains (Apple clang, MinGW-w64); Android uses the NDK against bionic.
The client daemonizes on Linux/macOS/Android but runs in the foreground on Windows unless
TUN_FOREGROUND=1is set. Kernel 3.2+ for the Linux builds; no libc or framework dependencies thanks to static musl builds. - BSD is not in the release matrix.
- Routers are not a target — see the note in Build. The client relays sockets; it never
creates a
tun0interface, so a router OS that requires one to be present gets nothing. - No runtime state beyond the two files in the state directory (
connectionanddevice_id): the orchestrator remains the only source of truth for what the pipelines should be doing.