Silta binds local TCP ports and forwards each accepted connection to a Kubernetes Service in a chosen cluster context. It talks to the Kubernetes API directly, resolving the Service to a Ready backing pod and opening a port-forward stream straight to it -- no kubectl subprocess and nothing deployed into the cluster.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Alexey Aristov cd38727897 Report which side closed a bridged connection
The bridge logged a bare 'closed' for every disconnect, so a graceful client
hang-up and an upstream stream that died mid-session (a killed pod, a reset
port-forward leg) looked identical -- exactly the case that made a recent
investigation take an hour. Replace copy_bidirectional with a copy that names
the side that ended the connection: client close stays INFO, an upstream-ended
stream or a copy error is logged at WARN, and the pod:port is on the line so
the operator knows which upstream to look at.

Closes #11
2026-08-14 16:09:45 +02:00
.github/workflows Drop the Intel macOS release target 2026-08-11 14:43:17 +02:00
docs/examples Derive local ports from a context-level offset 2026-08-11 13:51:01 +02:00
src Report which side closed a bridged connection 2026-08-14 16:09:45 +02:00
.gitignore Scaffold silta Cargo project under BSD 3-Clause 2026-08-06 18:21:32 +02:00
Cargo.lock Replace script printing with run, setup and teardown subcommands 2026-08-11 13:51:02 +02:00
Cargo.toml Add skill and describe subcommands, a global --config flag, and loopback-restricted listeners 2026-08-11 13:51:02 +02:00
LICENSE Scaffold silta Cargo project under BSD 3-Clause 2026-08-06 18:21:32 +02:00
README.md Add README 2026-08-11 13:51:00 +02:00

Silta

Silta (Finnish): a bridge. A local TCP port on one bank, a Kubernetes Service on the other, and a fresh crossing laid for every connection.

Silta binds local TCP ports and forwards each accepted connection to a Kubernetes Service in a chosen cluster context. It talks to the Kubernetes API directly, resolving the Service to a Ready backing pod and opening a port-forward stream straight to it -- no kubectl subprocess and nothing deployed into the cluster.

Why

kubectl port-forward runs one process per forward. The listener dies with the process, and startup takes 0.5 to 1.5 seconds against a remote API server. Respawning it per connection restores isolation, but each connection then pays for a subprocess and an intermediate local port, and kubectl must be on PATH.

krelay avoids the per-connection cost by deploying a helper pod. That modifies the cluster, which is not always permitted.

Silta provides per-connection isolation without a subprocess and without an in-cluster helper. Each connection resolves its own pod and opens its own stream, so a failure affects only the connection that caused it and the listener stays up. A connection to a pod that is later replaced does not affect the next connection.

What it does

client -> local TCP port -> silta (resolve target, port-forward) -> API server -> pod

For each accepted connection, a svc/NAME target is resolved as follows:

  1. Read the Service in its namespace: take .spec.selector, find the .spec.ports entry whose port matches the configured remote, and take its targetPort (numeric or named).
  2. List pods by that selector and pick the first with a Ready condition of True. A named targetPort is resolved against that pod's container ports.
  3. Open a port-forward stream to pod:port and bridge it to the client socket in both directions.

Pod selection happens per connection, not from a cache, so each new connection lands on a currently-Ready pod. Resolution is two API calls.

A pod/NAME target skips steps 1 and 2: the pod is named outright, so no API call precedes the stream. Two consequences follow:

  • remote is the container port, not a Service port. No targetPort mapping is applied.
  • The pod is pinned. Nothing re-resolves it, so once that pod is replaced, every later connection fails until the config is edited. Use svc/NAME unless one specific pod is required, for example when replicas differ.

Startup resolves the config into one flat list of forwards and refuses to start if two forwards would bind the same socket, naming both. The check is per address and port: the same port on two different bind_addresses is allowed, and a 0.0.0.0 bind conflicts with every address on that port.

What counts as a failure

The config is all-or-nothing; the listeners are not.

A forward that does not resolve -- an unknown target prefix, a missing local with no context offset and no bind_address, a derived port out of range, two forwards claiming one socket -- aborts startup before anything binds. A config file is applied as a unit, so one invalid forward prevents the run. All invalid forwards are reported together.

A forward that resolves but cannot bind -- typically a local port already in use -- is logged and skipped, and the rest are served. Startup reports the count (serving 3 of 4 forward(s); 1 could not bind) and aborts only when no forward bound at all, so a process forwarding nothing does not appear healthy to a supervisor. --require-all makes any bind failure fatal, for supervisors that should restart the process rather than run it degraded.

Past startup, a failure stays with one connection: a resolve or port-forward error warns and closes that client, and the listener keeps accepting.

Quick start

Build (Rust, 2024 edition, stable toolchain):

cargo build --release

Run:

silta run --config docs/examples/example.toml

Silta reads the kubeconfig the same way kubectl does, exec credential plugins included; the kubectl binary is not required. It binds every configured local port and serves until SIGINT. It leaves no state on disk and nothing in the cluster.

Connect to a forwarded port as to the service itself:

