Open N SSH local-forward tunnels, fetch small remote config files, and produce ready-to-use kubeconfigs — all in a single bootstrap. Built for disposable CI / operator environments that talk to k3s or similar internal services without public ingress.
Audience: infrastructure engineers running short-lived jobs (CI, local
containers, Terragrunt hooks) that need SSH-tunneled access plus a kubeconfig
(or similar config file) from one or more remote hosts. The tool is generic
— it does not depend on Kubernetes — but the motivating use case is k3s edge
nodes whose apiserver binds to 127.0.0.1 only.
- Internal apiservers (k3s, gitea, registries) are not publicly exposed; SSH is the only audited path in.
- Tools like
helm/kubectlneed an endpoint and a kubeconfig at plan/apply time — pulling both in one bootstrap avoids a second authentication and a second sshd session. - Ephemeral environments cannot rely on persistent SSH setup, agent forwarding, or pre-installed kubeconfigs.
- Raw kubeconfigs from k3s point
server:at the apiserver's own address (often127.0.0.1:6443) and carry a TLS certificate whose SAN does not include127.0.0.1— consumers previously had to rewriteserver:and also determine the correcttls-server-namethemselves.kube_targetshandles both.
uvx (recommended for one-shot / disposable use — no install needed):
uvx --from git+https://github.com/AlexMKX/tunstrap.git tunstrap --helppipx (persistent install):
pipx install git+https://github.com/AlexMKX/tunstrap.gitFor development:
git clone https://github.com/AlexMKX/tunstrap.git && cd tunstrap
pip install -e ".[dev]"Requires Python >= 3.10. Linux and macOS supported; Windows works via WSL only.
#!/usr/bin/env bash
set -euo pipefail
SESSION_DIR=$(mktemp -d)
PRIVATE_KEY=$(cat ~/.ssh/id_ed25519)
JSON=$(cat <<EOF
{
"nodes": {
"edge1": {
"host": "198.51.100.10",
"user": "root",
"ssh_pkey": $(jq -Rs . <<<"$PRIVATE_KEY"),
"remote_targets": {},
"kube_targets": {
"k3s": {"kubeconfig_path": "/etc/rancher/k3s/k3s.yaml"}
},
"required": true
}
},
"daemon": {
"auto_stop_idle_seconds": 600
}
}
EOF
)
RESULT=$(echo "$JSON" | tunstrap start --session-dir "$SESSION_DIR")
PORT=$(jq -r '.connections.edge1.kube_targets.k3s.local_port' <<<"$RESULT")
TLS_NAME=$(jq -r '.connections.edge1.kube_targets.k3s.tls_server_name' <<<"$RESULT")
KUBECONFIG_B64=$(jq -r '.connections.edge1.kube_targets.k3s.content_b64' <<<"$RESULT")
KUBECONFIG_FILE=$(mktemp)
base64 -d <<<"$KUBECONFIG_B64" >"$KUBECONFIG_FILE"
# server: and tls-server-name are already patched — no sed step needed
kubectl --kubeconfig="$KUBECONFIG_FILE" get nodes
tunstrap stop --session-dir "$SESSION_DIR"
rm -f "$KUBECONFIG_FILE"The daemon.auto_stop_idle_seconds: 600 setting makes the daemon shut
itself down after 10 minutes with no client connections. Useful for
ephemeral CI runs that may abort before reaching tunstrap stop.
Omit the field (or set to null) to keep the daemon alive until you call
stop explicitly.
RESULT=$(echo "$JSON" | tunstrap start --session-dir "$SESSION_DIR")
KUBECONFIG_B64=$(jq -r '.connections.edge1.fetch_files.kubeconfig.content_b64' <<<"$RESULT")
KUBECONFIG_FILE=$(mktemp)
base64 -d <<<"$KUBECONFIG_B64" >"$KUBECONFIG_FILE"
# you must patch server: and determine tls-server-name yourself
sed -i "s|server: https://127.0.0.1:6443|server: https://127.0.0.1:${PORT}|" \
"$KUBECONFIG_FILE"
tunstrap stop --session-dir "$SESSION_DIR"Besides the JSON-on-stdin interface above, a single remote host can be driven entirely from command-line flags — no JSON required.
tunstrap start root@edge1.example.net \
--ssh-key ~/.ssh/id_ed25519 \
--target api=127.0.0.1:6443 \
--kube k3s=/etc/rancher/k3s/k3s.yamlUSER@HOST[:PORT]sets the SSH user, host, and port (default22). IPv6 literals are bracketed:root@[2001:db8::1]:6443.- Repeatable
--target NAME=HOST:PORTopens a local forward;--kube NAME=/abs/pathand--fetch NAME=/abs/pathmirrorkube_targets/fetch_files. - Auth:
--ssh-key <file>(optionally--ssh-key-passphrase) or--ssh-password-stdin(the password is read from the first stdin line). When neither flag is given, tunstrap uses keys from the running ssh-agent (via$SSH_AUTH_SOCK). - Daemon knobs:
--auto-stop-idle-seconds,--materialize,--log-file,--session-dir.
The connection host becomes the schema node key, which must match
^[a-zA-Z_][a-zA-Z0-9_-]*$. Use a hostname (e.g.localhost,edge1.example.net) rather than a bare IP literal in flag mode.
start defaults to --output json. With --output env it instead prints
POSIX export lines (and force-materializes kube files), ready for eval:
eval "$(tunstrap start root@edge1 --ssh-key ~/.ssh/id_ed25519 \
--target api=127.0.0.1:6443 --kube k3s=/etc/rancher/k3s/k3s.yaml --output env)"
curl "http://$TUNSTRAP_API_ENDPOINT/healthz"
kubectl get nodes # KUBECONFIG is exported automatically
tunstrap stop --session-dir "$TUNSTRAP_SESSION_DIR"Variables emitted (no node segment; names upper-cased, non-alphanumerics → _):
| Variable | Meaning |
|---|---|
TUNSTRAP_SESSION_DIR |
Session dir — pass to stop --session-dir. |
TUNSTRAP_PID |
Daemon PID. |
TUNSTRAP_<NAME>_PORT |
Local forwarded port for --target NAME=.... |
TUNSTRAP_<NAME>_ENDPOINT |
127.0.0.1:<port> for a target; full URL for a kube target. |
TUNSTRAP_<NAME>_KUBECONFIG |
Materialized kubeconfig path for --kube NAME=.... |
KUBECONFIG |
Colon-joined paths of all kube targets. |
run opens the tunnel, injects the same TUNSTRAP_* / KUBECONFIG
environment into a child command, waits for it, and then always tears the
tunnel down (even if the child crashes or fails to launch):
tunstrap run root@edge1 \
--ssh-key ~/.ssh/id_ed25519 \
--kube k3s=/etc/rancher/k3s/k3s.yaml \
-- helm listEverything after -- is the child command and its arguments. SIGINT /
SIGTERM are forwarded to the child.
Exit codes (run): the child's exit code wins on success. Before the
child runs, run may exit with 2 (required tunnel failure), 3 (a live
session already holds the requested --session-dir), or 4 (daemon error);
127 if the child binary cannot be launched.
Top level
| Field | Type | Default | Description |
|---|---|---|---|
nodes |
dict[str, NodeInput] |
required | One entry per remote host |
daemon.log_file |
str | null |
null |
If set, daemon's stdout/stderr go here. Never contains fetched content. |
daemon.shutdown_grace_seconds |
int |
10 |
SIGTERM grace period before SIGKILL |
daemon.auto_stop_idle_seconds |
int | null |
null |
Seconds of idle (no active forward connections) before the daemon SIGTERMs itself. null disables. |
daemon.materialize |
bool |
false |
Write patched kubeconfig files to <session-dir>/tunnel-data/ (mode 0600). See On-disk materialization. |
NodeInput (per entry in nodes)
| Field | Type | Default | Description |
|---|---|---|---|
host |
str |
required | Remote SSH hostname or IP |
port |
int |
22 |
Remote SSH port |
user |
str |
required | Remote SSH user |
ssh_pkey |
str | null |
null |
PEM-encoded private key (in-memory, never written) |
ssh_password |
str | null |
null |
Password fallback. If neither ssh_pkey nor ssh_password is set, keys from $SSH_AUTH_SOCK (ssh-agent) are used; if the agent is also unavailable, schema validation fails. |
ssh_pkey_passphrase |
str | null |
null |
Optional passphrase for ssh_pkey |
remote_targets |
dict[str, str] | null |
null |
Up to 16 entries; each value is "host:port". Host is resolved on the SSH server side, enabling bastion-style cross-host forwards. |
ssh_options.compression |
bool |
false |
Enable SSH compression |
ssh_options.connect_timeout |
int |
60 |
Seconds |
required |
bool |
true |
If false, this node may fail without aborting start |
fetch_files |
dict[str, FileSpec] | null |
null |
Files to read at start (max 16) |
kube_targets |
dict[str, KubeTarget] | null |
null |
Kubernetes clusters to access via the SSH tunnel (max 16). See Kube mode. |
FileSpec (per entry in fetch_files)
| Field | Type | Default | Description |
|---|---|---|---|
path |
str |
required | Absolute remote path (no ~, no $VAR expansion) |
required |
bool |
true |
If false, fetch failure does not fail the node |
Constraints:
fetch_files/kube_targetslogical name:^[a-zA-Z_][a-zA-Z0-9_-]*$, 1..64 charsFileSpec.path/KubeTarget.kubeconfig_path: starts with/, 1..4096 chars- Per-file size cap: 1 MiB (exceeded →
EFBIG) - Host key verification: not enforced in this release. Use on trusted networks or with disposable hosts.
kube_targets is the high-level interface for k3s / Kubernetes access. For
each entry the tool:
- Fetches the remote kubeconfig over SFTP (same 1 MiB cap as
fetch_files). - Reads the
current-contextand extracts the associated cluster + user. - Resolves the
server:host on the SSH-server side (split-horizon DNS correct). - Opens a local forward
127.0.0.1:<os-assigned>→ apiserver. - Probes the apiserver's TLS certificate SAN to choose a
tls-server-name. - Rewrites
server:tohttps://127.0.0.1:<local_port>and injectstls-server-name. Other clusters in the file are byte-stable. - Returns the patched kubeconfig plus already-extracted fields
(
endpoint,certificate_authority_data,client_certificate_data,client_key_data,tls_server_name).
One cluster per target. The tool takes the current-context and ignores
all other contexts/clusters in the file. To access two clusters, use two
kube_targets entries. If the kubeconfig contains more than one context, a
warnings[] entry names the ignored contexts.
KubeTarget (per entry in kube_targets)
| Field | Type | Default | Description |
|---|---|---|---|
kubeconfig_path |
str |
required | Absolute remote path to the kubeconfig file |
tls_server_name |
str | null |
null |
Explicit TLS server name hint. If set, overrides the SAN probe entirely. |
insecure_fallback |
bool |
false |
See below. |
required |
bool |
true |
If false, this target's failure does not fail the node. |
TLS server name selection. When tls_server_name is not set, the tool
probes the apiserver certificate SAN and selects in order:
- The original
server:host, if it appears in the SAN. - The first DNS-type SAN.
- The first IP-type SAN.
If the selected name is not an exact match of the original server: host (a
fallback fired), a warnings[] entry records the chosen SAN.
insecure_fallback. When the SAN probe yields no usable name and no
explicit tls_server_name is set:
false(default): the target fails (subject torequired) with a clear error. Fail-fast.true: the patched kubeconfig carriesinsecure-skip-tls-verify: true,certificate-authority-datais dropped, and awarnings[]entry records that TLS verification was disabled for this target. Use only on disposable hosts on trusted networks.
Kube target output fields (under connections[node].kube_targets[name]):
| Field | Description |
|---|---|
cluster_name |
Cluster name from the kubeconfig |
context_name |
current-context value |
local_port |
OS-assigned local forwarded port |
endpoint |
https://127.0.0.1:<local_port> |
tls_server_name |
Chosen TLS server name, or null on insecure fallback |
certificate_authority_data |
Base64 CA cert, or "" on insecure fallback |
client_certificate_data |
Base64 client cert |
client_key_data |
Base64 client private key |
content_b64 |
Full patched kubeconfig (always present) |
path |
Absolute path to the materialized file, or null if daemon.materialize=false |
Success (OutputSchema)
session_dir is always present. Pass it to stop --session-dir.
Failure (ErrorOutput)
{
"error": "RequiredTunnelFailure",
"message": "required tunnel(s) failed to start",
"details": {
"failed": [
{"node": "edge1", "error": "required fetch_files failed: ['kubeconfig']"}
]
}
}Always inspect the top-level error key first to distinguish success from
failure.
| Value | Meaning | First remediation |
|---|---|---|
SSH_FX_NO_SUCH_FILE |
Path doesn't exist | ssh user@host ls -la <path> |
SSH_FX_PERMISSION_DENIED |
File ACL blocks the SSH user | Check ownership/mode |
SSH_FX_FAILURE |
Generic server-side SFTP failure | Inspect remote sshd logs |
SSH_FX_NO_CONNECTION |
SFTP subsystem rejected the channel | Verify Subsystem sftp in sshd_config |
SSH_FX_CONNECTION_LOST |
Channel died mid-read | Network instability; retry |
SSH_FX_OP_UNSUPPORTED |
Server doesn't implement the operation | Non-OpenSSH SFTP server; not supported |
EFBIG |
File exceeds the 1 MiB hard cap | This tool is for configs, not blobs |
ChannelOpenError / ConnectionResetError / TimeoutError |
Transport-level failure | Network or sshd config issue |
RuntimeError |
Internal state issue | Check stderr and daemon.log_file |
daemon.log_file(if set) receives only asyncssh/asyncio debug noise. Noprint/logcall path in this codebase carries decoded file bytes.content_b64is base64; callers must decode and protect it.tokenreturned bystartis the authorization handle forstop/status. Store it like a credential.- Private keys (
ssh_pkey) stay in process memory; they are never written to~/.sshor to a tempfile. Parsing happens viaasyncssh.import_private_key.
On-disk materialization (daemon.materialize)
By default (materialize=false) fetched content travels exactly once: from the
daemon to the parent process via an IPC pipe, then to the parent's stdout. The
tool itself never writes content to disk — the "content never to disk" guarantee
is preserved.
When materialize=true: the patched kubeconfig (including embedded private keys)
is written mode 0600 to <session-dir>/tunnel-data/<node>-<kube_target_name>.
The daemon removes these files on stop or atexit. The path field in the
kube target output becomes non-null. Callers opting in accept that decoded files
(including private keys) land on disk until stop/atexit runs. If the daemon
is killed with kill -9, tunnel-data/ is orphaned and must be cleaned up
manually: rm -rf <session-dir>/tunnel-data.
Host-key verification — threat model
Remote host keys are not verified in this release. This is a deliberate choice re-affirmed for kube mode: the tool targets disposable/CI hosts on trusted networks where the SSH endpoint is established out-of-band by the caller (e.g. infrastructure outputs). In kube mode the SSH transport carries the kubeconfig (with private keys) and the SAN probe result; a MITM on an unverified connection could tamper with both. Operators on untrusted networks must not use kube mode until host-key pinning lands. Pinning is a tracked future feature.
| Symptom | Likely cause |
|---|---|
error: SchemaValidationError, details mentions require |
Old top-level require field. Use per-node required: bool instead. |
error: SchemaValidationError, details mentions connections[...] |
Old output shape. connections[node] is now {ports, fetch_files, kube_targets}, not a list. |
error: SchemaValidationError, details mentions remote_ports |
The old remote_ports: list[int] field is gone. Use remote_targets: {"handle": "host:port"}. |
fetch_files[name].error == "EFBIG" |
File exceeds 1 MiB. Wrong file, or this tool isn't the right transport. |
fetch_files[name].error == "SSH_FX_PERMISSION_DENIED" |
The SSH user lacks read on the file. Check ACLs. |
kube_targets[name] missing or has error |
Check warnings[] for SAN-probe details; try setting explicit tls_server_name. |
start with a supplied --session-dir fails with "tunnel-data already exists" |
Orphaned tunnel-data/ from a previous kill -9. Remove it: rm -rf <session-dir>/tunnel-data. |
start hangs |
Node firewalled / DNS-stuck. Increase ssh_options.connect_timeout or remove the node. |
status says alive but stop says "token mismatch" |
The PID was reused. Token guards against this — investigate which process holds the PID. |
Two breaking changes from the original release:
Output shape
- jq '.connections.edge1[0].local_port'
+ jq '.connections.edge1.ports.kubeapi'Input require → per-node required
nodes:
edge1: {host: ..., remote_targets: {kubeapi: "127.0.0.1:6443"}}
- edge2: {host: ..., remote_targets: {kubeapi: "127.0.0.1:6443"}}
+ edge2: {host: ..., remote_targets: {kubeapi: "127.0.0.1:6443"}, required: false}
- require: ["edge1"]Pydantic's extra=forbid on InputSchema rejects the old require field
with a clear error.
Remote targets
- remote_ports: [6443]
+ remote_targets: {kubeapi: "127.0.0.1:6443"}- jq '.connections.edge1.ports[0].local_port'
+ jq '.connections.edge1.ports.kubeapi'Previous remote_ports: list[int] implied 127.0.0.1 on the SSH server.
New remote_targets makes the target host explicit, enabling
bastion-style forwards to other hosts in the SSH server's network.
local_ports is removed — local listeners are always OS-assigned.
Removed ssh_options fields: host_key_policy, known_hosts_path, threaded (unused since the asyncssh migration; extra=forbid rejects them).
stop --pid --token removed
The legacy stop --pid <pid> --token <token> interface is gone. The only stop
interface is now stop --session-dir <path>.
- RESULT=$(echo "$JSON" | tunstrap start)
- PID=$(jq -r '.pid' <<<"$RESULT")
- TOKEN=$(jq -r '.token' <<<"$RESULT")
- tunstrap stop --pid "$PID" --token "$TOKEN"
+ SESSION_DIR=$(mktemp -d)
+ RESULT=$(echo "$JSON" | tunstrap start --session-dir "$SESSION_DIR")
+ tunstrap stop --session-dir "$SESSION_DIR"--session-dir is optional on start (a temporary dir is generated if
omitted), but session_dir is always present in the output JSON. The
simplest migration is to capture and reuse it:
RESULT=$(echo "$JSON" | tunstrap start)
SESSION_DIR=$(jq -r '.session_dir' <<<"$RESULT")
# ... do work ...
tunstrap stop --session-dir "$SESSION_DIR"Unit:
pip install -e ".[dev]"
pytest tests/unitIntegration (Linux + Docker Compose v2):
pytest tests/integration -m integration- Kube-targets design:
docs/specs/2026-05-30-kube-targets-design.md - Fetch-files design:
docs/specs/2026-05-20-feature-fetch-files-design.md - Original design (historical):
docs/specs/2026-05-16-tunstrap-design.md
MIT.
{ "connections": { "edge1": { "ports": { "kubeapi": 40123 }, "fetch_files": { "kubeconfig": { "content_b64": "YXBpVmVyc2lvbjogdjEK...", "size": 2918, "sha256": "d2a0bf3c..." } }, "kube_targets": { "k3s": { "cluster_name": "default", "context_name": "default", "local_port": 40124, "endpoint": "https://127.0.0.1:40124", "tls_server_name": "edge1.example.net", "certificate_authority_data": "<b64>", "client_certificate_data": "<b64>", "client_key_data": "<b64>", "content_b64": "YXBpVmVyc2lvbjogdjEK...", "path": null } } } }, "pid": 12345, "token": "<opaque>", "session_dir": "/tmp/tunstrap-session-abc123", "started_at": "2026-05-30T10:00:00Z", "warnings": [] }