Skip to main content

tunneld

Config-driven SSH tunnel manager for Python 3.9+. Define local, dynamic proxy, and remote forwards in TOML; tunneld runs one system OpenSSH process per [[tunnels]] entry, keeps it connected, and reloads changes automatically.

One [[tunnels]] entry means one SSH connection. Every entry in its forwards, proxy, and remote_forwards arrays shares that connection.

Features

  • Local (-L), dynamic SOCKS5 (-D), and remote (-R) forwarding.
  • One SSH process per tunnel, regardless of forwarding-entry count.
  • Automatic reconnect with bounded exponential backoff.
  • Automatic, non-destructive config reload.
  • Strict Pydantic validation with unknown-key rejection and useful field paths.
  • Per-forward route rows with server-side markers, optional labels, listener conflict checks, and JSON Schema.
  • Uses system ssh, preserving ~/.ssh/config, ssh-agent, ProxyJump, and known_hosts.
  • Ships a PEP 561 py.typed marker for type-checking library consumers.

Requirements

  • Python 3.9+
  • OpenSSH client (ssh on PATH)
  • Linux or macOS (the daemon control channel is a Unix domain socket)

Python 3.11+ uses tomllib; Python 3.9/3.10 use tomli.

Install

uv tool install tunneld

pipx install tunneld works too. To pin an exact release from GitHub:

uv tool install --force \\
  "tunneld @ git+https://github.com/goodboys-ai/tunneld@v0.3.1"

Quick start

tunneld init
tunneld edit
tunneld check --show-command
tunneld up
tunneld status

The default config is $XDG_CONFIG_HOME/tunneld/tunneld.toml, falling back to ~/.config/tunneld/tunneld.toml. Override it with a global option:

tunneld --config ./tunneld.toml check

Global options must appear before the subcommand.

Configuration

tunneld init writes a concise config. tunneld init --full writes a fully commented example containing every supported setting.

#:schema https://raw.githubusercontent.com/goodboys-ai/tunneld/main/tunneld.schema.json

[daemon]
watch = true
watch_interval = 1.5
reconnect_initial_delay = 1.0
reconnect_max_delay = 30.0

[defaults]
keep_alive = 30
keep_alive_count = 3

[[tunnels]]
name = "prod"
host = "prod"                  # ~/.ssh/config alias or hostname
enabled = true
user = "root"                  # optional
# port = 22
# identity = "~/.ssh/id_ed25519"
ssh_options = ["Compression=yes"]

# Local forwarding (-L): listen here, connect from the SSH server side.
forwards = [
  { label = "postgres", local = 5432, remote = 5432 },
  { label = "service_b", local = 4321, remote = "db.internal:4321" },
  { local = "127.0.0.1:9090", remote = "metrics.internal:9090" },
]

# Dynamic SOCKS5 proxy (-D).
proxy = [
  { label = "browser", local = 1080 },
]

# Remote forwarding (-R): listen on the SSH server, connect back here.
remote_forwards = [
  { label = "webhook", local = 8080, remote = 18080 },
]

[[tunnels]]
name = "staging"
host = "staging"

forwards = [
  { local = 15432, remote = "postgres.internal:5432" },
]

proxy = [
  { local = 1081 },
]

This creates two SSH processes. The prod command contains three -L arguments, one -D, and one -R.

Endpoint shorthand

An integer means localhost:<port> on the corresponding side:

{ local = 5432, remote = 5432 }

becomes -L 5432:localhost:5432. Use a string for an explicit address:

{ local = "127.0.0.1:5432", remote = "db.internal:5432" }

Bare ports written as strings ("5432") are rejected; write 5432 instead. Ports must be in 1..65535. IPv6 endpoints use [address]:port.

Binding a local or proxy listener to 0.0.0.0 exposes it to other machines. A remote 0.0.0.0 listener also requires the SSH server to permit GatewayPorts.

Forward direction

Config array OpenSSH Listener Target reached from
forwards -L local machine SSH server side
proxy -D local machine dynamic through SSH
remote_forwards -R SSH server local machine side

label is optional. If present, it must be unique across all forwarding arrays within that tunnel. It affects status and errors, not the SSH command.

Strict validation

Pydantic rejects unknown keys, invalid endpoints, duplicate tunnel names, duplicate labels, conflicting enabled local listeners, managed SSH options, and tunnels without forwarding entries. Tunnel names must start with an ASCII letter or digit and may also contain ., _, and -. Disabled tunnels are excluded from listener-conflict checks. keep_alive_count = 0 is accepted and preserves OpenSSH's no-termination behavior. host and user are passed directly to the system ssh argv; invalid aliases or names are diagnosed by OpenSSH and tunneld doctor, without shell interpretation.

tunneld check
tunneld check --show-command
tunneld schema
tunneld schema --output tunneld.schema.json

Status model

status groups rows by tunnel and displays every forwarding entry:

tunneld  pid=12844  config=~/.config/tunneld/tunneld.toml