psql -h 127.0.0.1 -p 15432 -U postgres
curl http://127.0.0.1:18080/-/healthy

Configuration

TOML. A file has optional top-level keys, an optional [defaults] table, and one or more [[context]] blocks; each context names a kube context and lists its forwards inline.

interface = "feth99"             # optional; the virtual interface setup creates.
                                 # Top-level, so it must sit above the first table

[defaults]                       # all keys optional
bind_address = "127.0.0.1"
connect_timeout_secs = 30
read_timeout_secs = 0            # 0 = no read timeout
tcp_keepalive_secs = 30          # 0 = no keepalive

[[context]]
name = "my-cluster"
offset = 10000                   # local port = remote + offset, where local is omitted
connect_timeout_secs = 45        # optional per-context override

forwards = [
  { namespace = "db",        target = "svc/postgres",  remote = 5432 },   # -> 15432
  { namespace = "telemetry", target = "svc/collector", remote = 4317 },   # -> 14317
  # one named pod; remote is its container port, and it is never re-resolved:
  { namespace = "db",        target = "pod/postgres-0", local = 15433, remote = 5432 },
  # per-forward overrides win over context, which wins over defaults:
  { namespace = "monitoring", target = "svc/prometheus", remote = 8080, bind_address = "0.0.0.0" },
]

Per-forward keys:

Key Meaning
namespace Namespace holding the Service.
target svc/NAME or pod/NAME. A prefix is required; anything else is a startup error.
local Local TCP port to bind, 1..=65535. Optional when the context sets offset, or when the forward or its context sets bind_address.
remote The Service port to reach, 1..=65535.

Deriving local ports with offset

When a context sets offset, any forward that omits local gets remote + offset. One offset per context replaces a hand-written local on every forward, and the local port stays related to the remote one: with offset = 20000, Postgres on 5432 is served at 25432 and Prometheus on 8080 at 28080. Distinct offsets per context prevent collisions between contexts.

local takes precedence where given, so a single forward can deviate from the scheme. A derived port outside 1..=65535 is a startup error naming the forward.

Giving a context its own address instead

An offset exists only to prevent collisions on 127.0.0.1. A context with its own bind_address has no such collisions, so its forwards may omit local as well; it then defaults to the remote port:

[[context]]
name = "test"
bind_address = "127.0.0.11"
forwards = [ { namespace = "db", target = "svc/postgres", remote = 5432 } ]   # -> 127.0.0.11:5432

Every service then answers on its real port, so a connection string from the cluster works with only the host changed. An offset on the same context still takes precedence, in case both are wanted. A bind_address inherited from [defaults] does not enable this default, because every context would share the address and defaulting local to remote would collide by construction.

Silta cannot own an address in 127.0.0.0/8: the kernel pins that range to the loopback interface, and a loopback alias records no owner. Contexts must use addresses outside it. 192.0.2.0/24 is reserved for documentation (RFC 5737) and is never routed, which makes it a safe choice. Silta manages such addresses itself:

sudo silta setup    --config forwards.toml
     silta run      --config forwards.toml
sudo silta teardown --config forwards.toml

setup creates the interface the config names -- the top-level interface key, feth99 when omitted -- puts one /32 address on it per context, and adds a block to /etc/hosts so clients can use names:

psql -h test.silta.internal -p 5432

Names are suffixed .silta.internal. .internal is reserved by ICANN for private use and will never be delegated, so the names cannot collide with real DNS. The silta label identifies the tool that wrote them.

Ownership is recorded explicitly. The config names its interface, setup refuses if that interface already exists, and teardown refuses unless /etc/hosts holds a block for it. setup and update also refuse to claim an address that already exists on another interface, naming the address and its owner, because a second copy would shadow the existing one. The hosts block is keyed by interface name, so two configs naming different interfaces coexist and each removes only its own. /etc/hosts.silta.bak keeps the previous file either way.

Dedicated addresses are not reachable from the network. macOS accepts a packet addressed to any local address regardless of the interface it arrives on (the weak host model), so an address on the silta interface would otherwise answer to any LAN peer with a static route to the machine. run therefore binds every dedicated-address listener to lo0 with IP_BOUND_IF. Local clients are unaffected, because the /32's delivery route already runs through loopback; a connection arriving from the network matches no socket and is refused.

CLI

silta run      [--port-offset N] [--require-all]
silta setup    [--persistent]        # needs root
silta update                         # needs root
silta teardown                       # needs root
silta describe
silta skill    [--out FILE]

Every subcommand reads the same config, resolved in this order: --config FILE (before or after the subcommand), the SILTA_CONFIG environment variable, then ~/.config/silta/config.toml.

setup, update and teardown apply only to configs whose contexts bind dedicated addresses; for a config that stays on 127.0.0.1 they do nothing.

