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 is0. - or pass the key via
SISH_KEYinstead 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 .