prod  running  pid=12844  uptime=2h13m
┏━━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┓
┃ Label    ┃ Type ┃ Route                                     ┃ State  ┃
┡━━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━┩
│ postgres │ -L   │ localhost:5432 → localhost:5432 (prod)    │ active │
│ browser  │ -D   │ localhost:1080 → dynamic (SOCKS5 via prod)│ active │
│ webhook  │ -R   │ localhost:18080 (prod) → localhost:8080   │ active │
└──────────┴──────┴───────────────────────────────────────────┴────────┘

The Route column always reads entry → exit; a (host) suffix marks the endpoint resolved on the SSH server side, so identical address strings on both sides stay distinguishable. When name == host, the header shows the name once; otherwise it shows name → [user@]host. -D rows use via host instead of a suffix because SOCKS destinations are dynamic.

All entries in a tunnel share one SSH process, so their state inherits the tunnel state. active means OpenSSH created the listener successfully; it is not a destination-service health check.

Commands

tunneld up [NAMES...]             start enabled tunnels
tunneld down [NAMES...]           stop named tunnels; with no names, stop daemon
tunneld down --keep-daemon        stop all tunnels but keep the daemon
tunneld down --kill-daemon        explicitly stop tunnels and daemon
tunneld restart [NAMES...]        restart tunnels
tunneld reload                    reload and converge immediately
tunneld status                    show tunnel and forwarding-entry state
tunneld list                      inspect config without the daemon
tunneld check [--show-command]    strictly validate and normalize config
tunneld logs NAME [--follow]      read one tunnel's SSH output
tunneld init [--full]             write a config template
tunneld edit                      open config in $EDITOR
tunneld doctor                    check config, ssh, and host aliases
tunneld schema [-o PATH]          emit JSON Schema
tunneld --version

Daemon behavior

  • up starts a detached daemon if necessary and replaces a daemon using an incompatible IPC protocol after an upgrade.
  • Every SSH process uses ExitOnForwardFailure=yes and configured keepalives.
  • A dropped process reconnects using the configured delay range.
  • The watcher performs one stat at the configured interval and only parses when mtime changes.
  • Invalid edits leave current tunnels running and appear as a config error.
  • down NAME stays stopped until up NAME or restart NAME; it leaves other tunnels and the daemon running. Plain down stops everything and exits the daemon; use down --keep-daemon to retain the watcher.
  • IPC requests and responses are capped at 1 MiB. The runtime directory is mode 0700 and the control socket is mode 0600.
  • Per-tunnel logs are truncated in place at 10 MiB; the last 64 KiB is preserved in <name>.log.prev, and logs -f continues across truncation. The daemon log rotates into three generations when it exceeds the limit at daemon startup.
  • Logs live under $XDG_RUNTIME_DIR/tunneld/logs/, falling back to ~/.cache/tunneld/logs/.
  • The PID file is informational and overwritten at startup; daemon liveness is determined through IPC. Like any Unix process, SIGKILL can leave a stale PID file, which does not prevent the next start.

systemd user service

# ~/.config/systemd/user/tunneld.service
[Unit]
Description=tunneld SSH tunnel manager
After=network-online.target

[Service]
ExecStart=%h/.local/bin/tunneld daemon --foreground
Restart=always
RestartSec=3

[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now tunneld

Development

git clone https://github.com/goodboys-ai/tunneld
cd tunneld
uv sync --extra dev
pre-commit install
uv run pytest          # coverage report included; CI enforces >=70%
uv run ruff check src tests
uv run pyright

Pre-commit runs Ruff lint/format, Pyright, and codespell. CI runs the same checks plus the coverage gate on Python 3.9, 3.11, and 3.12.

License

MIT. See LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tunneld-0.3.1.tar.gz (94.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tunneld-0.3.1-py3-none-any.whl (28.2 kB view details)

Uploaded Python 3

File details

Details for the file tunneld-0.3.1.tar.gz.

File metadata

  • Download URL: tunneld-0.3.1.tar.gz
  • Upload date:
  • Size: 94.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for tunneld-0.3.1.tar.gz
Algorithm Hash digest
SHA256 7c384e8bb776563099d67792899917dd68b4c32ddbb0bb6e93c5bea24be1f638
MD5 7c528d28e25fd4917982acd261bca639
BLAKE2b-256 6371055afc353d85268bf5372701cf7effe7214717ed3d31f054911603810fec

See more details on using hashes here.

Provenance

The following attestation bundles were made for tunneld-0.3.1.tar.gz:

Publisher: publish.yml on goodboys-ai/tunneld

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file tunneld-0.3.1-py3-none-any.whl.

File metadata

  • Download URL: tunneld-0.3.1-py3-none-any.whl
  • Upload date:
  • Size: 28.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for tunneld-0.3.1-py3-none-any.whl
Algorithm Hash digest
SHA256 313e4e590c575769dc847c8a91e7cd992df39d7e2fd1d43b519c6500f9a1e4d1
MD5 3838e8c37963ca3f1101692a414299e2
BLAKE2b-256 e3c7aa5e0a1b243e1a23df184bcc9f9c9980a53a35f1637a0d7921b857b9354e

See more details on using hashes here.

Provenance

The following attestation bundles were made for tunneld-0.3.1-py3-none-any.whl:

Publisher: publish.yml on goodboys-ai/tunneld

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.3.1 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page