189 lines
7.2 KiB
Markdown
189 lines
7.2 KiB
Markdown
# sish-client
|
|
|
|
A small, environment-driven connector for a [sish](https://github.com/antoniomika/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
|
|
|
|
```sh
|
|
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):
|
|
|
|
```sh
|
|
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:
|
|
|
|
```yaml
|
|
readinessProbe:
|
|
exec:
|
|
command: ["test", "-f", "/tmp/sish-client/ready"]
|
|
periodSeconds: 5
|
|
```
|
|
|
|
## Examples
|
|
|
|
### Docker Compose
|
|
|
|
```yaml
|
|
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)
|
|
|
|
```yaml
|
|
- 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
|
|
|
|
```yaml
|
|
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
|
|
|
|
```sh
|
|
docker build -t registry.traberph.de/public/sish-client:dev .
|
|
```
|