tnl

module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 23, 2026 License: MIT

README

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:

  1. Detect: a host is already Provisioned when an effective identity file exists (from ssh -G <host>); those hosts are skipped.
  2. 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.
  3. 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).
  4. 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.

Directories

Path Synopsis
cmd
tnl command
Command tnl is a daemon-based SSH tunnel manager.
Command tnl is a daemon-based SSH tunnel manager.
internal
cli
Package cli parses the tnl command line.
Package cli parses the tnl command line.
config
Package config loads and validates the tnl tunnel configuration file.
Package config loads and validates the tnl tunnel configuration file.
daemon
Package daemon implements the tnl daemon: runtime path resolution, pid-file handling, the Unix-socket IPC server, and the client used by the tnl CLI to talk to a running daemon.
Package daemon implements the tnl daemon: runtime path resolution, pid-file handling, the Unix-socket IPC server, and the client used by the tnl CLI to talk to a running daemon.
output
Package output renders tunnel status snapshots for the terminal.
Package output renders tunnel status snapshots for the terminal.
sshsetup
Package sshsetup provisions ssh identities for tunnels.
Package sshsetup provisions ssh identities for tunnels.
status
Package status defines the supervision state model reported by tnl.
Package status defines the supervision state model reported by tnl.
supervisor
Package supervisor spawns and supervises one system ssh process per port mapping, restarting dead mappings with exponential backoff and supporting per-tunnel lifecycle control.
Package supervisor spawns and supervises one system ssh process per port mapping, restarting dead mappings with exponential backoff and supporting per-tunnel lifecycle control.
version
Package version holds the tnl build version.
Package version holds the tnl build version.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL