#!/usr/bin/env bash # larry-tunnel — open a reverse SSH tunnel from a remote shell back to # a public hop, so Bryan's home Larry can SSH "in" to this machine. # # Two modes: # 1. Bryan-controlled hop (recommended once set up) # Requires: an SSH-reachable host you control (bjnoela.com or a VPS) # with `GatewayPorts clientspecified` (or yes) in sshd_config, plus # either your SSH pubkey installed there or password auth allowed. # # 2. serveo.net fallback (zero-config, no account, no install) # Just works. Less private. Useful when the primary hop is offline # or not yet configured. # # Usage: # larry-tunnel.sh # uses env vars or prompts # larry-tunnel.sh --serveo # force serveo.net mode # larry-tunnel.sh --hop user@host:port # force custom-hop mode # # Env vars (set in $LARRY_HOME/.env or shell): # LARRY_HOP_HOST e.g. bjnoela.com # LARRY_HOP_USER e.g. larry-tunnel (a low-priv user on the hop) # LARRY_HOP_PORT SSH port on the hop (default 22) # LARRY_HOP_BIND remote-side bind port (default 0 = auto) # LARRY_LOCAL_PORT local SSH port to expose (default 22) # LARRY_HOP_KEY path to private key for the hop (default ~/.ssh/id_rsa or id_ed25519) # # Files: # $LARRY_HOME/tunnel.pid PID of the active tunnel ssh process # $LARRY_HOME/tunnel.url the public host:port to dial from home # $LARRY_HOME/tunnel.log ssh stderr/log set -u set -o pipefail LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" mkdir -p "$LARRY_HOME" [ -f "$LARRY_HOME/.env" ] && { set -a; . "$LARRY_HOME/.env"; set +a; } LARRY_HOP_HOST="${LARRY_HOP_HOST:-}" LARRY_HOP_USER="${LARRY_HOP_USER:-}" LARRY_HOP_PORT="${LARRY_HOP_PORT:-22}" LARRY_HOP_BIND="${LARRY_HOP_BIND:-0}" LARRY_LOCAL_PORT="${LARRY_LOCAL_PORT:-22}" LARRY_HOP_KEY="${LARRY_HOP_KEY:-}" C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_CYAN=$'\033[36m' say() { printf '%s%slarry-tunnel>%s %s\n' "$C_CYAN" "$C_BOLD" "$C_RESET" "$*"; } err() { printf '%serror:%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; } warn() { printf '%swarn:%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; } MODE="" for arg in "$@"; do case "$arg" in --serveo) MODE="serveo" ;; --hop) MODE="hop" ;; --hop=*) MODE="hop"; spec="${arg#--hop=}" LARRY_HOP_USER="${spec%@*}" rest="${spec#*@}" LARRY_HOP_HOST="${rest%:*}" [ "$rest" != "${rest%:*}" ] && LARRY_HOP_PORT="${rest##*:}" ;; --status) if [ -f "$LARRY_HOME/tunnel.pid" ] && kill -0 "$(cat "$LARRY_HOME/tunnel.pid")" 2>/dev/null; then say "running (PID $(cat "$LARRY_HOME/tunnel.pid"))" [ -f "$LARRY_HOME/tunnel.url" ] && say "public: $(cat "$LARRY_HOME/tunnel.url")" exit 0 else say "not running" exit 1 fi ;; --stop) if [ -f "$LARRY_HOME/tunnel.pid" ]; then kill "$(cat "$LARRY_HOME/tunnel.pid")" 2>/dev/null && say "stopped" rm -f "$LARRY_HOME/tunnel.pid" "$LARRY_HOME/tunnel.url" fi exit 0 ;; -h|--help) sed -n '2,32p' "$0"; exit 0 ;; *) err "unknown arg: $arg"; exit 2 ;; esac done # Pick mode if not forced if [ -z "$MODE" ]; then if [ -n "$LARRY_HOP_HOST" ] && [ -n "$LARRY_HOP_USER" ]; then MODE="hop" else MODE="serveo" fi fi # Identify ssh key flag (only if set and exists) KEY_FLAG="" if [ -n "$LARRY_HOP_KEY" ] && [ -f "$LARRY_HOP_KEY" ]; then KEY_FLAG="-i $LARRY_HOP_KEY" fi # Common ssh options for reverse tunnel SSH_OPTS=( -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$LARRY_HOME/known_hosts" ) run_hop() { if [ -z "$LARRY_HOP_HOST" ] || [ -z "$LARRY_HOP_USER" ]; then err "hop mode needs LARRY_HOP_HOST and LARRY_HOP_USER (or --hop=user@host)" exit 2 fi say "opening reverse tunnel: ${LARRY_HOP_USER}@${LARRY_HOP_HOST}:${LARRY_HOP_PORT}" say " binds remote port ${LARRY_HOP_BIND} -> localhost:${LARRY_LOCAL_PORT}" while true; do : > "$LARRY_HOME/tunnel.log" # shellcheck disable=SC2086 ssh $KEY_FLAG \ "${SSH_OPTS[@]}" \ -p "$LARRY_HOP_PORT" \ -R "${LARRY_HOP_BIND}:localhost:${LARRY_LOCAL_PORT}" \ "${LARRY_HOP_USER}@${LARRY_HOP_HOST}" \ 2> "$LARRY_HOME/tunnel.log" & local pid=$! echo "$pid" > "$LARRY_HOME/tunnel.pid" printf '%s:%s\n' "$LARRY_HOP_HOST" "$LARRY_HOP_BIND" > "$LARRY_HOME/tunnel.url" say "ssh PID $pid — dial from home: ssh -p ${LARRY_HOP_BIND} @${LARRY_HOP_HOST}" wait "$pid" rc=$? warn "tunnel exited (rc=$rc) — reconnecting in 5s" sleep 5 done } run_serveo() { say "opening serveo.net reverse tunnel (no account needed)" say " binds an auto-assigned port -> localhost:${LARRY_LOCAL_PORT}" say " serveo.net is third-party; do NOT use for sensitive sessions" while true; do : > "$LARRY_HOME/tunnel.log" # serveo prints assignment on stderr/stdout — we capture both ssh \ "${SSH_OPTS[@]}" \ -R "0:localhost:${LARRY_LOCAL_PORT}" \ serveo.net \ > "$LARRY_HOME/tunnel.log" 2>&1 & local pid=$! echo "$pid" > "$LARRY_HOME/tunnel.pid" # Try to extract the forwarded URL local attempts=0 url="" while [ "$attempts" -lt 30 ] && [ -z "$url" ]; do sleep 1 attempts=$((attempts + 1)) url=$(grep -Eo 'Forwarding tcp from [^ ]+|tcp://[^ ]+|serveo\.net:[0-9]+' \ "$LARRY_HOME/tunnel.log" 2>/dev/null | head -1) done if [ -n "$url" ]; then echo "$url" > "$LARRY_HOME/tunnel.url" say "public: $url" say "dial from home: ssh -p @serveo.net (PORT from URL above)" else warn "could not read serveo.net assignment — check $LARRY_HOME/tunnel.log" fi wait "$pid" rc=$? warn "tunnel exited (rc=$rc) — reconnecting in 5s" sleep 5 done } trap 'kill $(cat "$LARRY_HOME/tunnel.pid" 2>/dev/null) 2>/dev/null; rm -f "$LARRY_HOME/tunnel.pid" "$LARRY_HOME/tunnel.url"; exit 0' INT TERM case "$MODE" in hop) run_hop ;; serveo) run_serveo ;; *) err "unknown mode: $MODE"; exit 2 ;; esac