8000 GitHub - tedli/ssh-gateway · GitHub
[go: up one dir, main page]

Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

SSH Gateway

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.

What it supports

  • 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 session channel proxying for shell and exec commands.
  • SFTP via session + subsystem: sftp passthrough 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.

Architecture

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

SSH proxy flow

  1. Client connects to the gateway LoadBalancer on port 22.
  2. 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.
  3. The selected Route yields one upstream target:
    • Kubernetes Service DNS name;
    • upstream port and user;
    • upstream private key bytes;
    • optional proxyCommand transport.
  4. The gateway opens a second SSH client connection to the upstream.
  5. SSH global requests and channels are proxied between the two transports.

Important channel behavior:

  • session is proxied for shell, exec, PTY, env, signals, exit status, and SFTP subsystem requests.
  • direct-tcpip is opened on the upstream SSH connection, so a client request for 127.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.com and hostkeys-prove-00@openssh.com) are intentionally filtered because gateway and upstream are separate SSH transports with different session IDs.

Custom Resource

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

Spec fields

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.

Forwarding and host key spec fields

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: false

For VS Code Remote SSH, TCP forwarding must be enabled either globally:

defaultAllowTcpForwarding: true

or on the relevant Route's spec.allowTcpForwarding.

Project layout

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

Build

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.

Regenerate code

make generate
make manifests

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

Vendoring (-mod=vendor)

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 vendor

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

klog fork and the replace directive

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-5a4969b5a1c1

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

Build with Dockerized Go

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

Run tests with Dockerized Go

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 `
  test -mod=vendor ./...

Build the image

make generate manifests
make build

# custom registry / architecture
REPO=your.registry/ssh-gateway ARCH=arm64 make build

The Dockerfile uses vendored Go builds (-mod=vendor) and produces a distroless runtime image containing only /usr/local/bin/gateway.

Install

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=true

Prerequisites:

  • Kubernetes v1.25+.
  • cert-manager with the configured clusterIssuerName (default: selfsigned) for the validating webhook certificate. The webhook HTTPS server (provided by venus-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: password login Route (demo.yaml)

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-demo

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

Operational notes

  • Host keys are read from the Helm-managed {{ .Release.Name }}-host-keys Secret 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.*.private value is empty (the default), the chart generates that key on first install via Helm's genPrivateKey; the resulting Secret carries helm.sh/resource-policy: keep and is guarded by a lookup, 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 default defaultInsecureIgnoreHostKey, which defaults to false). When verification is enabled the gateway pins the upstream host key to the ssh-known-hosts entry (OpenSSH authorized_keys line format) of the Route's upstream Secret; otherwise behavior is equivalent to OpenSSH StrictHostKeyChecking=no for the upstream leg.
  • Set defaultInsecureIgnoreHostKey: true (or spec.insecureIgnoreHostKey: true on 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 no ssh-known-hosts entry is required.
  • Downstream client authentication runs on the underlying golang.org/x/crypto/ssh ServerConfig with its default MaxAuthTries of 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 at V(1) right after a pod starts is expected and harmless. The chart's startupProbe is a tcpSocket check against the SSH port (22): kubelet opens a TCP connection and closes it without performing an SSH handshake, so ssh.NewServerConn returns EOF. The remote address in that line is the node IP because the probe originates from kubelet on the node. It appears only once because the startupProbe stops after its first success; livenessProbe/readinessProbe target the HTTPS port (/healthz, /readyz) and never reach the SSH listener.

Roadmap

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 logr events 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.

License

Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE for details.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

0