commit 32b060a502c3a7a9cc63fecfaa24f5491902f3ed Author: Philipp Traber Date: Sat Sep 26 19:42:11 2026 +0200 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..864179f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +* +!entrypoint.sh diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..700d8ad --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,54 @@ +name: build + +on: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: + +env: + IMAGE: registry.traberph.de/public/sish-client + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Tag scheme for Flux image automation: -, + # e.g. 20260926154233-1a2b3c4. Timestamps sort numerically, see README. + - name: Compute tags + id: tags + run: | + ts="$(date -u +%Y%m%d%H%M%S)" + sha="$(git rev-parse --short=7 HEAD)" + tags="${IMAGE}:${ts}-${sha}" + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + tags="${tags},${IMAGE}:${GITHUB_REF_NAME}" + else + tags="${tags},${IMAGE}:latest" + fi + echo "tags=${tags}" >> "$GITHUB_OUTPUT" + echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "Tags: ${tags}" + + - uses: docker/setup-qemu-action@v4 + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: registry.traberph.de + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.tags.outputs.tags }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ steps.tags.outputs.sha }} + org.opencontainers.image.created=${{ steps.tags.outputs.created }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3cc4f29 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1 +FROM docker.io/library/alpine:3.24.2 + +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 +USER 65534:65534 + +# -g: forward signals to the whole process group, so ssh stops cleanly too +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 new file mode 100644 index 0000000..8f557bd --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# 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, `" "` (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 + +``` +[:][=:] +``` + +| 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:"`. 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 . +``` diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..12e55fc --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,185 @@ +#!/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. +set -eu +set -f # routes may contain wildcards (*.example.com); never glob-expand them + +ESC=$(printf '\033') + +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; } + +# --- configuration ----------------------------------------------------------- + +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} + +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')" +done +case "$PROXY_PROTOCOL" in 1 | 2 | off | none | false | 0) ;; *) die "PROXY_PROTOCOL must be 1, 2 or off" ;; esac + +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" + +# --- 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 +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 +done