#!/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