186 lines
6.2 KiB
Bash
186 lines
6.2 KiB
Bash
#!/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: <host>[:<bind-port>][=<target-host>:<target-port>]
|
|
# 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
|