Files
sish-client/README.md
T
traberph 32b060a502
build / image (push) Successful in 1m3s
init
2026-09-26 19:42:11 +02:00

7.2 KiB

sish-client

A small, environment-driven connector for a sish edge. It opens SSH reverse tunnels to the edge, claims hostnames there, and relays their traffic to local targets. In the default mode sish routes by SNI without terminating TLS, and each connection starts with a PROXY v2 header carrying the real client address.

client ──TLS──▶ edge :443 (sish, SNI routing) ══ssh══▶ sish-client ──▶ target (terminates TLS)

Image: registry.traberph.de/public/sish-client (Alpine + OpenSSH client + tini, ~20 MB, runs as nobody, works with a read-only root filesystem).

Quick start

docker run -d --name sish-client --read-only --tmpfs /tmp \
  -e SISH_HOST=edge.example.com \
  -e SISH_HOST_KEY="ssh-ed25519 AAAA..." \
  -e ROUTES="app.example.com=app:8443" \
  -v ./id_ed25519:/secrets/id_ed25519:ro \
  registry.traberph.de/public/sish-client:latest

The connector's public key must be authorized on the edge (sish --authentication-keys-directory), and each hostname must be allowed there (sish --bind-hosts or _sish TXT records).

Configuration

Variable Default Description
SISH_HOST required Edge hostname or IP
SISH_PORT 2222 sish SSH port
SISH_USER connector SSH user (sish accepts any; shows up in edge logs)
SISH_KEY Private key contents (e.g. from a Kubernetes secretKeyRef). Takes precedence over SISH_KEY_FILE
SISH_KEY_FILE /secrets/id_ed25519 Path to a mounted private key
SISH_HOST_KEY Edge host public key, "<type> <base64>" (the first two fields of the .pub file)
SISH_KNOWN_HOSTS_FILE Alternative to SISH_HOST_KEY: a known_hosts file matching [SISH_HOST]:SISH_PORT
SISH_STRICT_HOST_KEY true false disables host key verification (testing only)
ROUTES Routes, separated by commas, spaces or newlines (see below)
ROUTES_FILE File with routes, one per line, # comments allowed. Combined with ROUTES
TARGET Default host:port for routes without an explicit target
DEFAULT_BIND_PORT 443 Edge port for routes without an explicit bind port
SNI_PROXY true Ask sish to route by SNI instead of terminating TLS
PROXY_PROTOCOL 2 PROXY header version sent to the target: 1, 2 or off
KEEPALIVE_INTERVAL 15 Seconds between SSH keepalives (3 missed = reconnect)
CONNECT_TIMEOUT 10 SSH connect timeout in seconds
BACKOFF_MIN / BACKOFF_MAX 2 / 60 Reconnect backoff in seconds (doubles per failure, resets after a successful session)
SSH_EXTRA_OPTS Extra ssh arguments, e.g. -o Ciphers=aes128-gcm@openssh.com
STATE_DIR /tmp/sish-client Writable directory for the key copy, known_hosts and the ready marker

Routes

<host>[:<bind-port>][=<target-host>:<target-port>]
Example Meaning
app.example.com=traefik:8443 SNI app.example.com on edge :443 → traefik:8443
app.example.com Same, using TARGET
*.apps.example.com=traefik:8443 Wildcard (sish needs --bind-wildcards)
app.example.com:80=traefik:8080 Plain HTTP on edge :80, routed by Host header (needs SNI_PROXY=false, see below)

HTTP (:80) and SNI (:443) routes cannot share one connector. With SNI_PROXY=true sish treats every forward as an SNI listener, which collides with its HTTP listener on :80. Run a second instance for HTTP routes (e.g. for HTTP→HTTPS redirects):

SNI_PROXY=false PROXY_PROTOCOL=off ROUTES="app.example.com:80=traefik:8080"

If the edge rejects any route, the whole session fails (ExitOnForwardFailure) and is retried with backoff. A misconfigured route is therefore loud (visible in the logs and health status) instead of silently missing.

Several connectors may claim the same hostname. sish then load-balances between them, which is how you get redundancy (e.g. one connector per ingress replica).

Health

The container writes $STATE_DIR/ready once sish has confirmed every route, and removes it when the tunnel drops. The image's HEALTHCHECK tests this file; in Kubernetes use it as a readiness probe:

readinessProbe:
  exec:
    command: ["test", "-f", "/tmp/sish-client/ready"]
  periodSeconds: 5

Examples

Docker Compose

services:
  app:
    image: nginx   # must terminate TLS and accept PROXY protocol on 8443
  connector:
    image: registry.traberph.de/public/sish-client:latest
    read_only: true
    tmpfs: [/tmp]
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    environment:
      SISH_HOST: edge.example.com
      SISH_HOST_KEY: ssh-ed25519 AAAA...
      ROUTES: app.example.com=app:8443
    volumes:
      - ./id_ed25519:/secrets/id_ed25519:ro
    restart: unless-stopped

The key file must be readable by uid 65534 inside the container. Compose ignores uid/mode on file secrets, so either:

  • make the key readable by a group and run with user: "65534:<gid>". With rootless Docker your host files appear as owned by root inside the container, so the gid is 0.
  • or pass the key via SISH_KEY instead of a file.

Kubernetes sidecar (next to an ingress controller)

- name: sish-client
  image: registry.traberph.de/public/sish-client:20260926154233-1a2b3c4 # {"$imagepolicy": "flux-system:sish-client"}
  env:
    - { name: SISH_HOST, value: edge.example.com }
    - { name: SISH_HOST_KEY, value: "ssh-ed25519 AAAA..." }
    - { name: ROUTES_FILE, value: /etc/sish-client/routes.txt }
    - { name: TARGET, value: "127.0.0.1:9443" } # ingress entrypoint with PROXY protocol
    - name: SISH_KEY
      valueFrom: { secretKeyRef: { name: sish-client, key: id_ed25519 } }
  securityContext:
    runAsNonRoot: true
    readOnlyRootFilesystem: true
    allowPrivilegeEscalation: false
    capabilities: { drop: [ALL] }
  volumeMounts:
    - { name: tmp, mountPath: /tmp }
    - { name: routes, mountPath: /etc/sish-client, readOnly: true }
  readinessProbe:
    exec: { command: ["test", "-f", "/tmp/sish-client/ready"] }

Routes are only read at connect time. Roll the pods after changing them (e.g. with a config hash annotation).

Image tags

The Gitea workflow (.gitea/workflows/build.yaml) builds linux/amd64 and linux/arm64 and pushes:

Trigger Tags
Push to main <yyyymmddHHMMSS>-<sha7> (UTC) and latest
Git tag v* <yyyymmddHHMMSS>-<sha7> and the git tag, e.g. v1.0.0

Repository secrets required: REGISTRY_USERNAME, REGISTRY_TOKEN (a token with push access to registry.traberph.de/public). The runner needs Docker (Buildx + QEMU for arm64).

Flux image automation

apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata: { name: sish-client, namespace: flux-system }
spec:
  image: registry.traberph.de/public/sish-client
  interval: 10m
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata: { name: sish-client, namespace: flux-system }
spec:
  imageRepositoryRef: { name: sish-client }
  filterTags:
    pattern: '^(?P<ts>[0-9]{14})-[0-9a-f]{7}$'
    extract: '$ts'
  policy:
    numerical: { order: asc }

Build locally

docker build -t registry.traberph.de/public/sish-client:dev .