Skip to content

About

Rootless SOCKS5 or port forwarding-over-QUIC tunnel

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

flextunnel

A TCP tunnel over QUIC with optional local SOCKS5/HTTP proxies and port forwards. Proxy requests matching the server's tunnel set go through the server, which resolves DNS and connects from its own network; off-list requests connect directly from the client. Port forwards always send their fixed target to the server, which enforces its routed set.

This lets you reach hosts that are only reachable from the server side — a private network, the server's own localhost, or names that only resolve via the server's DNS — without a VPN. Because it uses ordinary userspace sockets (no TUN device), neither the client nor the server needs admin/root.

Transport, NAT traversal, relay fallback, and TLS 1.3 encryption are provided by iroh: the client dials the server by its EndpointId, so the server needs no public inbound port or port forwarding.

local app ──SOCKS5/HTTP──► flextunnel client (optional listeners, e.g. 127.0.0.1:1080 / :8081)
                          │  one iroh QUIC connection (fixed ALPN + auth handshake)
                          │  ├─ control stream:  Hello / HelloResponse
                          │  └─ N data streams:  [target header][reply][raw bytes]
                          ▼
                      flextunnel server  (no root, no TUN)
                          │  per stream: resolve DNS ─► TcpStream::connect
                          ▼
                      target host:port  (reachable from the SERVER's network)

Scope

  • SOCKS5 CONNECT only. No UDP ASSOCIATE, no BIND. The optional HTTP proxy supports HTTP CONNECT plus absolute-URI plain-HTTP forwarding.
  • The local SOCKS5 and HTTP proxy listeners are unauthenticated, and each is disabled unless explicitly configured. Any local user or process that can reach them can use the tunnel, so they bind 127.0.0.1 only (you configure just the port, like the desktop client) and flextunnel assumes a trusted, single-user host. The client keypair authenticates the client process to the server; it does not authenticate local callers of the proxy front-ends, and is no substitute for OS-level access control on a shared machine.

Security model

flextunnel lets a set of trusted clients reach resources on the server's side of the network. Both ends are run by the same trusted party: whoever runs the server decides which client keys it authorizes. It is not a multi-tenant service and does not defend the server against the clients it admits — a client with an authorized key can, by design, reach whatever the server's network can reach (including the server's own localhost). Authorize keys accordingly, and scope the server's network access if that reach is too broad. The threats it does address are on-path attackers (encryption + per-client keypairs) and accidental misconfiguration (e.g. duplicate-id detection catching two clients or servers started with the same identity — an operator guard rail, not an adversary defense).

Normal client connections authenticate with an ed25519 keypair:

  • Client keypair — each client generates a keypair in the shared flexaccess-keys format (flexaccess-keys generate-auth-key); the server keeps the public keys in an ssh-style authorized-keys file. In the handshake the client sends its public key, its (ephemeral) iroh endpoint id, and a signature over that id; the server accepts only if the claimed id matches the connection's TLS-authenticated id, the signature verifies, and the key is authorized — the secret never leaves the client, and a captured handshake cannot be replayed from another endpoint.

(The keyless exceptions are server-to-server bridges and --quick sessions, whose credential is the peer's TLS-authenticated EndpointId, checked natively against an allowlist at the handshake — the authorized_keys model.)

The QUIC ALPN is a fixed protocol identifier (flextunnel/1), not a secret: it ensures both peers speak the flextunnel protocol but provides no access control on its own.

All payload is end-to-end encrypted by QUIC/TLS 1.3.

Install

Prebuilt release assets are published on the GitHub Releases page. Stable releases include flextunnel for Linux amd64/arm64, macOS arm64, and Windows amd64, plus the iOS xcframework asset. Automated prereleases currently include Linux amd64/arm64, macOS arm64, and the iOS xcframework, but skip Windows. The install scripts download the latest binary and verify its SHA-256 checksum. On Linux/macOS this installs to a per-user location (~/.local/bin, no root required). On Windows this installs system-wide to C:\Program Files\flextunnel and updates the machine PATH, which requires an elevated (Administrator) PowerShell session — running the installed binary afterward does not.

flextunnel (server / client) — Linux / macOS:

curl -sSL https://flexaccessdev.github.io/flextunnel/install.sh | bash

flextunnel (server / client) — Windows (PowerShell):

irm https://flexaccessdev.github.io/flextunnel/install.ps1 | iex

Options: append -s -- --prerelease (bash) for the latest prerelease, a release tag to pin a version, or --download-only / -DownloadOnly to fetch the binary without installing. -PreRelease is also accepted by the Windows installer when the selected prerelease includes a Windows asset. A container image is also published to ghcr.io/flexaccessdev/flextunnel.

Desktop app (tray GUI, server and client)

Stable releases also include the desktop app (flextunnel-desktop-macos-arm64.dmg and flextunnel-desktop-windows-amd64.msi), a tray app with two roles of equal weight, either one optional: client connections to any number of servers elsewhere, and this computer's own server (a generated identity, then the clients you authorize). See its README. The installers are unsigned — if you'd rather not apply the workarounds below, build it yourself instead:

cargo build --release -p flextunnel-desktop
# binary: target/release/flextunnel-desktop — locally built binaries are not
# quarantined, so no workaround is needed

That produces the bare executable, not a .app bundle — so on macOS it has no Info.plist and will show a Dock icon instead of running as a pure menu-bar app. For the proper bundle, build it the way CI does with cargo-packager (macOS only):

cargo install cargo-packager   # or: cargo binstall cargo-packager
cargo packager --release -p flextunnel-desktop --formats app
# bundle: target/release/flextunnel.app  (locally built → not quarantined,
# so no Gatekeeper workaround needed)

For the drag-to-Applications disk image instead, run ./build-macos-dmg.sh (also needs brew install create-dmg); it writes dist/macos/flextunnel-desktop-macos-arm64.dmg.

Otherwise:

macOS: because the app is not notarized, Gatekeeper quarantines the download and shows "flextunnel" is damaged and can't be opened.

The cleanest fix is to avoid the quarantine flag in the first place: browsers set com.apple.quarantine on downloads, but command-line tools like curl and wget do not. Download the disk image from the terminal instead:

# Replace vX.Y.Z with the release tag from the Releases page.
curl -fL -o flextunnel-desktop.dmg \
  https://github.com/flexaccessdev/flextunnel/releases/download/vX.Y.Z/flextunnel-desktop-macos-arm64.dmg

Then in Finder: double-click the .dmg to open it, drag flextunnel.app onto the Applications shortcut in the window (choose Replace if an older copy is already there), then eject the mounted image.

macOS zsh note: the shell snippets in this README include # comment lines. zsh (the macOS default shell) treats # as a comment on an interactive prompt only after setopt interactivecomments; without it, pasting a # line reports command not found. Run setopt interactivecomments once per session, or just omit the comment lines when pasting.

If you already downloaded the .dmg via a browser, the quarantine flag propagates to the app you copy out of it; remove it after installing (into /Applications) instead:

xattr -cr /Applications/flextunnel.app

Alternatively, right-click the app and choose Open the first time.

Windows: SmartScreen's "Windows protected your PC" warning is triggered by the Mark of the Web (the Zone.Identifier stream), which — like macOS quarantine — is set by browsers but not by command-line tools. Avoid it the same way: download with curl.exe (bundled in Windows 10+) or PowerShell instead of a browser. Replace vX.Y.Z with the release tag from the Releases page:

curl.exe -fL -o flextunnel-desktop.msi `
  https://github.com/flexaccessdev/flextunnel/releases/download/vX.Y.Z/flextunnel-desktop-windows-amd64.msi
# or: Invoke-WebRequest -OutFile flextunnel-desktop.msi `
#   https://github.com/flexaccessdev/flextunnel/releases/download/vX.Y.Z/flextunnel-desktop-windows-amd64.msi

If you already downloaded via a browser, strip the mark instead: Unblock-File .\flextunnel-desktop-windows-amd64.msi (or right-click → Properties → Unblock). Otherwise click More info → Run anyway.

Build from source

cargo build --release
# binary: target/release/flextunnel

Requires a recent Rust toolchain (edition 2024). A bare cargo build --release uses the workspace's default members and builds the CLI, but not the iOS static library. To cross-build the CLI for Linux amd64 + arm64 via Docker, use ./build-linux.sh.

Quick start

1. Generate credentials (once)

flextunnel generate-iroh-key -o server.key   # prints the server's EndpointId
flextunnel show-iroh-id --secret-file server.key   # re-print the EndpointId

Client authentication keys are managed by the standalone flexaccess-keys CLI. Install it with its one-line installer:

# macOS / Linux
curl -sSL https://flexaccessdev.github.io/flexaccess-keys/install.sh | bash
# Windows (PowerShell)
irm https://flexaccessdev.github.io/flexaccess-keys/install.ps1 | iex

Then:

# On each client machine: generate that client's keypair, then derive its
# public authorized-key entry. Send only the public entry to the server admin.
flexaccess-keys generate-auth-key "alice laptop" -o client.key
flexaccess-keys show-auth-key --private-key-file client.key

Without -o, the generate commands print the key file to stdout instead. Keep server.key and each client.key private (written 0600 on Unix; both key files carry # Created: / # Public key: comments). Share the server's EndpointId with clients, and collect each client's printed authorized-key entry (ed25519-pub:… — not a secret) into the server's authorized-keys file, one per line with an optional trailing comment, ssh authorized_keys style:

# ./authorized_keys
ed25519-pub:XXXXXXXX alice laptop
ed25519-pub:YYYYYYYY build server

All commands above accept --json for machine-readable output (QA automation).

2. Configure and run the server (no root needed)

The routed set is configured in server.toml. It is optional: flextunnel.internal (the status page) and localhost.flextunnel.internal (the server's own localhost) are always routed, so a server with no routed set still serves those. This example is a full tunnel; narrow it later with specific domains/CIDRs if you want split tunneling.

secret_file = "./server.key"

# Authorized client public keys — always a file (ssh authorized_keys style).
authorized_keys_file = "./authorized_keys"

routed_domains = ["*"]
routed_cidrs = ["0.0.0.0/0", "::/0"]

[host_aliases]
"server.internal" = "127.0.0.1"
flextunnel server start -c server.toml

It prints flextunnel server Node ID: <ENDPOINT_ID> — give that to clients.

3. Run the client (no root needed)

flextunnel client start \
    --server-node-id <ENDPOINT_ID> \
    --auth-key-file  client.key \
    --socks-port     1080            # SOCKS5 on 127.0.0.1:1080 (loopback only)

4. Use it

Point any SOCKS5 client at 127.0.0.1:1080. Use socks5h:// so routed hostnames reach flextunnel as names and are resolved on the server side:

# With the full-tunnel routed set above, DNS + connect happen server-side.
curl -x socks5h://127.0.0.1:1080 https://example.com

# a server-side host alias, for example server.internal -> 127.0.0.1
curl -x socks5h://127.0.0.1:1080 http://server.internal:8000/

# SSH through the tunnel (see `client nc`; needs no proxy port). -n names the
# running client by its server, since this one was started without a config.
ssh -o ProxyCommand='flextunnel client nc -n <ENDPOINT_ID> %h %p' user@internal-host

Quick ephemeral tunnel (no setup)

For a throwaway "route everything through this box for a few minutes" session, --quick skips all of the above — no key files, no config. There is no keypair auth at all: each side enters the other side's EndpointId. The client's id becomes the server's one-entry allowlist (enforced natively at the TLS handshake, like bridge allowlisting), and dialing the server's id is itself what authenticates the server:

# On the client host: prints this client's EndpointId (enter it on the server)
# and prompts for the server EndpointId, then shows a live control panel in
# this terminal. Needs an interactive terminal.
flextunnel client start --quick

# On the server host: prompts for the client EndpointId printed above and
# allowlists it as the only allowed client, then prints this server's
# EndpointId to enter at the client prompt. Full-tunnels all traffic; exits on
# its own if the client doesn't connect within 5 minutes. Nothing is persisted.
flextunnel server start --quick

Either side can start first — both block at their prompt until the other's id is entered.

The quick server runs a full tunnel (routed_domains = ["*"], routed_cidrs = ["0.0.0.0/0", "::/0"]); enter a SOCKS5/HTTP port at the client prompt to open a local proxy listener. Both sides are fully ephemeral: neither takes the single-instance lock (so a quick session can run alongside a real one, or another quick one), and both forget everything on exit.

The quick client is self-contained: after the prompt it runs the same live panel as client control right in that terminal — but it opens no control socket (nothing else can attach to it), and quitting the panel (q) disconnects the tunnel and exits, rather than detaching. A quick client reads no config, so it has no port forwards.

Server status page

From a connected client, flextunnel.internal (and every *.flextunnel.internal subdomain) is reserved by flextunnel and is always tunneled to the server, regardless of the routed set. The browser view is HTML; /status.txt is plain text and /status.json is structured JSON for scripts:

# plain-text status through a SOCKS5 listener configured on port 1080
curl -sS -x socks5h://127.0.0.1:1080 http://flextunnel.internal/status.txt

# JSON status through the same listener
curl -sS -x socks5h://127.0.0.1:1080 http://flextunnel.internal/status.json

# same endpoint through the optional HTTP proxy listener
curl -sS -x http://127.0.0.1:8081 http://flextunnel.internal/status.txt
curl -sS -x http://127.0.0.1:8081 http://flextunnel.internal/status.json

The JSON response includes version, server_node_id, routed_domains, routed_cidrs, host_aliases, dns_forwards, bridges, inbound_bridges, and duplicate-id blocklist counts under duplicate_id_blocklist.

Server loopback

localhost.flextunnel.internal is the server's own localhost, with no server config needed: it bypasses the routed set, bridges and host aliases, and is dialed on both 127.0.0.1 and ::1, so a service bound to only one of them is still reached. It works from the proxies and as a forward's remote_host:

# a web app listening on the server's loopback port 8000
curl -sS -x socks5h://127.0.0.1:1080 http://localhost.flextunnel.internal:8000/

Only the exact name maps to loopback; other *.flextunnel.internal subdomains answer with an HTTP 404.

For more ways to use the proxy — curl/git/browser recipes, ssh through the tunnel, and putting a plain local TCP port in front of it for apps that can't speak SOCKS5 (databases, RDP, most GUIs) — see docs/proxy-usage.md.

HTTP proxy front-end (optional)

Add --http-port <PORT> to also run an HTTP proxy alongside SOCKS5 — useful for the many tools that only speak an HTTP proxy or whose SOCKS5 support resolves DNS client-side (wget, Docker pulls, npm/yarn, JVM/JDBC). It handles HTTPS (and any TCP) via CONNECT tunneling and plain-HTTP via absolute-URI forwarding; either way the hostname goes to the proxy, so DNS still happens on the server.

flextunnel client start \
    --server-node-id <ENDPOINT_ID> \
    --auth-key-file  client.key \
    --socks-port     1080 \
    --http-port      8081

# HTTPS tunnels via CONNECT; plain HTTP is forwarded
https_proxy=http://127.0.0.1:8081 curl https://example.com
http_proxy=http://127.0.0.1:8081  curl http://example.com

See docs/http-proxy-roadmap.md for remaining HTTP proxy work. Apps without proxy support can use built-in port forwards.

Commands

Command Description
server start Run the proxy server.
client start Run the proxy client (optional SOCKS5 and HTTP proxy listeners, plus the port forwards declared in its config).
client control Attach the read-only terminal control panel to a running client.
client nc Relay stdin/stdout through a running client to a server-side host:port (SSH ProxyCommand).
client help Show the client subcommands and their help.
generate-iroh-key [-o <FILE>] [--force] [--json] Generate the server's iroh identity key (stdout without -o).
show-iroh-id --secret-file <FILE> [--json] Print the iroh id (EndpointId) for a key.

Client auth keypairs are generated with the standalone flexaccess-keys CLI (generate-auth-key / show-auth-key), not by flextunnel itself.

server start

Flag Description
-c, --config <FILE> Load options from a TOML file (CLI flags override it).
--default-config Load ~/.config/flextunnel/server.toml.
--secret-file <FILE> Server identity key.
--authorized-keys-file <FILE> File of authorized client public keys, one ed25519-pub:… per line (optional trailing comment, ssh authorized_keys style).
--relay-url <URL> Custom relay URLs (repeatable; at least two distinct URLs). See the shared relay guide for discovery and dialing requirements.
--relay-auth-token <TOKEN> Shared bearer token sent to every custom relay's WebSocket upgrade. Only valid with --relay-url (rejected with the default relays).
--quick Ephemeral one-off server: prompt for the client's EndpointId (shown by client start --quick) and natively allowlist it as the only allowed client — no auth keypair — then mint an in-memory identity, full-tunnel all traffic, print this server's EndpointId, and exit if the client doesn't connect within 5 minutes. Needs an interactive terminal. Takes no single-instance lock; nothing is persisted. Conflicts with -c/--secret-file/--authorized-keys-file.

The server takes a single-instance lock keyed by the prefix of its own EndpointId (~/.config/flextunnel/server-<key>.lock). So one server runs per identity per user: servers with different identity keys — including the desktop app's — run side by side without any extra configuration, while a second start of the same identity is refused.

client start

Flag Description
-c, --config <FILE> Load options from a TOML file (CLI flags override it). Without it, ~/.config/flextunnel/client.toml is used if present.
-n, --server-node-id <ID> Server EndpointId.
--socks-port <PORT> Optional SOCKS5 listener port, e.g. 1080. Binds 127.0.0.1 only. Disabled unless set.
--http-port <PORT> Optional HTTP proxy listener port (CONNECT + plain-HTTP forwarding). Binds 127.0.0.1 only.
--auth-key <SECRET> / --auth-key-file <FILE> Client auth keypair (one required): the inline ed25519-sec:… secret, or the key file from flexaccess-keys generate-auth-key.
--relay-url <URL> Custom relay URLs (repeatable; at least two distinct URLs, matching the server's set). See the shared relay guide above.
--relay-auth-token <TOKEN> Shared bearer token sent to every custom relay's WebSocket upgrade. Only valid with --relay-url (rejected with the default relays).
--auto-reconnect Force auto-reconnect on (overrides auto_reconnect = false in the config).
--no-auto-reconnect Exit on the first failed connection attempt or drop instead of retrying.
--max-reconnect-attempts <N> Cap consecutive retries before giving up (unlimited if unset).
--quick Self-contained ephemeral session (pairs with server start --quick): ignore any saved config, print this client's EndpointId (enter it at the quick server's prompt — that allowlist entry is the credential; no auth keypair), prompt for the server EndpointId, then run the live control panel in this terminal. Needs an interactive terminal. Takes no lock and opens no control socket; quitting the panel disconnects. Nothing is persisted. Conflicts with -c/--auth-key(-file).

flextunnel client start needs at least one flag — run with no arguments and it prints help. With a flag but no -c, it loads ~/.config/flextunnel/client.toml if it exists (so flextunnel client start --socks-port 1080 runs off the default config). --quick ignores any saved config, prints this client's EndpointId (its credential — enter it on the quick server; there is no auth keypair), and prompts (on an interactive terminal) for the server EndpointId and optional proxy ports, then runs the self-contained control panel described under client control right in that terminal — but with no control socket exposed, and quitting the panel disconnects instead of detaching. Nothing is saved.

Port forwards are declared in the config as [[forwards]] tables (see client.toml.example): each listens on localhost:<local_port> and opens a server-direct stream to <remote_host>:<remote_port> on the authenticated connection (the server enforces its routed set and resolves the host). They are validated at startup (nonzero ports, valid host, unique local ports) and all come up with the client. There is no CLI flag and nothing can be changed live: to change the set, edit the config and restart the client.

Forwarding to the server's loopback works differently from ssh -L. With ssh, -L 8080:localhost:80 always reaches the remote's own port 80. The flextunnel equivalent is remote_host = "localhost.flextunnel.internal", a reserved name that always means the server's own loopback and needs no server config. Any other spelling is checked against the server's routed set first, like any other tunneled target, and loopback is not allowed by default. What the server needs depends on how remote_host is written:

remote_host Checked against Server config needed
localhost.flextunnel.internal nothing (reserved) none
127.0.0.1 / ::1 routed_cidrs a loopback CIDR, e.g. routed_cidrs = ["127.0.0.0/8", "::1"]
localhost routed_domains (it is a hostname) "localhost" (or "*") in routed_domains
a [host_aliases] name, e.g. server.internal → 127.0.0.1 routed_domains, before aliasing the alias name in routed_domains

When the check passes, localhost and loopback literals are dialed on both 127.0.0.1 and ::1, so a service bound to only one of them is still reached. When it fails, nothing is dialed: the client logs Forward localhost:<port>: server rejected target: connection not allowed and the local connection closes. Covering loopback in the routed set also opens the server's loopback to SOCKS5/HTTP proxy users of that server, not just to forwards.

With neither --socks-port nor --http-port (nor the config keys) the client runs in port-forward-only mode: it holds the tunnel and serves only the control panel and the port forwards.

A client's on-disk identity — the single-instance lock and the control socket — is keyed by the prefix of its server_node_id (which never changes for a profile). So one client runs per server per user, and clients for different servers coexist without any extra configuration. The optional name key in the config ("aws", "home network") is a display-only label shown in the control panel. (A --quick client is exempt: it takes neither — no lock, no socket.)

client control

flextunnel client control                # profile from ~/.config/flextunnel/client.toml
flextunnel client control -c aws.toml    # profile from a specific config
flextunnel client control -n <ENDPOINT_ID>   # or by server id directly

Attaches a read-only terminal control panel to the running client for a profile, over its control socket (~/.config/flextunnel/client-<server id prefix>.sock; a named pipe on Windows). It shows live status — connection phase and uptime, server/client node ids, connection paths (direct/relay), the server-pushed routing breakdown (split-tunnel rules, host aliases, DNS forwards, and bridge routes), and the port forwards declared in the client's config with their live state (listening, active connections, or switched off with the bind-failure reason).

Nothing about the client can be changed from the panel: the forward set is fixed by the config for the life of the client (edit the config and restart to change it), and the channel carries no mutations. It runs as the same user as the client — no elevated privilege on either end.

Detaching (q) never affects the tunnel; several panels can attach at once.

client nc

flextunnel client nc <HOST> <PORT>               # profile from ~/.config/flextunnel/client.toml
flextunnel client nc -c aws.toml <HOST> <PORT>   # profile from a specific config
flextunnel client nc -n <ENDPOINT_ID> <HOST> <PORT>

Opens one tunnel to <HOST>:<PORT> through the running client for a profile (picked the same way as for client control, over the same control socket) and relays it to stdin/stdout — a built-in netcat for SSH's ProxyCommand:

Host workstation
    HostName workstation.internal
    ProxyCommand flextunnel client nc -c ~/.config/flextunnel/aws.toml %h %p

The tunnel rides the client's live connection, so it opens instantly, and it works with no SOCKS or HTTP port configured. Like a port forward, the target goes to the server, which enforces its routed set and resolves the host; an off-list target is refused rather than connected directly. It fails fast with the reason (client not running, tunnel down, target refused), which ssh prints. It reaches clients started with client start; the desktop app's connections have no control socket, so reach those through their SOCKS port instead (ProxyCommand nc -X 5 -x 127.0.0.1:<port> %h %p).

Configuration files

Instead of passing everything on the command line, server start and client start can read a TOML file:

flextunnel server start -c server.toml
flextunnel client start -c client.toml
flextunnel client start --socks-port 1080  # loads ~/.config/flextunnel/client.toml

Precedence is CLI flag > config file > built-in default, so you can keep a file and override settings on the command line. Credential groups are replaced as a unit: for example, if the CLI supplies either --auth-key or --auth-key-file, the config file's auth_key_file is ignored. Unknown or misspelled keys are rejected (deny_unknown_fields) rather than silently ignored. Paths support ~ expansion.

See server.toml.example and client.toml.example for the full set of keys. A minimal client file:

server_node_id = "<server endpoint id>"
socks_port     = 1080          # SOCKS5 on 127.0.0.1:1080 (loopback only)
auth_key_file  = "~/.config/flextunnel/client.key"

Private keys are always file references in a config (secret_file, auth_key_file) — there are no inline-key config keys. CLI flags still work and override any of these (--auth-key exists only as a CLI flag).

Server-only routing keys include host_aliases, dns_forwards, outbound [bridges.<name>], and inbound allowed_bridge_servers; these are config-file only because they describe the server's routing policy. Bridges carry no keypair: a bridging server authenticates by its TLS-authenticated endpoint id, which the receiving server must list in allowed_bridge_servers (enforced natively at the handshake).

Host aliases (server-side)

The server config can map hostnames to addresses on its own network, so a client can reach the server's loopback or internal hosts by a real name. Add a [host_aliases] table to server.toml (config-file only — there is no CLI flag):

[host_aliases]
"server.internal" = "127.0.0.1"      # the server's own loopback
"node2.internal"  = "192.168.1.50"   # another host on the server's network

When a requested hostname matches a key (case-insensitive), the server rewrites it to the value — an IP or another hostname — keeping the requested port, then resolves and connects like any other target. Only domain targets are aliased; literal IPs pass through unchanged.

For the server's own loopback no alias is needed: the reserved localhost.flextunnel.internal (see Server loopback) always maps there, which also sidesteps Firefox refusing to proxy literal localhost / 127.0.0.1. Aliases are for other hosts on the server's network, or for giving loopback a name of your choosing. Either way, use socks5h:// (or set Firefox's network.proxy.socks_remote_dns = true) so the name is resolved by the server, not locally.

Routed-set split-tunneling

The routed set (the tunnel set) is a VPN-style split-tunnel "included routes" list that decides which proxy destinations traverse the tunnel. Off-list proxy requests connect directly from the client; server-direct forwards are rejected if their target is off-list. This suits a client that must send all its traffic to the local proxy (for example a WebView with a global proxy config) when only some hosts should be tunneled. The set is optional and configured on the server only, through its config file or the desktop server pane:

# server.toml
routed_domains = ["*.example.com", "httpbin.org"]
routed_cidrs   = ["10.0.0.0/8", "192.168.1.5"]

The reserved flextunnel.internal namespace is always routed, independent of the tunnel set: flextunnel.internal (the status page) and localhost.flextunnel.internal (the server's own localhost, see Server loopback). So a server with an empty tunnel set (both lists omitted) is valid — clients reach just those two names through it and connect everything else directly. An empty set never means "tunnel everything". To route all traffic through the tunnel (full tunnel), use the catch-alls:

routed_domains = ["*"]
routed_cidrs   = ["0.0.0.0/0", "::/0"]

The server is the single source of truth. It pushes the list to every client in the handshake response, so there is no client list to keep in sync:

  • Client — on connect it learns the server's list. It tunnels only matching targets and connects everything else directly from its own network (split-tunneling). The direct path is independent of the tunnel, so off-list targets keep connecting even while the tunnel is down; an on-list target during a drop/backoff is held for the client's own reconnect (up to 45s, deploy-style connection holding) and proceeds transparently once the link is back — only a reconnect that never lands within the hold gets a network-unreachable failure (SOCKS5 reply 0x03; the HTTP front-end maps it to 502 Bad Gateway).
  • Server — it also enforces the same list independently as a whitelist, rejecting any tunnel request for a target not on it (SOCKS5 reply 0x02). This enforces routing policy for trusted clients and catches accidental misconfiguration; it is not an isolation boundary for untrusted clients.

Matching: domain entries are exact (example.com), wildcard (*.example.com, which matches subdomains only — not the bare apex), or * (matches every hostname), case-insensitive; CIDR entries match IP targets, accept a bare IP as a single host, and a default route (0.0.0.0/0 / ::/0) matches every IP. Hostnames are matched only against routed_domains and IPs only against routed_cidrs. A numeric IP literal is always gated by routed_cidrs even when a client sends it in hostname form (SOCKS5 ATYP_DOMAIN), so * never lets a raw IP through — it can only route real hostnames.

Either list, or both, may be omitted. The two never cross: an omitted/empty list means that whole category is off-list and always direct-connected. So routed_domains alone (no routed_cidrs) tunnels those hostnames but direct-connects every bare-IP target, and routed_cidrs alone tunnels those IPs but direct-connects every hostname.

Roadmap

  • Client blocking mode. Today the client always direct-connects every off-list target (split-tunneling), and this is the same for the desktop and iOS clients (they share the same core). A future client option — likely routed_mode = "block" | "direct" (default "direct") — will let a client instead refuse an off-list connection, returning a SOCKS5 error to the local app rather than falling back to a direct connection. This is aimed mainly at the desktop client, where blocking off-list traffic can be preferable to letting it leak out directly; the iOS client keeps defaulting to direct-connect. (The server's 0x02 rejection above is a separate, server-side control and is unaffected.)

Reconnect behavior

Auto-reconnect is enabled by default (auto_reconnect = true); pass --no-auto-reconnect (or set auto_reconnect = false) to disable it, and --auto-reconnect to force it on over a config that disabled it.

  • A failed connection attempt — the first one included — or a lost connection is retried with exponential backoff + jitter (1s doubling to 60s), indefinitely, unless --max-reconnect-attempts caps it or auto-reconnect is disabled. A server that is down, or not up yet, is the ordinary case, not a reason to exit: the client waits it out and connects when the server appears.
  • The delay between attempts is capped at 60s plus jitter; connection and handshake time add to it. After three consecutive failures, the next attempt rebuilds the endpoint; further rebuilds are at least 30 minutes apart during that outage. See the reconnect policy.
  • A permanent error, such as a rejected key or malformed config, ends the session. Transient errors also end it when retries are disabled or exhausted.
  • The control panel shows the outage's progress: failed attempts so far, the last error, and when the next attempt is due.
  • The local proxy listeners stay bound across reconnects. Off-list targets keep connecting directly; on-list requests are held for the reconnect (up to 45s) and only then fail with a network-unreachable reply.

Server home-relay failover is provided by flexaccess-iroh; see the shared failover design.

Logging

Logging uses env_logger. The default is info with iroh/tracing quieted to warn. Override with RUST_LOG, e.g. RUST_LOG=flextunnel=debug.

Documentation

  • iroh-common-architecture — the iroh transport layer shared with tunnel-rs and ezvpn: relays and address lookup (default vs custom relays, relay hints, the per-relay startup probe, relay auth tokens) and self-hosting (running your own iroh relay).
  • flexaccess-keys — the app-independent Ed25519 key format and tooling shared with tunnel-rs: the ed25519-sec: / ed25519-pub: tokens, key files, authorized-keys documents (key-format specification), and the generate-auth-key / show-auth-key CLI. flextunnel links against the same crate for parsing and verification and retains only its own domain-separated authentication transcript.
  • docs/architecture.md — how it works: connection lifecycle (fixed ALPN, auth handshake, per-stream protocol), module map, concurrency model, reconnect policy, security boundaries, and reference constants.
  • docs/local-ci.md — clippy and tests on all three host platforms via ci/all.sh; Linux/macOS run natively on the current host and the other platforms use SSH.
  • docs/systemd.md — running the CLI client under systemd: one template-unit instance per server, reconnects, the control panel and journald logging.
  • docs/http-proxy-roadmap.md — remaining HTTP proxy work.
  • docs/proxy-usage.md — using the SOCKS5 and HTTP proxies: which listener a tool needs, native SOCKS5 clients (curl, git, browsers), HTTP-proxy-only tools (wget, Docker, JVM/JDBC), ssh through the tunnel, and socat/ssh -L/-D forwards for apps that speak neither.

How it relates to ezvpn

flextunnel is modeled on the sibling project ezvpn (an IP-over-QUIC VPN), reusing its iroh transport and secret-key identity (client access control is flextunnel's own ed25519 keypair scheme). The difference: ezvpn creates a TUN device and ships IP packets over unreliable QUIC datagrams (and needs root); flextunnel exposes SOCKS5/HTTP proxy listeners and tunnels TCP over reliable QUIC streams (and needs no root).

License

MIT — see LICENSE.

About

Rootless SOCKS5 or port forwarding-over-QUIC tunnel

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages