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)
- SOCKS5
CONNECTonly. No UDPASSOCIATE, noBIND. The optional HTTP proxy supports HTTPCONNECTplus 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.1only (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.
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.
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 | bashflextunnel (server / client) — Windows (PowerShell):
irm https://flexaccessdev.github.io/flextunnel/install.ps1 | iexOptions: 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.
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 neededThat 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.dmgThen 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 aftersetopt interactivecomments; without it, pasting a#line reportscommand not found. Runsetopt interactivecommentsonce 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.appAlternatively, 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.msiIf 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.
cargo build --release
# binary: target/release/flextunnelRequires 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.
flextunnel generate-iroh-key -o server.key # prints the server's EndpointId
flextunnel show-iroh-id --secret-file server.key # re-print the EndpointIdClient 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 | iexThen:
# 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.keyWithout -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).
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.tomlIt prints flextunnel server Node ID: <ENDPOINT_ID> — give that to clients.
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)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-hostFor 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 --quickEither 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.
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.jsonThe 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.
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.
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.comSee docs/http-proxy-roadmap.md for remaining
HTTP proxy work. Apps without proxy support can use built-in port forwards.
| 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.
| 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.
| 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.)
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 directlyAttaches 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.
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).
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.tomlPrecedence 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).
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 networkWhen 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.
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 to502 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.
- 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's0x02rejection above is a separate, server-side control and is unaffected.)
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-attemptscaps 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 uses env_logger. The default is info with iroh/tracing quieted to
warn. Override with RUST_LOG, e.g. RUST_LOG=flextunnel=debug.
- 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 thegenerate-auth-key/show-auth-keyCLI. 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 viaci/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),sshthrough the tunnel, andsocat/ssh -L/-Dforwards for apps that speak neither.
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).
MIT — see LICENSE.