Docker-based tunnel & port-forward manager. Define your VPNs / SSH jumps /
relays as small YAML files, bring them up with one command (each in its own
isolated container), and reach everything through stable localhost ports — with
an optional live web panel that maps your tunnels and even discovers your LAN.
The web panel's tunnel map (all data shown is synthetic).
Beyond the Map, the panel has two more views:
Tree view — every tunnel expands to its subnets, hosts, and forwards; the side pane inspects the selected tunnel's published ports (all data synthetic).
LAN view — optional nmap discovery: each local interface fans out to the devices it can reach, with their open ports; click a device to forward one (all data synthetic).
One tool, three tunnel types:
type |
What runs in the container | Use it for |
|---|---|---|
vpn |
openconnect (fortinet / gp / anyconnect / …) or strongSwan IPsec/IKE (protocol: ipsec) |
corporate & site VPNs, including out-of-band TOTP codes; SSL-VPN portals and native IPsec (FortiGate dialup: PSK + XAuth) |
ssh |
ssh -N with -L forwards / -D SOCKS, optional multi-hop ProxyJump |
anything behind a bastion / jump host |
local |
plain socat relays |
pinning a LAN/remote service to a stable localhost port |
Every tunnel runs in its own container on its own Docker network, which buys you, for free:
- No route conflicts — two VPNs pushing overlapping
10.xsubnets simply cannot collide; every tunnel lives in its own network namespace. - No cross-tunnel reachability — one tunnel's container (and its remote side) can never reach another's. Listeners bind to the Docker side only, so nothing on the far side of a tunnel can reach them.
- Host DNS untouched — a VPN's DNS applies only inside its container (forward targets resolve through it), so Tailscale / mDNS / your resolver never break.
- No sudo on the host — openconnect gets root inside the container only.
- Concurrent tunnels — bring up as many as you like at once.
Access goes through published port forwards and an optional per-tunnel
SOCKS5 proxy, e.g. ssh -p 2222 user@127.0.0.1 or
curl --proxy socks5h://127.0.0.1:1080 http://10.0.0.20/.
- Requirements
- Install
- Configure
- CLI usage
- TOTP flow
- Unattended / server mode
- Web panel
- Architecture
- Security notes
- License
| Where | Dependency |
|---|---|
| Host | bash ≥ 3.2, docker (Docker Desktop / OrbStack / Linux dockerd), yq (mikefarah v4 — reads the YAML configs) |
| Web panel (optional) | go ≥ 1.21 to build the daemon; nmap only for the LAN-discovery canvas |
| Container image (built for you) | Alpine + openconnect + strongswan + openssh-client + socat + microsocks |
git clone https://github.com/Asimatasert/t-forward && cd t-forward
ln -s "$PWD/t-forward" /usr/local/bin/t-forward # or anywhere on PATHThe container image is pulled automatically from
Docker Hub (multi-arch:
amd64 + arm64) on first up. To build it locally instead, run
./t-forward build — or set T_FORWARD_HUB_IMAGE="" to always build.
One YAML file per tunnel in ~/.config/t-forward/conf.d/ — the file name is the
tunnel name (conf.d/office.yaml → t-forward up office). Files are read with
yq (never sourced, so values need no shell quoting); mode 600 is enforced.
Fully-commented templates live in examples/.
# vpn
name: Example VPN
type: vpn # vpn | ssh | local
tags: [example]
vpn:
server: vpn.example.com:10443
protocol: fortinet # fortinet | gp | anyconnect | nc | pulse | f5 | array
user: username1
password: "" # omit -> prompted; a wrong servercert makes the log print the real pin
totp: false # true -> code prompted after the password is sent
totp_secret: "" # optional base32 secret -> code generated automatically
socks: 1080 # optional SOCKS5 port ("any" = random free port)
hosts: # forwards grouped by host, each self-describing
- ip: 10.0.0.20
name: app server # shown in the panel; optional
tags: [db, web] # optional
forwards:
- { remote: 22, local: 2222, service: ssh, user: root }
- { remote: 5432, local: any } # local port auto-picked; service auto-detected
- { remote: 8080, local: 0.0.0.0:8080 } # bind on the LAN, not just localhost# ssh — optionally multi-hop via ProxyJump
name: DB via bastion
type: ssh
ssh:
host: 10.0.0.30 # the host you land on; forwards resolve from here
user: username1
key: ~/.ssh/id_ed25519 # key auth (required when `jump` is set); omit for a password prompt
jump: [10.0.0.10] # ordered hops before `host`: you -> 10.0.0.10 -> 10.0.0.30
hosts:
- { ip: 10.0.0.40, name: db, forwards: [ { remote: 5432, local: 5433, service: postgres } ] }# local — no tunnel; use host.docker.internal for services on this machine
name: LAN printer
type: local
hosts:
- { ip: 192.168.1.77, forwards: [ { remote: 9100, local: 9100 } ] }Fields. remote is the server's port; local is your localhost port — a
number, any for an auto-picked free port, or BIND:PORT (e.g. 0.0.0.0:8080
to expose on the LAN). service is auto-detected from the remote port when
omitted (22→ssh, 80→http, 5432→postgres, …); user feeds the panel's
ready-to-run copy string. Optional subnets: and per-device labels are used by
the web panel (below). Full schema: docs/CONFIG_NOTES.md.
t-forward # interactive menu
t-forward up office # bring up (by file name, list index, or unique substring)
t-forward up office prod # several tunnels at once
t-forward status # active tunnels + their published ports
t-forward list # every configured tunnel and its state
t-forward down office # tear one down
t-forward down all # tear everything down
t-forward logs -f office # follow a tunnel's logsAd-hoc, without any config file:
t-forward up --server vpn.example.com --user username1 --protocol gp \
--forward 2222:10.0.0.5:22 --socks any
t-forward up --ssh-host bastion.example.com --ssh-user username1 \
--ssh-key ~/.ssh/id_ed25519 --forward 5433:10.0.0.20:5432
t-forward up --type local --name printer --forward 9100:192.168.1.77:9100Shell completion. Completes verbs and tunnel names (up/down/code/logs/events):
source <(t-forward completion bash) # bash — add to ~/.bashrc
t-forward completion zsh > "${fpath[1]}/_t-forward" # zsh — then restart the shellFor totp: true VPNs the password is sent automatically; the CLI then waits:
Verification code for 'office' (arrives via SMS / mail / Telegram): _
Type the code when it lands (usually 10–30 s later) and press Enter — the tunnel
comes up and forwards start. If totp_secret is set, openconnect generates the
code itself and no prompt appears. The web daemon can also inject codes without a
stored secret: POST /totp/<name> {code} (a phone shortcut / bot), or a
totp_command: in the conf whose stdout supplies the code.
A rejected password fails fast with a clear error instead of hanging on the code prompt — so a wrong credential never masquerades as “still waiting”.
Two options smooth over real-world gateways:
servercert:empty / omitted → the current certificate pin is probed once and trusted (trust-on-first-use). Survives a gateway rotating its cert without re-pinning by hand; pin it explicitly to close the first-connection MITM window (the log prints the exact pin to paste).no_dtls: true→ tunnel over TLS only. Some gateways complete the DTLS (UDP) handshake but then black-hole the tunnel, stalling the connection; this skips UDP entirely.
t-forward up office --restart (or restart: true in the conf) sets the
container's restart policy to unless-stopped: the tunnel reconnects by itself
after a crash, a drop, or a host reboot, and published ports stay stable (any
ports are resolved to fixed free ports up front). Credentials are kept in the
private auth dir so the container can re-authenticate; t-forward down removes
them. Not allowed for manual-TOTP VPNs — unattended reconnection needs
totp_secret (or no TOTP).
An optional, self-contained Go daemon (web/, stdlib only) serves a live
control panel over Server-Sent Events. It drives this same CLI and reads Docker
state — no database, no framework.
cd web && go build -o t-forward-web .
TF_WEB_TOKEN=$(openssl rand -hex 24) ./t-forward-web --addr 127.0.0.1:8787
# open http://127.0.0.1:8787/?token=<the token above>Flags: --addr (bind address), --token-file <path> (read the token from a
file), --config-dir, --tf <path to CLI>, --no-auth (disable the token
check — only on a trusted private bind). The token may also come from the
TF_WEB_TOKEN env var; if none is given a random one is printed at startup.
Unauthenticated liveness probing is available at GET /healthz (200 ok) for
systemd or uptime monitoring. The build version can be checked at GET /version
(requires authentication).
VPN, SSH, and local tunnels can be started and stopped from their tunnel or host detail panels, including SSH tunnels nested under a jump host. Host actions control the whole tunnel; Tunnel details opens its status and connection view.
The panel has two canvases, switched from the top bar:
Tunnels — a live topology map, YOU → container → target hosts, with:
- real packet-flow particles driven by per-tunnel throughput (
docker stats), and a rolling TX/RX chart; - pan & zoom (drag empty space or scroll to zoom toward the cursor;
−/%/fitcontrols) and draggable boxes to hand-arrange the map — the arrangement is saved server-side, so it looks the same on every machine; a lock guards against accidental moves and a reset restores the auto layout; - per-port reachability dots (green reachable / red blocked / grey unknown) next to every forward — so a host the gateway lets you reach on one port but firewalls on another reads at a glance;
- a filterable, level-tagged network-event console (connections, tunnel, auth, errors);
- click any node to inspect it — copy ready-made connection strings
(
ssh -p … user@host,psql …,redis-cli …), open http/vnc/rdp URIs, edit names/tags/notes inline (written safely back to the YAML); - virtual subnet nodes for the network each host sits behind, jump-chain
nodes for multi-hop ssh, and cross-tunnel jumps: an ssh tunnel that rides
another tunnel's forward folds into it as one continuous
host → subnet → jumped hostchain, to any depth; - a 🔑 marker on TOTP tunnels; up / down / retry / TOTP-code actions, tag & online/offline filters.
LAN — network discovery. Point scan.yaml at
one or more interfaces and the daemon uses nmap to map reachable devices and
their open ports, on an interval, cached (memory + a JSON file, optionally
Redis via redis-cli). Features:
- root scan (ARP + SYN, fast, resolves MACs) when a
NOPASSWDsudoers entry for nmap exists, otherwise an unprivileged connect-scan — no root required; - text + port filters; scan events in the console;
- per-device name/tags, keyed by a stable identity (MAC when known, else IP), persisted and re-applied on every scan;
- one-click forward of any discovered open port to localhost (spins up an
any-portlocalrelay), per-port or all-at-once; - per-adapter rescan that updates one interface in place.
Two planes. A control plane — the t-forward bash CLI (and the optional Go
web daemon) — that only ever talks to Docker and yq; and a data plane —
one container per tunnel — that holds the actual tunnel and the forwards. The
control plane keeps no state of its own: state is Docker.
What t-forward up office actually does, start to finish:
flowchart TD
A["t-forward up office"] --> B["① read office.yaml (yq)<br/>write creds → /auth (tmpdir 700;<br/>password / totp_secret / ssh_key each 600)"]
B --> C["② docker run tf-office<br/>own network · NET_ADMIN · /dev/net/tun<br/>-v /auth · -p host:port · non-secret env only"]
C --> D["③ entrypoint: openconnect<br/>← password over a stdin FIFO (never in ps/env)"]
D --> E{"totp?"}
E -- "no" --> G["④ tun0 comes up"]
E -- "yes" --> F["drop /auth/awaiting_code, wait for the code<br/>(auto from secret · SMS / mail typed in panel or CLI)"]
F -- "code fed" --> G
G --> H["⑤ start forwards (socat on eth0 / ssh -L)<br/>+ optional SOCKS5"]
H --> I["⑥ wipe /auth · drop the ready marker"]
I --> J(["office is up — reach services at host:port"])
D -. "credentials rejected" .-> X["abort with a clear error<br/>(never hangs at the code prompt)"]
down is just docker rm -f tf-office — the network namespace (routes, tun0,
listeners) dies with the container, so there is nothing to unwind.
Each tunnel is one container (tf-<name>) on its own Docker network. Inside it,
the tunnel comes up and a small relay publishes each service on a host port:
flowchart LR
you["you"] -->|"host:2222 (published)"| socat
subgraph tf["tf-office · own network namespace"]
socat["socat :10001<br/>(or ssh -N -L)"] --> oc["openconnect<br/>tun0"]
end
oc -->|"VPN"| gw["vpn.example.com"]
gw --> target["10.0.0.20:22"]
- vpn —
openconnect(fortinet/gp/anyconnect/…) ontun0;socatlisteners forward eachhost:port → tun0 → remote. - ssh —
ssh -Nwith native-Lforwards /-DSOCKS, optionally through a multi-hopProxyJumpchain. - local — no tunnel; plain
socatrelays (pin a LAN/remote host:port to a stable local port).
Listeners bind the container's eth0 (the Docker-published side) only, so
nothing on the far side of a tunnel can reach a forward or the SOCKS proxy. The
host port is 127.0.0.1 by default, or T_FORWARD_BIND (e.g. a Tailscale IP)
to share forwards on a trusted network.
Credentials never sit on the host command line or in the container's env. up
writes them into a private 700 tmpdir mounted at /auth (password,
totp_secret, or an ssh_key, each 600). The entrypoint feeds the password to
openconnect over a stdin FIFO (so it never appears in ps), handles the TOTP
code (auto from a secret, or waits for the host to drop /auth/code), then —
once the tunnel is up — wipes /auth and drops a ready marker the CLI
waits on. Only non-secret operands (VPN_SERVER, VPN_USER, the public
SERVERCERT pin, FORWARDS, …) are passed as env.
There is no database and no daemon-of-record. A tunnel’s existence, status and
metadata live in the tf-<name> container and its labels, so status, logs
and down are thin wrappers over docker ps / logs / rm -f, and a crash or
reboot leaves nothing to reconcile. up --restart sets unless-stopped so the
container reconnects itself.
A dependency-free Go binary (web/) that reconciles, never owns: it reads
the same YAML (via yq -o=json, never sourcing it) and live docker ps/stats/ logs, streams them to the panel over SSE (with a /state + /evlog polling
fallback for proxies that buffer SSE), and turns panel clicks into the same CLI
verbs. Config edits go through yq -i with values passed only as environment
variables — so a crafted value can't inject yq/shell syntax — and never touch the
credential keys. It binds loopback with a token by default; --no-auth is for a
trusted private bind (e.g. a tailnet IP), still behind a Host-header /
cross-site / JSON-only guard.
- The web daemon is meant to bind a trusted address (localhost, or a private
overlay like a Tailscale IP) and is protected by a bearer token; it has no
TLS and the token can appear in a URL, so never expose it on a public
interface or behind a logging proxy.
--no-authremoves the token entirely — only use it on a private bind where everyone who can reach it is trusted. - Configs hold plaintext credentials; they are kept at mode
600and the.gitignoreexcludes real config directories. Keep them out of version control. - Panel config edits are whitelisted and injection-safe, and never modify the password / servercert / totp_secret / ssh key.
Contributions are welcome. See CONTRIBUTING.md for how to build, test, and open a PR. Good places to start:
good first issue— small, self-contained tasks.help wanted— larger items on the roadmap.
Have a question or an idea? Open a Discussion. Found a security issue? See SECURITY.md.