Command Does
run Binds the forwards and serves until SIGINT.
setup Creates the interface the config names, puts one /32 address on it per context, and writes that interface's /etc/hosts block. Refuses if the interface already exists.
update Reconciles an existing interface with the config: adds addresses that appeared, drops ones that went away, rewrites the hosts block if names changed.
teardown Destroys the interface and removes its hosts block. Refuses unless that block exists, which is the only record that silta created the interface.
describe Prints the config's forwards with their local addresses as markdown, querying each cluster for what the targets are (port protocol, container image). An unreachable cluster leaves its hints blank.
skill Writes a short skill file for coding agents. It lists no forwards; it instructs the reader to run silta describe for the current state, so the file cannot go stale. Stdout unless --out names a file.
Flag On Meaning
--port-offset N run Add N to every local port. Negative allowed; remote ports untouched.
--require-all run Exit if any forward fails to bind, instead of serving the rest.
--persistent setup Also install a boot daemon, so the interface survives a reboot.

Privileges

setup, update and teardown reconfigure the host and refuse to start unless already root; silta never escalates by itself. run refuses to start as root: it needs no privileges, and running it as root leaves the exec credential plugin's token cache under ~/.kube owned by root, which breaks later unprivileged runs. A local port below 1024 would be the one reason to want root, and a forward can use a higher port instead.

What run checks before binding

Host reconfiguration and forwarding are separate commands, so the host can drift from the config. run checks before binding:

  • The interface is missing: refuse and point at setup. Binding would otherwise fail once per forward.
  • An address the config needs is absent, typically a context added since setup: refuse, name the address, and point at update.
  • Only spare addresses or stale host names remain, typically a context removed: warn and continue, since no forward is affected.

Surviving a reboot

The interface does not survive a reboot; /etc/hosts, being a file, does. After a reboot, run setup again, or install a per-interface LaunchDaemon once with --persistent so setup re-runs at boot.

--persistent refuses unless both the silta binary and the config file are owned by root and writable by nobody else. The boot daemon runs as root, so anything able to rewrite what it points at gains root at boot, and a binary in target/release is rewritten by every cargo build. Install both outside the home directory first:

sudo install -d -o root -m 755 /usr/local/etc/silta
sudo install -o root -m 755 target/release/silta /usr/local/bin/silta
sudo install -o root -m 644 forwards.toml /usr/local/etc/silta/
sudo /usr/local/bin/silta setup --persistent --config /usr/local/etc/silta/forwards.toml

The plist is written but not loaded, so it takes effect at the next boot: loading it immediately would re-run setup against an interface that already exists. teardown always removes the daemon; leaving it in place would recreate at the next boot what teardown removed.

Timeouts and liveness

A port-forward stream carries no traffic of its own while a session is idle -- a psql prompt, an open gRPC stream -- so nothing distinguishes a healthy idle session from one whose network path has died, unless something probes it.

read_timeout_secs is 0 (disabled) by default and must stay disabled for idle sessions to survive. kube applies it at the connector, so it covers every raw socket read, including reads on the port-forward stream; a non-zero value severs healthy idle sessions.

tcp_keepalive_secs detects dead paths instead. It enables TCP keepalive on the connection to the API server: probes start after the configured idle time, repeat every third of it, and give up after three failures. When the network path dies without a FIN -- a VPN drop, a sleeping laptop, a changed network -- the kernel fails the socket, the bridge tears down, and the client sees a closed connection and reconnects. Without keepalive the bridge waits indefinitely and the client never sees a close.

Each connection's setup is bounded by connect_timeout_secs. Without it, a new connection reusing a pooled socket that died silently waits indefinitely, since the read timeout is off. Missing the deadline also drops the cached API client, so the next connection does not inherit the same dead connection pool.

Design notes

  • Per connection, not per session. Every accepted connection resolves its own pod and opens its own stream. A failure is isolated to one client; the listener stays up.
  • Nothing in the cluster. No helper pod, no daemonset, no privileges beyond reading Services and pods and opening a port-forward.
  • No kubectl. The kubeconfig is read directly, exec credential plugins included, with no subprocess per connection.
  • Services and pods. svc/NAME re-resolves to a Ready pod per connection; pod/NAME names one outright and is never re-resolved. Deployments, statefulsets and bare names are out of scope: a target without a known prefix is a startup error, not a guess.
  • Conflicts are startup errors. A local port claimed by two forwards aborts before anything binds and names both offenders.
  • The API client is cached, keyed by (context, connect timeout, read timeout, keepalive). Forwards sharing those share one client, and it is dropped when a connection's setup misses its deadline.

Implementation

Rust (2024 edition) on Tokio: kube and k8s-openapi for cluster access, rustls for TLS, clap for the CLI, serde and toml for config, tracing for logs, anyhow for startup errors.

kube's own Client::try_from builds its connector with TCP keepalive off and offers no way to pass one in, so k8s::build_client assembles the same layer stack by hand around a keepalive-enabled HttpConnector. It omits kube's TraceLayer, so per-request HTTP debug spans are not emitted.

A Note on Agentic Engineering

LLM agents were heavily involved in writing this code. Every change has nonetheless been reviewed by a human, who made a best effort to understand, test, and validate it before it landed. Where the agents and the human disagreed, the human had the final say -- and bears responsibility for the result, bugs included. Read it with the same scrutiny you'd apply to any code: trust, but verify.

License

BSD 3-Clause. See LICENSE.