tnl

module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 14, 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

Requirements

  • Go 1.26+ (to build)
  • OpenSSH client (ssh)
  • macOS or Linux (unix only in v1)

Install

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 SIGTERM 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 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)
  -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 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):

[api] web [connecting]
[db] 32000:5432 [backing off] (attempt 3)
[clash] 33000:80 [error] - port 33000 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>

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/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.

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.
launchd
Package launchd integrates tnl with macOS launchd by installing and removing a per-user LaunchAgent that starts the daemon at login.
Package launchd integrates tnl with macOS launchd by installing and removing a per-user LaunchAgent that starts the daemon at login.
output
Package output renders tunnel status snapshots for the terminal.
Package output renders tunnel status snapshots for the terminal.
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