diff --git a/Dockerfile b/Dockerfile index 3cc4f29..4c28957 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,7 @@ -# syntax=docker/dockerfile:1 -FROM docker.io/library/alpine:3.24.2 - +FROM docker.io/library/alpine:3 RUN apk add --no-cache openssh-client tini - COPY --chmod=0755 entrypoint.sh /usr/local/bin/sish-client - -# nobody; HOME and state live in /tmp so the root filesystem can be read-only -ENV HOME=/tmp \ - STATE_DIR=/tmp/sish-client +# nobody: ssh needs a uid that exists in /etc/passwd, so keep runAsUser at 65534 USER 65534:65534 - -# -g: forward signals to the whole process group, so ssh stops cleanly too +# tini reaps zombies and forwards SIGTERM to the whole process group (-g), incl. ssh ENTRYPOINT ["/sbin/tini", "-g", "--", "/usr/local/bin/sish-client"] - -HEALTHCHECK --interval=15s --timeout=3s --start-period=30s --retries=2 \ - CMD ["/bin/sh", "-c", "test -f \"$STATE_DIR/ready\""] - -LABEL org.opencontainers.image.title="sish-client" \ - org.opencontainers.image.description="Environment-driven sish connector (SSH reverse tunnels with SNI passthrough)" diff --git a/README.md b/README.md index 8f557bd..54e35bd 100644 --- a/README.md +++ b/README.md @@ -1,188 +1,47 @@ # 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 +Opens SSH reverse tunnels to a [sish](https://github.com/antoniomika/sish) edge and reconnects +every 5s when the session drops. Image: `registry.traberph.de/public/sish-client`. ```sh -docker run -d --name sish-client --read-only --tmpfs /tmp \ +docker run -d --read-only --tmpfs /tmp \ -e SISH_HOST=edge.example.com \ -e SISH_HOST_KEY="ssh-ed25519 AAAA..." \ - -e ROUTES="app.example.com=app:8443" \ + -e SISH_ROUTES="app.example.com:443=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 | +| Variable | Default | | |---|---|---| -| `SISH_HOST` | *required* | Edge hostname or IP | +| `SISH_HOST` | *required* | Edge host | +| `SISH_HOST_KEY` | *required* | Edge host key, `" "` (first two fields of its `.pub` file) | +| `SISH_ROUTES` | *required* | Comma-separated `host:bind-port=target:port` | | `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, `" "` (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 | +| `SISH_KEY_FILE` | `/secrets/id_ed25519` | Private key | +| `SISH_SNI_PROXY` | `true` | `true`: sish routes by SNI and passes TLS through. `false`: HTTP routing by `Host` header | +| `SISH_PROXY_PROTOCOL` | `2` | PROXY header sent to the target: `1`, `2` or `off` | -### Routes +HTTP (:80) routes need `SISH_SNI_PROXY=false` and a separate instance from SNI (:443) routes. +If sish rejects any route, the session fails and is retried, so a bad route shows up in the logs. -``` -[:][=:] -``` +## Requirements -| 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) | +- `/tmp` must be writable (e.g. a memory `emptyDir`), the root filesystem can be read-only. +- Run as uid `65534` (the image default). ssh refuses to start for uids missing from `/etc/passwd`. +- The key file must be readable by uid 65534, e.g. a Secret volume with `defaultMode: 0440` + and `fsGroup: 65534`. ssh rejects keys owned by 65534 that others can read. -**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): +## Security -```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:"`. 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` | `-` (UTC) and `latest` | -| Git tag `v*` | `-` 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[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 . -``` +- The edge is authenticated only by `SISH_HOST_KEY`. There is no trust-on-first-use and no + fallback: a wrong or missing key fails the connection. +- All ssh_config files are ignored (`-F /dev/null`). Agent and X11 forwarding are off, so the + edge can only open connections to the configured route targets. +- **PROXY protocol:** the target trusts the PROXY header for the client IP. It must only accept + PROXY connections from this client (e.g. listen on `127.0.0.1` in the sidecar and set trusted + IPs), otherwise anything that can reach that port can spoof client IPs. If the target does not + expect a PROXY header, set `SISH_PROXY_PROTOCOL=off`. +- Whoever controls the edge sees HTTP traffic, and with `SISH_SNI_PROXY=true` still sees the + SNI hostnames and client IPs, but not TLS contents. +- The base image follows `alpine:3`. Rebuild regularly to pick up OpenSSH fixes. diff --git a/entrypoint.sh b/entrypoint.sh index 12e55fc..3d7368a 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,185 +1,51 @@ #!/bin/sh -# sish-client: environment-driven connector for a sish edge. -# Claims hostnames on the edge via SSH reverse forwards and relays them to local -# targets. Reconnects forever with exponential backoff. See README.md. +# Opens SSH reverse tunnels to a sish edge and reconnects forever. See README.md. set -eu -set -f # routes may contain wildcards (*.example.com); never glob-expand them +set -f # no globbing: routes may contain wildcards (*.example.com) -ESC=$(printf '\033') +die() { echo "sish-client: $*" >&2; exit 64; } -log() { printf '%s sish-client: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; } -die() { log "error: $*"; exit 64; } -is_true() { - case "$(printf '%s' "$1" | tr 'A-Z' 'a-z')" in - 1 | true | yes | on) return 0 ;; - *) return 1 ;; - esac -} -is_uint() { case "$1" in '' | *[!0-9]*) return 1 ;; *) return 0 ;; esac; } +: "${SISH_HOST:?is required}" "${SISH_HOST_KEY:?is required}" "${SISH_ROUTES:?is required}" +SNI_PROXY=${SISH_SNI_PROXY:-true} +PROXY_PROTOCOL=${SISH_PROXY_PROTOCOL:-2} +KEY=${SISH_KEY_FILE:-/secrets/id_ed25519} -# --- configuration ----------------------------------------------------------- +case $SNI_PROXY in true | false) ;; *) die "SISH_SNI_PROXY must be true or false" ;; esac +case $PROXY_PROTOCOL in 1 | 2 | off) ;; *) die "SISH_PROXY_PROTOCOL must be 1, 2 or off" ;; esac +[ -r "$KEY" ] || die "cannot read private key $KEY" -SISH_HOST=${SISH_HOST:-} -SISH_PORT=${SISH_PORT:-2222} -SISH_USER=${SISH_USER:-connector} -SISH_KEY=${SISH_KEY:-} -SISH_KEY_FILE=${SISH_KEY_FILE:-/secrets/id_ed25519} -SISH_HOST_KEY=${SISH_HOST_KEY:-} -SISH_KNOWN_HOSTS_FILE=${SISH_KNOWN_HOSTS_FILE:-} -SISH_STRICT_HOST_KEY=${SISH_STRICT_HOST_KEY:-true} +# Pin the edge host key: the only entry ssh will trust. A newline would allow +# extra known_hosts lines (e.g. a wildcard entry), so the key must be one line. +case $SISH_HOST_KEY in *' +'*) die "SISH_HOST_KEY must be a single line" ;; esac +KNOWN_HOSTS=$(mktemp) # private 0600 file with a random name, safe in a shared /tmp +printf 'sish-edge %s\n' "$SISH_HOST_KEY" > "$KNOWN_HOSTS" -ROUTES=${ROUTES:-} -ROUTES_FILE=${ROUTES_FILE:-} -TARGET=${TARGET:-} -DEFAULT_BIND_PORT=${DEFAULT_BIND_PORT:-443} - -SNI_PROXY=${SNI_PROXY:-true} -PROXY_PROTOCOL=${PROXY_PROTOCOL:-2} - -KEEPALIVE_INTERVAL=${KEEPALIVE_INTERVAL:-15} -CONNECT_TIMEOUT=${CONNECT_TIMEOUT:-10} -BACKOFF_MIN=${BACKOFF_MIN:-2} -BACKOFF_MAX=${BACKOFF_MAX:-60} -SSH_EXTRA_OPTS=${SSH_EXTRA_OPTS:-} -STATE_DIR=${STATE_DIR:-/tmp/sish-client} - -[ -n "$SISH_HOST" ] || die "SISH_HOST is required" -for v in SISH_PORT DEFAULT_BIND_PORT KEEPALIVE_INTERVAL CONNECT_TIMEOUT BACKOFF_MIN BACKOFF_MAX; do - eval "val=\$$v" - is_uint "$val" || die "$v must be a non-negative integer (got '$val')" +# host:bind-port=target:port -> -R host:bind-port:target:port +# Each route stays a single argument to -R, so it cannot inject ssh options. +forwards="" +for r in $(printf '%s' "$SISH_ROUTES" | tr , ' '); do + case $r in *=*) ;; *) die "bad route '$r', expected host:port=target:port" ;; esac + forwards="$forwards -R ${r%%=*}:${r#*=}" done -case "$PROXY_PROTOCOL" in 1 | 2 | off | none | false | 0) ;; *) die "PROXY_PROTOCOL must be 1, 2 or off" ;; esac +[ -n "$forwards" ] || die "SISH_ROUTES contains no routes" -umask 077 -mkdir -p "$STATE_DIR" || die "STATE_DIR $STATE_DIR is not writable (mount a tmpfs on /tmp with a read-only root fs)" -READY_FILE=$STATE_DIR/ready -FIFO=$STATE_DIR/ssh.out -KEY=$STATE_DIR/id_key -KNOWN_HOSTS=$STATE_DIR/known_hosts -rm -f "$READY_FILE" +# sish session options, sent as the remote command +opts="" +if [ "$SNI_PROXY" = true ]; then opts="sni-proxy=true"; fi +if [ "$PROXY_PROTOCOL" != off ]; then opts="$opts proxy-protocol=$PROXY_PROTOCOL"; fi -# --- private key: always copied so ssh sees a private 0600 file we own ------- - -if [ -n "$SISH_KEY" ]; then - printf '%s\n' "$SISH_KEY" > "$KEY" -elif [ -r "$SISH_KEY_FILE" ]; then - cat "$SISH_KEY_FILE" > "$KEY" -else - die "no private key: set SISH_KEY or mount a readable key at SISH_KEY_FILE ($SISH_KEY_FILE)" -fi -chmod 600 "$KEY" - -# --- host key verification --------------------------------------------------- - -HOST_KEY_OPTS="" -if [ -n "$SISH_HOST_KEY" ]; then - printf 'sish-edge %s\n' "$SISH_HOST_KEY" > "$KNOWN_HOSTS" - HOST_KEY_OPTS="-o HostKeyAlias=sish-edge -o UserKnownHostsFile=$KNOWN_HOSTS -o StrictHostKeyChecking=yes" -elif [ -n "$SISH_KNOWN_HOSTS_FILE" ]; then - [ -r "$SISH_KNOWN_HOSTS_FILE" ] || die "SISH_KNOWN_HOSTS_FILE $SISH_KNOWN_HOSTS_FILE is not readable" - HOST_KEY_OPTS="-o UserKnownHostsFile=$SISH_KNOWN_HOSTS_FILE -o StrictHostKeyChecking=yes" -elif is_true "$SISH_STRICT_HOST_KEY"; then - die "no host key: set SISH_HOST_KEY (e.g. 'ssh-ed25519 AAAA...') or SISH_KNOWN_HOSTS_FILE" -else - log "WARNING: host key verification disabled (SISH_STRICT_HOST_KEY=false)" - HOST_KEY_OPTS="-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" -fi - -# --- routes ------------------------------------------------------------------ -# Entry: [:][=:] -# Without "=target", TARGET is used. Bind port defaults to DEFAULT_BIND_PORT. - -route_entries() { - { - printf '%s\n' "$ROUTES" | tr ', ' '\n\n' - if [ -n "$ROUTES_FILE" ]; then - [ -r "$ROUTES_FILE" ] || die "ROUTES_FILE $ROUTES_FILE is not readable" - sed 's/#.*//' "$ROUTES_FILE" | tr ', \t' '\n\n\n' - fi - } | sed '/^[[:space:]]*$/d' -} - -FORWARDS="" -ROUTE_COUNT=0 -for entry in $(route_entries); do - case "$entry" in - *=*) bind=${entry%%=*} target=${entry#*=} ;; - *) bind=$entry target=$TARGET ;; - esac - [ -n "$target" ] || die "route '$entry' has no target and TARGET is not set" - case "$target" in *:*) ;; *) die "target '$target' of route '$entry' must be host:port" ;; esac - case "$bind" in *:*) ;; *) bind="$bind:$DEFAULT_BIND_PORT" ;; esac - FORWARDS="$FORWARDS -R $bind:$target" - ROUTE_COUNT=$((ROUTE_COUNT + 1)) - log "route: $bind -> $target" -done -[ "$ROUTE_COUNT" -gt 0 ] || die "no routes: set ROUTES and/or ROUTES_FILE" - -# --- sish session options (sent as the remote "command") --------------------- - -REMOTE_CMD="" -is_true "$SNI_PROXY" && REMOTE_CMD="$REMOTE_CMD sni-proxy=true" -case "$PROXY_PROTOCOL" in 1 | 2) REMOTE_CMD="$REMOTE_CMD proxy-protocol=$PROXY_PROTOCOL" ;; esac - -SSH_OPTS="-T -p $SISH_PORT -i $KEY - -o IdentitiesOnly=yes -o BatchMode=yes -o LogLevel=ERROR - -o ExitOnForwardFailure=yes - -o ConnectTimeout=$CONNECT_TIMEOUT - -o ServerAliveInterval=$KEEPALIVE_INTERVAL -o ServerAliveCountMax=3 - $HOST_KEY_OPTS $SSH_EXTRA_OPTS" - -# --- run loop ---------------------------------------------------------------- - -SSH_PID="" -shutdown() { - log "received signal, shutting down" - rm -f "$READY_FILE" - [ -n "$SSH_PID" ] && kill "$SSH_PID" 2>/dev/null - exit 0 -} -trap shutdown TERM INT - -# Runs one ssh session. Marks ready once sish confirmed every forward. -WAS_READY=0 -run_once() { - WAS_READY=0 - rm -f "$READY_FILE" "$FIFO" - mkfifo "$FIFO" - # shellcheck disable=SC2086 # word splitting of option strings is intended - ssh $SSH_OPTS $FORWARDS "$SISH_USER@$SISH_HOST" $REMOTE_CMD > "$FIFO" 2>&1 & - SSH_PID=$! - confirmed=0 - while IFS= read -r line; do - line=$(printf '%s' "$line" | sed "s/${ESC}\[[0-9;]*m//g; s/\r\$//") - [ -n "$line" ] || continue - log "edge: $line" - case "$line" in - *"Starting SSH Forwarding service"*) - confirmed=$((confirmed + 1)) - if [ "$confirmed" -ge "$ROUTE_COUNT" ] && [ "$WAS_READY" -eq 0 ]; then - WAS_READY=1 - : > "$READY_FILE" - log "ready: $confirmed/$ROUTE_COUNT forward(s) active" - fi - ;; - esac - done < "$FIFO" - rc=0 - wait "$SSH_PID" || rc=$? - SSH_PID="" - rm -f "$READY_FILE" "$FIFO" - return "$rc" -} - -delay=$BACKOFF_MIN +# -F /dev/null: ignore all ssh_config files, every option is set here. +# The server can only open channels for the forwards requested above; agent and +# X11 forwarding are off by default. while :; do - log "connecting to $SISH_USER@$SISH_HOST:$SISH_PORT ($ROUTE_COUNT route(s))" - rc=0 - run_once || rc=$? - [ "$WAS_READY" -eq 1 ] && delay=$BACKOFF_MIN - log "tunnel closed (exit $rc), reconnecting in ${delay}s" - sleep "$delay" & - wait $! || true - delay=$((delay * 2)) - [ "$delay" -le "$BACKOFF_MAX" ] || delay=$BACKOFF_MAX + # shellcheck disable=SC2086 # $forwards and $opts are split on purpose + ssh -F /dev/null -T -p "${SISH_PORT:-2222}" -i "$KEY" \ + -o BatchMode=yes -o IdentitiesOnly=yes -o ExitOnForwardFailure=yes \ + -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \ + -o HostKeyAlias=sish-edge -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$KNOWN_HOSTS" -o GlobalKnownHostsFile=/dev/null \ + $forwards "connector@$SISH_HOST" $opts \ + || echo "sish-client: ssh exited ($?), reconnecting in 5s" >&2 + sleep 5 done