English | 简体中文
ssh-gateway is a Kubernetes-native SSH gateway. Users connect to one
LoadBalancer endpoint, authenticate against Route-defined credentials, and the
gateway proxies the SSH transport to the Route's upstream SSH service.
The current implementation is an in-process Go SSH proxy. It does not run
OpenSSH sshd, does not render sshd_config, and does not use
ForceCommand. This is intentional: VS Code Remote SSH, SFTP, and port
forwarding rely on SSH channel types such as session, subsystem, and
direct-tcpip; those channels must be proxied at the SSH protocol layer rather
than through a shell command.
- Route CRD based SSH access management.
- Downstream authentication by password or public key.
- Upstream authentication with a private key stored in a Kubernetes Secret.
- Multiple Routes sharing one ingress username:
- at most one password Route;
- any number of public-key Routes;
- public keys must be disjoint.
- SSH
sessionchannel proxying for shell and exec commands. - SFTP via
session+subsystem: sftppassthrough to the upstream SSH server. - TCP forwarding via
direct-tcpip/forwarded-tcpip, including VS Code Remote SSH's dynamic forwarding path. - Optional agent-forwarding channel passthrough.
- Shared gateway host keys across replicas through a Kubernetes Secret.
- Per-pod status sharding through Server-Side Apply.
+-----------------------------+
| kube-apiserver |
+--------------+--------------+
|
| Route CRs + Secrets
v
+--------------------------------------------------+
| ssh-gateway (StatefulSet, replicas=N) |
| |
| pod ssh-gateway-0 pod ssh-gateway-1 |
| +-------------------+ +-------------------+|
| | gateway (PID 1) | | gateway (PID 1) ||
| | reconciler | | reconciler ||
| | sshproxy.Server | | sshproxy.Server ||
| | route snapshot | | route snapshot ||
| +-------------------+ +-------------------+|
| ^ | ^ | |
| | | | | |
| shared host-key Secret shared host-key Secret|
+--------|-------|------------------|-------|-------+
| | | |
LB :22| | upstream SSH | | upstream SSH
v v v v
SSH clients Backend Service(s) SSH clients Backend Service(s)
Each pod independently reconciles all Routes and updates an in-memory snapshot
used by its local sshproxy.Server. There is no shared filesystem and no leader
election. Each pod writes only its own status.instances[podName] entry via
Server-Side Apply with field manager ssh-gateway-<podName>.
- Client connects to the gateway LoadBalancer on port 22.
- The Go SSH server authenticates the client:
- password auth selects the username's password Route;
- public-key auth maps the key fingerprint to a public-key Route.
- The selected Route yields one upstream target:
- Kubernetes Service DNS name;
- upstream port and user;
- upstream private key bytes;
- optional
proxyCommandtransport.
- The gateway opens a second SSH client connection to the upstream.
- SSH global requests and channels are proxied between the two transports.
Important channel behavior:
sessionis proxied for shell, exec, PTY, env, signals, exit status, and SFTP subsystem requests.direct-tcpipis opened on the upstream SSH connection, so a client request for127.0.0.1:<port>resolves on the upstream host, not in the gateway pod. This is what makes VS Code Remote SSH work.- OpenSSH host-key update requests (
hostkeys-00@openssh.comandhostkeys-prove-00@openssh.com) are intentionally filtered because gateway and upstream are separate SSH transports with different session IDs.
apiVersion: paas.infrawaves.com/v1alpha1
kind: Route
metadata:
name: my-route
namespace: demo
spec:
ingress:
username: exampleuser
method: publicKey # or password
secretRef:
name: ingress-key
upstream:
serviceRef:
name: upstream-ssh
port: 22
user: root
secretRef:
name: upstream-root-key
status:
instances:
- podName: ssh-gateway-0
phase: Ready
message: proxy snapshot applied| Field | Purpose |
|---|---|
ingress.username |
Username clients use when connecting to the gateway. |
ingress.method |
password or publicKey. |
ingress.secretRef |
Secret holding data.password or data.authorized_keys. |
ingress.secretKey |
Optional override for the credential key in the Secret. |
ingress.password |
Optional inline password for demos/tests; prefer Secrets otherwise. |
upstream.serviceRef |
Kubernetes Service fronting the upstream SSH server. |
upstream.namespace |
Optional upstream Service namespace; defaults to the Route namespace. |
upstream.port |
Upstream SSH port; defaults to 22. |
upstream.user |
Username used by the gateway when logging into upstream; defaults to root. |
upstream.secretRef |
Secret containing the upstream private key. |
upstream.secretKey |
Optional key name; defaults to ssh-privatekey. |
upstream.proxyCommand |
Optional stdio transport command. - disables the global proxy command. |
TCP forwarding, agent forwarding, and upstream host key checking are controlled
by gateway defaults and per-Route spec fields. Each field is an optional
boolean; when omitted the gateway-global default applies:
spec:
allowTcpForwarding: true
allowAgentForwarding: true
# When false, the gateway verifies the upstream host key against the
# "ssh-known-hosts" entry of this Route's upstream Secret.
insecureIgnoreHostKey: falseFor VS Code Remote SSH, TCP forwarding must be enabled either globally:
defaultAllowTcpForwarding: trueor on the relevant Route's spec.allowTcpForwarding.
cmd/gateway/ # main entrypoint and manager wiring
pkg/api/v1alpha1/ # Route CRD types + generated code
pkg/gateway/ # Route reconciler, Secret watches, SSA status
pkg/sshproxy/ # in-process SSH server, auth store, channel proxy
pkg/validating/ # admission webhook
pkg/service/ # service-level constants
charts/ssh-gateway/ # Helm chart
Dockerfile # builds the gateway binary into a distroless image
Makefile # image build, CRD generation, manifests
The supported, verified build path is the containerized Linux toolchain. The
gateway is built with CGO_ENABLED=0 for linux, so native Windows builds are
not a valid verification path on a Windows workstation.
Use the containerized toolchain instead.
make generate
make manifestsOn Windows, make generate prints controller-gen warnings and a
failed executing generator / not all generators ran successfully error from the
apply-configuration generator. This is expected: that generator emits import paths with the
OS path separator (\) on Windows. hack/fix-applyconfiguration rewrites them afterward and
runs only on Windows, so the output is safe to ignore as long as make generate exits 0.
Both the Dockerfile image build (via make gateway / make build)
and the example docker run commands below pass -mod=vendor. This makes the
build use the local vendor/ directory instead of downloading modules from the
network, which speeds up container builds and removes the per-build dependency
download.
The vendor/ directory is intentionally not committed (it is listed in
.gitignore). Because of this, you must populate it once before any
container-based build with -mod=vendor can succeed:
go mod vendorIf you would rather not vendor, you can simply drop -mod=vendor from the
go build line in the Dockerfile and from the docker run
commands below; Go will then download modules normally at build time.
This project builds on venus-kit,
which depends on a fork of k8s.io/klog/v2. Our go.mod therefore
repeats the same replace line:
replace k8s.io/klog/v2 => github.com/tedli/klog/v2 v2.0.0-20260407032038-5a4969b5a1c1The fork adds an HTTP API (klog.Configure) that lets operators change the log
level of a running process at runtime — useful during an incident to temporarily
raise verbosity without restarting the gateway, then lower it again.
replace directives in Go only take effect in the main module being built.
When venus-kit is consumed as a dependency, its own replace is ignored by the
Go toolchain, so every downstream service must repeat the same line in its
go.mod. This is a Go hard constraint, not a choice. Keeping the fork under the
original k8s.io/klog/v2 module path is required so that indirect dependencies
(client-go, controller-runtime, …) share the same global klog state and can
also be tuned at runtime. See the
venus-kit README
for the upstream rationale.
PowerShell:
docker run --rm -ti --entrypoint go `
-v ${PWD}:/go/src/github.com/infrawaves-venus/ssh-gateway `
-w /go/src/github.com/infrawaves-venus/ssh-gateway `
golang:1.26.1-trixie `
build -mod=vendor ./cmd/gatewayPowerShell:
docker run --rm -ti --entrypoint go `
-v ${PWD}:/go/src/github.com/infrawaves-venus/ssh-gateway `
-w /go/src/github.com/infrawaves-venus/ssh-gateway `
golang:1.26.1-trixie `
test -mod=vendor ./...make generate manifests
make build
# custom registry / architecture
REPO=your.registry/ssh-gateway ARCH=arm64 make buildThe Dockerfile uses vendored Go builds (-mod=vendor) and produces a distroless
runtime image containing only /usr/local/bin/gateway.
helm upgrade --install ssh-gateway \
charts/ssh-gateway \
--namespace ssh-gateway-system --create-namespace \
--set gateway.image=your.registry/ssh-gateway:vX.Y \
--set defaultAllowTcpForwarding=truePrerequisites:
- Kubernetes v1.25+.
cert-managerwith the configuredclusterIssuerName(default:selfsigned) for the validating webhook certificate. The webhook HTTPS server (provided byvenus-kit) hot-reloads this certificate from disk on the next TLS handshake after cert-manager rotates it, so renewals take effect without restarting the gateway pods.- Upstream Services must expose SSH servers reachable from the gateway pods.
demo.yaml provides a simple password-login Route and an upstream SSH service
for validation. Apply it with:
kubectl apply -f demo.yaml
kubectl -n ssh-gateway-demo get pod,svc,route
kubectl -n ssh-gateway-demo describe route password-login-demoAfter the gateway LoadBalancer has an address:
ssh exampleuser@<ssh-gateway-lb> -p 22
# password: ChangeMe123.The demo uses an inline password for convenience. Production Routes should reference Secrets for credentials.
- Host keys are read from the Helm-managed
{{ .Release.Name }}-host-keysSecret mounted at/etc/ssh/host-keys. - All replicas mount the same host-key Secret so clients see a stable gateway identity regardless of LoadBalancer routing.
- The chart ships no host keys. When a
gateway.hostKeys.*.privatevalue is empty (the default), the chart generates that key on first install via Helm'sgenPrivateKey; the resulting Secret carrieshelm.sh/resource-policy: keepand is guarded by alookup, so keys stay stable across upgrades. Provide your own for a real deployment, e.g. via--set-file gateway.hostKeys.rsa.private=./ssh_host_rsa_key. Only private keys are stored — the gateway derives public material itself. - Upstream host key verification is on by default and can be relaxed per Route
via
spec.insecureIgnoreHostKey(gateway defaultdefaultInsecureIgnoreHostKey, which defaults tofalse). When verification is enabled the gateway pins the upstream host key to thessh-known-hostsentry (OpenSSH authorized_keys line format) of the Route's upstream Secret; otherwise behavior is equivalent to OpenSSHStrictHostKeyChecking=nofor the upstream leg. - Set
defaultInsecureIgnoreHostKey: true(orspec.insecureIgnoreHostKey: trueon a Route) only when every upstream is inherently trusted — for example an in-cluster container reached over the pod network rather than an external SSH service. In that case verification can be skipped and nossh-known-hostsentry is required. - Downstream client authentication runs on the underlying
golang.org/x/crypto/sshServerConfigwith its defaultMaxAuthTriesof 6, which caps the number of authentication attempts per connection. The gateway is therefore not exposed to unbounded brute-force from this setting today. Stronger hardening — for example a per-source-address auth-attempt limit — is planned; see the Rate limiting roadmap item below. - A single
"SSH handshake failed" ... error="EOF"line atV(1)right after a pod starts is expected and harmless. The chart'sstartupProbeis atcpSocketcheck against the SSH port (22): kubelet opens a TCP connection and closes it without performing an SSH handshake, sossh.NewServerConnreturnsEOF. Theremoteaddress in that line is the node IP because the probe originates from kubelet on the node. It appears only once because thestartupProbestops after its first success;livenessProbe/readinessProbetarget the HTTPS port (/healthz,/readyz) and never reach the SSH listener.
The following capabilities are planned but not yet implemented. They are listed here so operators can plan around current limitations; none of them are available today.
- Audit logging — structured, per-session audit records (who connected,
which Route, source address, upstream target, channel types opened, session
start/end). Intended to be emitted as structured
logrevents and/or shipped to an external sink, without breaking the in-process proxy boundary. - Session recording — capture of interactive
session-channel I/O (PTY shell streams) for compliance replay, stored out-of-band rather than on a shared gateway PVC. Must remain optional and per-Route to bound overhead. - Rate limiting — throttling of new connection and authentication attempts per source address and/or per ingress username, to blunt brute-force and connection-storm scenarios at the gateway edge.
- Connection quotas — caps on concurrent sessions per ingress username and
per Route, enforced independently in each pod's
sshproxy.Server(consistent with the no-shared-state, per-pod runtime model). - Richer Prometheus metrics — counters and histograms for connections, authentication outcomes, active sessions, channel types, upstream dial latency, and proxy throughput, exposed on the existing HTTP server.
Design constraints for all of the above: keep the in-process SSH proxy
boundary intact, prefer typed Route spec fields plus webhook validation over
annotations for per-Route policy, and avoid reintroducing shared filesystem
state.
Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE for details.