TailProxy combines LD_PRELOAD syscall interception (like proxychains) with Tailscale's tsnet library to create a transparent proxy that routes any application's traffic through a Tailscale network.
Purpose: Intercept network syscalls and redirect to SOCKS5 proxy
Intercepted Functions:
connect()- Main interception point for outbound TCP connectionsbind()- Rewrites bind addresses to loopback (export mode only)listen()- Detects new listeners and notifies Go (export mode only)close()- Tracks listener closure (export mode only)getaddrinfo()- DNS resolution (passed through, not intercepted)gethostbyname()- Legacy DNS resolution (passed through)
How it works:
- Uses
dlsym(RTLD_NEXT, "connect")to get the original syscall - When
connect()is called, checks if it's a TCP socket - Skips localhost connections (to avoid intercepting proxy connection)
- Connects to local SOCKS5 proxy instead of original destination
- Performs SOCKS5 handshake with original destination info
- Returns to application as if connected to original destination
SOCKS5 Protocol Implementation:
// 1. Greeting
[0x05, 0x01, 0x00] // Version 5, 1 method, no auth
// 2. Connect request
[0x05, 0x01, 0x00, ATYP, ADDR, PORT]
// ATYP: 0x01 (IPv4), 0x03 (domain), 0x04 (IPv6)
// 3. Response
[0x05, 0x00, ...] // Version 5, successWhen TAILPROXY_EXPORT_LISTENERS=1 is set, the preload library also intercepts server-side syscalls:
bind() Interception:
// Original: bind(fd, 0.0.0.0:8000)
// Rewritten: bind(fd, 127.0.0.1:8000)
// IPv4: Any non-127.x.x.x address → 127.0.0.1
// IPv6: Any non-::1 address → ::1listen() Notification:
// After successful listen(), send via Unix socket:
"LISTEN tcp4 8000\n"close() Notification:
// When a listener FD is closed:
"CLOSE tcp4 8000\n"FD Tracking:
- Maintains a table mapping FDs to socket info (family, port, is_listener)
- Thread-safe via pthread mutex
- Tracks TCP sockets through bind→listen→close lifecycle
Purpose: SOCKS5 proxy that routes through Tailscale
Key Operations:
- Creates embedded Tailscale node using
tsnet.Server - Listens on
127.0.0.1:1080for SOCKS5 connections - Accepts SOCKS5 handshake from preload library
- Uses
tsnet.Server.Dial()to connect through Tailscale - Bidirectional data forwarding between client and remote
tsnet Integration:
srv := &tsnet.Server{
Hostname: "tailproxy",
Dir: "/tmp/tailproxy-<hostname>",
}
conn, err := srv.Dial(ctx, "tcp", "destination:port")The srv.Dial() automatically routes through:
- Tailscale network (WireGuard encrypted)
- Configured exit node (if specified)
- Internet or private network destination
Purpose: Manage tsnet listeners that forward to local services
Components:
- Control socket server (Unix stream socket)
- Port exporter instances (one per exported port)
- Port filtering (allow/deny lists)
- Reference counting for duplicate listeners
Control Socket Protocol:
LISTEN tcp4 <port>\n # Start exporting port
LISTEN tcp6 <port>\n # Start exporting port (IPv6)
CLOSE tcp4 <port>\n # Stop exporting port
CLOSE tcp6 <port>\n # Stop exporting port (IPv6)
Exporter Instance:
// For each exported port:
listener, _ := tsnetServer.Listen("tcp", ":8000")
for {
tsConn, _ := listener.Accept()
go forward(tsConn, "127.0.0.1:8000")
}Forwarding Logic:
- Accept connection from tailnet
- Dial local loopback on same port (try IPv4, fallback to IPv6)
- Bidirectional io.Copy between connections
Purpose: Orchestrate proxy server and command execution
Workflow:
- Parse command-line flags
- Start tsnet SOCKS5 proxy server in background
- If export mode enabled, start control socket and exporter manager
- Wait for proxy to be ready
- Set
LD_PRELOADenvironment variable - Set
TAILPROXY_*configuration env vars (including export settings) - Execute user command with modified environment
- On command exit, stop exporters and proxy server
Environment Variables (set for preload library):
TAILPROXY_HOST- Proxy host (127.0.0.1)TAILPROXY_PORT- Proxy port (1080)TAILPROXY_VERBOSE- Enable verbose loggingTAILPROXY_EXPORT_LISTENERS- Enable export mode (1 = enabled)TAILPROXY_CONTROL_SOCK- Path to control socket
Application calls connect("example.com", 80)
↓
[LD_PRELOAD intercepts]
↓
libtailproxy.so: connect("127.0.0.1", 1080)
↓
libtailproxy.so: SOCKS5 handshake(example.com, 80)
↓
Go Proxy Server receives SOCKS5 request
↓
srv.Dial("example.com:80") via tsnet
↓
Tailscale routes through:
- WireGuard tunnel
- Exit node (if configured)
- Internet
↓
Connection established to example.com:80
↓
Bidirectional forwarding
↓
Application reads/writes as normal
Application calls bind("0.0.0.0", 8000)
↓
[LD_PRELOAD intercepts]
↓
libtailproxy.so: bind("127.0.0.1", 8000) # Rewritten!
↓
Application calls listen(fd, backlog)
↓
[LD_PRELOAD intercepts]
↓
libtailproxy.so: Sends "LISTEN tcp4 8000\n" to control socket
↓
Go Exporter Manager receives notification
↓
ExporterManager: tsnet.Listen("tcp", ":8000")
↓
Tailnet client connects to tailproxy:8000
↓
Exporter accepts, dials 127.0.0.1:8000
↓
Bidirectional forwarding to local app
↓
Application handles request as normal
Exit nodes are configured at the Tailscale network level, not in the SOCKS5 protocol:
- User specifies
-exit-node=hostnameflag - Main program passes this to proxy server config
- tsnet uses Tailscale's routing preferences
- All
srv.Dial()calls automatically route through exit node
Note: Current implementation stores exit node preference but relies on tsnet's default routing. Full exit node support requires configuring Tailscale preferences via the LocalClient API.
- Requires dynamic linking (vulnerable to interception by design)
- Security-sensitive programs may ignore LD_PRELOAD (SUID binaries)
- All intercepted connections visible to preload library
- WireGuard encryption for all tunneled traffic
- Tailscale authentication required
- Auth tokens stored in state directory (protect with file permissions)
- Exit node must be trusted (sees cleartext traffic)
- No authentication between preload library and proxy (localhost only)
- Assumes localhost is trusted
- Proxy binds to 127.0.0.1 only (not accessible remotely)
- LD_PRELOAD: ~microseconds (function call overhead)
- SOCKS5 handshake: ~1-2ms (localhost)
- Tailscale routing: ~10-100ms (depends on exit node location)
- Total overhead: ~10-100ms per connection
- Limited by Tailscale/WireGuard throughput
- Typically 100-500 Mbps depending on CPU and network
- Go copy loop is efficient (io.Copy uses splice on Linux)
- Go proxy server: ~50-100MB (tsnet + dependencies)
- Preload library: ~100KB
- Per-connection overhead: ~16KB (buffers)
- Works: Dynamically-linked binaries using standard libc
- Doesn't work:
- Statically-linked binaries (no libc to intercept)
- Applications using raw sockets
- UDP traffic (different syscalls)
- Kernel-level networking
- DNS queries are NOT intercepted (by design)
- DNS resolution happens via system resolver
- IP addresses passed to SOCKS5 proxy
- Privacy: DNS queries visible to local DNS server
To intercept DNS, would need to:
- Intercept
getaddrinfo()and return fake IPs - Map fake IPs to real hostnames in proxy
- Send hostnames (not IPs) in SOCKS5 request
Current implementation has basic exit node support. Full support requires:
- Setting Tailscale preferences via LocalClient
- Waiting for exit node route to be established
- Verifying exit node is online and approved
- Handling exit node failover
-
C Library:
gcc -shared -fPIC -O2 -Wall -o libtailproxy.so preload.c -ldl -pthread
-shared: Create shared library-fPIC: Position-independent code (required for shared libs)-ldl: Link against libdl for dlsym()-pthread: Thread support for FD table mutex
-
Go Binary:
go build -o tailproxy main.go config.go proxy.go exporter.go
- Must specify files explicitly (avoid compiling .c file)
- Large binary (~32MB) due to tsnet dependencies
- DNS Interception: Intercept DNS for better privacy
- UDP Support: Intercept sendto/recvfrom for UDP
- Dynamic Exit Node: Switch exit nodes mid-session
- Connection Pooling: Reuse Tailscale connections
- IPv6 Support: Full IPv6 interception
- Performance Monitoring: Track latency, bandwidth
- Config Profiles: Saved configurations for different scenarios
- GUI: Graphical interface for non-technical users
- Export listeners idle timeout: Auto-close unused exporters
- dup/dup2/dup3 interception: Better FD tracking for export mode
- No connection failure retry logic
- Limited error messages from preload library
- Export mode: FD tracking doesn't handle dup() family
- C library: Test SOCKS5 handshake logic
- Go proxy: Test SOCKS5 server implementation
- Integration: Test end-to-end with real Tailscale
# Test basic functionality
./tailproxy echo "hello"
# Test network interception
./tailproxy -verbose curl https://ifconfig.me
# Test with exit node
./tailproxy -exit-node=us-exit curl https://ipinfo.io
# Test with non-proxy-aware app
./tailproxy python3 -c "import urllib.request; print(urllib.request.urlopen('https://ifconfig.me').read())"
# Test export listeners - bind rewrite
./tailproxy -export-listeners -verbose python -m http.server 8000
# Verify: server listens on 127.0.0.1:8000 (not 0.0.0.0)
# Test export listeners - tailnet access
# From another tailnet device:
curl http://tailproxy:8000/
# Test port filtering
./tailproxy -export-listeners -export-allow-ports="8000-8100" python -m http.server 8000- proxychains - Original LD_PRELOAD proxy tool
- tsnet documentation - Tailscale embedded networking
- SOCKS5 RFC 1928 - SOCKS Protocol Version 5
- LD_PRELOAD technique - Dynamic linker documentation