tnl — SSH Tunnel Manager
A daemon-based SSH tunnel manager. Declare tunnels in a YAML config; tnl spawns your system ssh binary for each port mapping and supervises it — restarting dead mappings with exponential backoff, detecting port collisions, and giving you per-tunnel lifecycle control through a Unix-socket daemon.
Inspired by tunn: same architecture (config → one ssh process per mapping → daemon + IPC), own codebase, plus supervision and lifecycle control tunn lacks.
Features
- YAML config (
~/.tnlrc.yaml): tunnels with multiple port mappings, optional display labels
- Native ssh: spawns the system
ssh, so keys, agents, and ~/.ssh/config behave exactly like your shell
- Parallel execution: every mapping runs concurrently
- Supervision: dead mappings restart with exponential backoff (1s → 60s cap, jittered); attempt counts surface in status
- Connection-death detection:
ServerAliveInterval/ServerAliveCountMax keepalives turn dead TCP connections into process exits the supervisor sees — no autossh
- Real liveness: a mapping is
active only when its local port accepts TCP connections
- Port-collision detection: occupied local ports are reported per-mapping and retried with backoff; two tunnels claiming the same port is rejected at config load
- Lifecycle control:
start/stop/restart individual tunnels without touching the daemon; enabled: false excludes a tunnel from "start everything"
- Daemon mode: background daemon with Unix-socket IPC, pid/socket/log files, stale-state cleanup
- Login integration (macOS):
tnl install registers a LaunchAgent that starts the daemon at login
- Provisioning:
tnl setup [name] generates an ssh keypair for each tunnel's host, records it in ~/.ssh/config, and installs the public key in the remote account's authorized_keys — one-time, never starts a tunnel
Requirements
- Go 1.26+ (to build)
- OpenSSH client (
ssh)
- macOS or Linux (unix only in v1)
Install
Prebuilt binaries are published for macOS and Linux on amd64/arm64 with
every release, signed with SLSA
provenance. Install the latest release with:
curl -fsSL https://raw.githubusercontent.com/ahmadaidin/tnl/main/scripts/install.sh | bash
# pinned version:
TNL_VERSION=v0.1.0 curl -fsSL https://raw.githubusercontent.com/ahmadaidin/tnl/main/scripts/install.sh | bash
# custom destination:
curl -fsSL https://raw.githubusercontent.com/ahmadaidin/tnl/main/scripts/install.sh | bash -s -- /usr/local/bin
The script installs tnl into ~/.local/bin (created if missing); pass a
directory to change that, e.g. bash scripts/install.sh /usr/local/bin (or
run ./scripts/install.sh from a checkout). It detects the OS/arch,
downloads
https://github.com/ahmadaidin/tnl/releases/latest/download/tnl-<os>-<arch>
and verifies the binary with tnl version. If ~/.local/bin is not on your
PATH, add it:
export PATH="$HOME/.local/bin:$PATH" # add to ~/.zshrc or ~/.bashrc
For an installed binary, use the Go toolchain:
go install github.com/ahmadaidin/tnl/cmd/tnl@latest
The binary is placed in $(go env GOBIN), or $(go env GOPATH)/bin when
GOBIN is unset. Ensure that directory is on your PATH.
For a local checkout, build into ./bin instead (also: task build):
go build -o bin/tnl ./cmd/tnl
# optional: pin a version string
go build -ldflags "-X github.com/ahmadaidin/tnl/internal/version.Version=v0.1.0" -o bin/tnl ./cmd/tnl
Configuration
Create ~/.tnlrc.yaml (or pass any path with -c):
tunnels:
pg_dev:
host: myserver # ssh host alias from ~/.ssh/config
ports:
- 3000:3000 # local:remote port mapping
- repl: 4000:4001 # optional label: the app on that port
enabled: true # optional, default true
user: pguser # optional, overrides ~/.ssh/config
identity_file: ~/.ssh/id_rsa # optional
db:
host: database
ports:
- postgres: 5432:5432
enabled: false # declared but not started by default
Each ports entry is a plain local:remote spec (the remote port lives on the ssh host), a local:desthost:remote spec to forward to a host reachable through the ssh host, or a single-pair map label: local:remote to give the mapping a display name (the app listening on it):
tunnels:
dbsiakad:
host: siakad.tech
user: aidin
reclaim: true # kill the occupant of a colliding local port (same-user only)
ports:
- MySQL: 3306:mysql:3306 # forward through siakad.tech to mysql:3306
reclaim: true (per tunnel, off by default) makes the supervisor terminate whatever process is listening on a colliding local port instead of waiting for it to free. It only kills processes owned by your user (uid guard), sends SIGINT with a 2s grace before SIGKILL, and logs the action; unkillable or foreign processes fall back to error - port in use.
Validation (all at load time, strict — unknown fields are rejected):
host required; each tunnel needs at least one port mapping
- Port specs must be
local:remote (or local:desthost:remote) with valid ports (1–65535); the label form is label: <spec> (one pair per entry)
- Two tunnels claiming the same
local port is an error naming both tunnels
identity_file undergoes env expansion
Usage
Usage: tnl [options] [tunnel ...]
SSH tunnel manager. By default, tnl starts tunnels in the foreground.
Commands:
tnl [names] start tunnels in the foreground
tnl -d [names] start tunnels in a background daemon
tnl status show daemon and tunnel status
tnl start [names] start tunnels through the daemon
tnl stop stop the daemon
tnl stop <name> stop a single tunnel
tnl restart <name> restart a single tunnel
tnl setup [name] provision ssh identity for tunnels
tnl install register tnl as a macOS launch agent
tnl uninstall remove the macOS launch agent
tnl version print the version
Options:
-d, --detach run as a background daemon
-c, --config <path> config file (default ~/.tnlrc.yaml)
--algorithm <alg> key algorithm: ed25519, ecdsa, or rsa
--filename <path> private key path (overrides the recommendation)
--passphrase-file <path>
read the key passphrase from a file (empty = none)
-y, --yes accept defaults; push via agent only
-h, --help show this help
Examples:
tnl # run all enabled tunnels in the foreground
tnl pg_dev db # run a subset in the foreground
tnl -d # start the daemon
tnl status # per-tunnel/per-mapping state
tnl start pg_dev # start a tunnel (auto-starts the daemon if needed)
tnl stop db # stop one tunnel; it will not restart
tnl restart pg_dev # stop, reset attempts, start fresh
tnl setup # provision ssh identities for all tunnels
tnl setup pg_dev # provision only pg_dev (skips already-provisioned hosts)
tnl setup -y # non-interactive: defaults + agent-based push
tnl stop # stop the daemon
Explicit tunnel names always win: tnl start pg_dev works even when the tunnel has enabled: false. A bare tnl while the daemon is running errors instead of launching a second supervisor.
Status output (from a live run):
web [1 mapping, 1 active]
- 3000:3000 [active]
db [3 mappings, 1 backing off, 2 active]
- 3303:3303 [active]
maria 5432:5432 [active]
redis 6380:redis:6379 [backing off] (attempt 8)
broken [1 mapping, 1 error]
- 4000:4000 [error] - port 4000 in use
How supervision works
- A mapping is Wanted when its tunnel is enabled (or explicitly started) and not manually stopped. Only Wanted mappings restart.
- On spawn: the local port is probed. Occupied →
error - port N in use, no spawn, but backoff retries keep trying, so the mapping recovers when the port frees. With reclaim: true on the tunnel, the supervisor instead terminates the occupant (same-user only, SIGTERM → 2s → SIGKILL) and takes the port.
- After spawn the mapping is
connecting; it becomes active once 127.0.0.1:<local> accepts TCP connections.
- Process exit while Wanted → attempt counter increments, backoff delay (
min(60s, 1s * 2^(attempt-1)) with ±20% jitter), respawn. After 5 attempts the message flips to failed - retrying with backoff; retries continue indefinitely.
- Stop or daemon shutdown:
SIGINT to each ssh child, 2s grace, then SIGKILL. The daemon does not exit until every child is gone.
The spawned ssh command is (with local:desthost:remote mappings, localhost is replaced by the destination host):
ssh -N -L <local>:localhost:<remote> -o ServerAliveInterval=5 -o ServerAliveCountMax=2 -o ExitOnForwardFailure=yes [-i <identity>] [-l <user>] <host>
Provisioning
tnl setup [name] gives each tunnel's ssh host an Identity — one per (host, effective-user) pair — without ever starting a tunnel:
- Detect: a host is already Provisioned when an effective identity file exists (from
ssh -G <host>); those hosts are skipped.
- Keygen:
ssh-keygen creates the keypair — ed25519 by default (ecdsa -b 521, rsa -b 4096); the recommended filename is ~/.ssh/id_<alg>_<host> with -2/-3 auto-suffix on collision. A dangling identity_file in the tunnel config generates the key at exactly that path. Default passphrase is empty; with a passphrase, the daemon needs ssh-agent to use the key at runtime.
- Config: the
Host <host> / IdentityFile <path> block is inserted into ~/.ssh/config before the first block that would match the host (first-match-wins safety).
- Push:
ssh -o StrictHostKeyChecking=accept-new records known_hosts, then installs the public key into the remote account's authorized_keys — idempotent grep -qFx before append, chmod 600.
Interactive prompts choose algorithm/filename/passphrase. -y accepts defaults and pushes via ssh-agent/ControlMaster only (no password prompt). tnl setup refuses to run while the daemon is running. Per-host failures don't stop the rest; the run exits non-zero if any host failed, and a failed host is rolled back (key + config block removed).
Runtime files
The daemon keeps its state in $XDG_RUNTIME_DIR/tnl (fallback ~/.cache/tnl), created 0700:
daemon.pid — daemon PID, used to prevent duplicate launches (stale entries cleaned on detection)
daemon.sock — Unix socket for IPC commands, 0600
daemon.log — aggregated daemon and supervision logs
All files are removed when the daemon exits cleanly. The daemon is also self-sufficient against lost runtime files: if daemon.pid or daemon.sock are removed from underneath it (e.g. by external cleanup), it re-creates them within ~5s, so it never becomes invisible to tnl status/tnl stop and a duplicate daemon can never be launched.
Login integration (macOS)
tnl install writes ~/Library/LaunchAgents/com.ahmadaidin.tnl.plist (pointing at the current binary, RunAtLoad) and loads it. KeepAlive is deliberately false: launchd never resurrects the daemon after tnl stop. tnl uninstall unloads and removes it. On non-macOS these commands error with a clear message. The plist bakes in the binary path — re-run tnl install after moving or rebuilding the binary elsewhere.
Architecture
flowchart LR
CLI[tnl CLI] -- JSON over unix socket --> S[daemon: IPC server]
S --> M[supervisor]
M --> P[per-mapping loop]
P --> SS[ssh -N -L ...]
P -- TCP dial 127.0.0.1:local --> P
M --> ST[(status store)]
ST -- snapshot --> S
S -- snapshot --> CLI
Packages:
| Package |
Role |
internal/config |
parse/validate ~/.tnlrc.yaml |
internal/status |
thread-safe mapping-state store |
internal/supervisor |
supervision loops, backoff, spawn, probes |
internal/sshsetup |
one-time key generation + ~/.ssh/config + authorized_keys provisioning |
internal/daemon |
unix-socket IPC, pid/socket/log lifecycle |
internal/cli |
argument parsing |
internal/output |
status rendering |
internal/launchd |
macOS LaunchAgent (darwin-only) |
cmd/tnl |
entrypoint and command dispatch |
Design notes
- No autossh — the daemon owns the supervision state machine; ssh keepalives convert dead connections into process exits. See
docs/adr/0001-in-process-supervision.md.
- Own config format — deliberately not tunn-compatible (
local:remote specs with optional label: entries). See docs/adr/0002-own-config-format.md.
- Domain language (Tunnel, Mapping, Wanted, Connecting, Active, Backing off, Label) is defined in
CONTEXT.md.
Development
go build ./...
go test ./...
go test -race ./internal/supervisor/ ./internal/daemon/
A Taskfile.yml wraps the common flows: task check (vet + test + build), task race, task smoke (end-to-end daemon run against a fake ssh shim), task run -- <args> (run the built binary, e.g. task run -- -d -c .tnlrc.yaml), task install, task clean. Build with a version string via task build VERSION=v0.1.0.
Unit and integration tests never require a real sshd: they use an executable fake-ssh shim (internal/testutil/fakessh.sh, env-driven via FAKE_SSH_LOG/FAKE_SSH_EXIT_IMMEDIATE) and an injectable port prober. A manual smoke run looks like XDG_RUNTIME_DIR=$(mktemp -d) PATH=<fakebin>:$PATH bin/tnl -d -c <config> with a fake ssh on PATH.
Code scanning
.github/workflows/codeql.yml runs CodeQL analysis for Go on pushes and pull requests targeting main. It requires the workflow permission security-events: write so GitHub can publish the analysis results used by the protected branch checks.
Linting
.github/workflows/ci.yaml builds, tests, and runs golangci-lint v2.12.2 on pushes to main and pull requests targeting main. Locally, run:
golangci-lint run
Release provenance (SLSA)
Pushing a v* tag (e.g. v0.1.0) runs .github/workflows/slsa.yml, which runs unit tests, builds four native binaries with the SLSA Go builder (builder_go_slsa3.yml@v2.1.0), then downloads and publishes all binaries and signed provenance from one release job:
tnl-linux-amd64 + tnl-linux-amd64.intoto.jsonl
tnl-linux-arm64 + tnl-linux-arm64.intoto.jsonl
tnl-darwin-amd64 + tnl-darwin-amd64.intoto.jsonl
tnl-darwin-arm64 + tnl-darwin-arm64.intoto.jsonl
Each binary is statically linked (CGO_ENABLED=0, -trimpath) with the tag version (leading v stripped) baked into internal/version.Version; each .intoto.jsonl is the signed SLSA provenance generated by the builder.
A workflow_dispatch run builds the same four artifacts with a dev-<commit> version but does not upload release assets, so releases are only ever created from v* tags.
The workflow needs GitHub Actions to mint OIDC tokens (keyless signing). The reusable builder job declares id-token: write, contents: write, and actions: read because its nested upload-assets job requires that permission during workflow validation; upload-assets: false keeps the builder from publishing. Only the final publish job actually uploads release assets. Make sure repository or organization settings don't restrict these permissions. Create release tags from the commit you intend to ship. Consumers can verify the attached provenance with the SLSA verifier; always verify before trusting an artifact.