#!/usr/bin/env bash # Copyright (c) 2025. LUXIM d.o.o., Slovenia - Matjaž Mozetič. # Licensed under the GNU GPL v3.0 # # backtunnel-share: Share a folder using reverse SSH for a limited duration # Syntax: # backtunnel-share /path/to/folder with remoteuser:remotehost for 2h [options] # # Options: # -p|--tunnel-port Remote port to expose with -R (default: 2222) # -l|--local-ssh-port Local sshd port to forward to (default: 22) # -i|--invite Print an invite command (and/or QR) for chat # --invite-mount Suggested mount point in invite (default: $HOME/remote-rssh) # --invite-file Also write invite text (with unmount hint) to FILE # --qr Also render the invite command as a QR code (qrencode) # --allow-key Temporarily authorize accessor's public key for this session # --allow-known Temporarily authorize ~/.config/backtunnel/authorized/NAME.pub # -h|--help Show help # # Profiles: ~/.config/backtunnel/profiles.ini (overrides /etc/backtunnel/profiles.ini then /usr/share/backtunnel/profiles.ini) # [default] tunnel_port=2222 local_ssh_port=22 invite_mount=$HOME/remote-rssh invite=true qr=false # [alice] user=alice host=vps.example.com tunnel_port=4422 set -euo pipefail # ---------------------------- # Config discovery # ---------------------------- CONFIG_USER="${XDG_CONFIG_HOME:-$HOME/.config}/backtunnel/profiles.ini" CONFIG_SYS="/etc/backtunnel/profiles.ini" CONFIG_PKG="/usr/share/backtunnel/profiles.ini" if [[ -f "$CONFIG_USER" ]]; then CONFIG_FILE="$CONFIG_USER" elif [[ -f "$CONFIG_SYS" ]]; then CONFIG_FILE="$CONFIG_SYS" else CONFIG_FILE="$CONFIG_PKG" fi # ---------------------------- # INI helpers # ---------------------------- # shellcheck disable=SC2317 ini_get() { # ini_get SECTION KEY -> value local sec="$1" key="$2" awk -v s="[""$sec""]" -v k="$key" ' $0==s {ok=1; next} /^\[/ {ok=0} ok && $0 ~ /^[[:alnum:]_.-]+[[:space:]]*=/ { line=$0 sub(/[[:space:]]*=[[:space:]]*/, "=", line) split(line,a,"=") if (a[1]==k) { val=substr(line, index(line,"=")+1) gsub(/^[[:space:]]+|[[:space:]]+$/,"",val) print val exit } }' "${CONFIG_FILE}" 2>/dev/null || true } # shellcheck disable=SC2317 profile_expand_remote() { # "@name" -> user@host, otherwise pass through local in="$1" if [[ "$in" == @* ]]; then local name="${in#@}" user host user="$(ini_get "$name" user)" host="$(ini_get "$name" host)" if [[ -n "$user" && -n "$host" ]]; then printf '%s@%s\n' "$user" "$host" return 0 fi fi printf '%s\n' "$in" } # shellcheck disable=SC2317 profile_apply_defaults() { # set globals if unset; named overrides default local name="$1" v # defaults v="$(ini_get default tunnel_port)"; [[ -n "$v" && "${TUNNEL_PORT}" == "2222" ]] && TUNNEL_PORT="$v" v="$(ini_get default local_ssh_port)"; [[ -n "$v" && "${LOCAL_SSH_PORT}" == "22" ]] && LOCAL_SSH_PORT="$v" v="$(ini_get default invite_mount)"; [[ -n "$v" && "${INVITE_MOUNT}" == "$HOME/remote-rssh" ]] && INVITE_MOUNT="$v" v="$(ini_get default invite)"; [[ "${v,,}" == "true" ]] && INVITE=true v="$(ini_get default qr)"; [[ "${v,,}" == "true" ]] && QR=true if [[ -z "$DURATION" ]]; then v="$(ini_get default duration)"; [[ -n "$v" ]] && DURATION="$v" fi # named section if [[ -n "$name" ]]; then v="$(ini_get "$name" tunnel_port)"; [[ -n "$v" ]] && TUNNEL_PORT="$v" v="$(ini_get "$name" local_ssh_port)"; [[ -n "$v" ]] && LOCAL_SSH_PORT="$v" v="$(ini_get "$name" invite_mount)"; [[ -n "$v" ]] && INVITE_MOUNT="$v" fi } # ---------------------------- # Defaults # ---------------------------- TUNNEL_PORT=2222 # remote-side port exposed via -R LOCAL_SSH_PORT=22 # local sshd port to forward to DURATION="" # required: e.g. 30m, 2h, 1d INVITE=false # print a ready-to-copy access command INVITE_MOUNT="$HOME/remote-rssh" INVITE_FILE="" QR=false # render invite as terminal QR (requires qrencode) # Accessor authorization (session-scoped) ALLOW_KEY_FILE="" ALLOW_KNOWN_NAME="" AUTHORIZED_STORE="${XDG_CONFIG_HOME:-$HOME/.config}/backtunnel/authorized" ADDED_MARKER_ID="" # unique ID for cleanup removal usage() { cat >&2 < [options] Positional (required, in order): /path/to/folder Informational only (the actual folder is mounted by backtunnel-access) with Literal keyword remoteuser:remotehost Or remoteuser@remotehost (or @profile) for Literal keyword e.g. 30m, 2h, 1d (passed to 'timeout') Options: -p, --tunnel-port N Remote tunnel port (default: ${TUNNEL_PORT}) -l, --local-ssh-port N Local sshd port to expose (default: ${LOCAL_SSH_PORT}) -i, --invite Print a ready-to-copy access command for the remote side --invite-mount PATH Mount point suggested in invite (default: ${INVITE_MOUNT}) --invite-file FILE Also write the invite text (with unmount hint) to FILE --qr Also print a QR code (requires 'qrencode') --allow-key FILE Authorize this public key for this session (restricted SFTP-only, tunnel-only) --allow-known NAME Authorize ~/.config/backtunnel/authorized/NAME.pub -h, --help Show this help Examples: $(basename "$0") ~/projects with alice:vps.example.com for 2h $(basename "$0") ~/projects with alice@vps.example.com for 1d -p 4422 -l 2222 $(basename "$0") ~/projects with @work for 2h -i --qr --allow-known alice EOF exit 1 } # ---------------------------- # Positional parsing # ---------------------------- [[ $# -lt 5 ]] && usage FOLDER=$1 KW1=$2 REMOTE=$3 KW2=$4 DURATION=$5 shift 5 || true [[ "$KW1" != "with" ]] && usage [[ "$KW2" != "for" ]] && usage # Apply [default] + named profile (if REMOTE is @name) before flag overrides profile_name="" if [[ "$REMOTE" == @* ]]; then profile_name="${REMOTE#@}" fi profile_apply_defaults "$profile_name" # Expand @name -> user@host for actual ssh use REMOTE="$(profile_expand_remote "$REMOTE")" # ---------------------------- # Optional flags # ---------------------------- while [[ $# -gt 0 ]]; do case "$1" in -p|--tunnel-port) [[ $# -lt 2 ]] && usage TUNNEL_PORT=$2 shift 2 ;; -l|--local-ssh-port) [[ $# -lt 2 ]] && usage LOCAL_SSH_PORT=$2 shift 2 ;; -i|--invite) INVITE=true shift ;; --invite-mount) [[ $# -lt 2 ]] && usage INVITE_MOUNT=$2 shift 2 ;; --invite-file) [[ $# -lt 2 ]] && usage INVITE_FILE=$2 shift 2 ;; --qr) QR=true shift ;; --allow-key) [[ $# -lt 2 ]] && usage ALLOW_KEY_FILE=$2 shift 2 ;; --allow-known) [[ $# -lt 2 ]] && usage ALLOW_KNOWN_NAME=$2 shift 2 ;; -h|--help) usage ;; *) echo "Unknown option: $1" >&2 usage ;; esac done # ---------------------------- # Duration validation # ---------------------------- if [[ ! "$DURATION" =~ ^[0-9]+[smhd]$ ]]; then echo "Invalid duration '$DURATION' (use forms like 30m, 2h, 1d)." >&2 exit 1 fi # ---------------------------- # Split remote user/host # ---------------------------- REMOTE_USER="" REMOTE_HOST="" if [[ "$REMOTE" == *:* ]]; then REMOTE_USER=${REMOTE%%:*} REMOTE_HOST=${REMOTE#*:} elif [[ "$REMOTE" == *"@"* ]]; then REMOTE_USER=${REMOTE%%@*} REMOTE_HOST=${REMOTE#*@} else echo "Invalid remote format. Use remoteuser:remotehost or remoteuser@remotehost or @profile" >&2 exit 1 fi # ---------------------------- # Deps check # ---------------------------- command -v ssh >/dev/null 2>&1 || { echo "ssh not found."; exit 1; } command -v timeout >/dev/null 2>&1 || { echo "timeout not found."; exit 1; } command -v tail >/dev/null 2>&1 || { echo "tail not found."; exit 1; } # ---------------------------- # Accessor temporary authorization # ---------------------------- restrict_key_line() { # usage: restrict_key_line local pk="$1" # Normalize pk="${pk//$'\r'/}" pk="${pk//$'\n'/}" if [[ "$pk" != ssh-* ]]; then echo "Provided key does not look like an OpenSSH public key." >&2 return 1 fi printf 'from="127.0.0.1",command="internal-sftp",restrict %s' "$pk" } add_temp_authorized_key() { local pubkey_text="$1" local ak="$HOME/.ssh/authorized_keys" # SC2155: Declare and assign separately to avoid masking return values. # shellcheck disable=SC2155 local marker marker="BACKTUNNEL-TEMP-$(date +%s)-$$-$RANDOM" local start="# ${marker} START" local end="# ${marker} END" mkdir -p "$HOME/.ssh" touch "$ak" chmod 700 "$HOME/.ssh" chmod 600 "$ak" local restricted restricted="$(restrict_key_line "$pubkey_text")" || return 1 { echo "$start" echo "$restricted" echo "$end" } >> "$ak" ADDED_MARKER_ID="$marker" } # shellcheck disable=SC2317 remove_temp_authorized_key() { local ak="$HOME/.ssh/authorized_keys" [[ -z "${ADDED_MARKER_ID:-}" ]] && return 0 [[ -f "$ak" ]] || return 0 awk -v m="$ADDED_MARKER_ID" ' $0 ~ "# " m " START" {skip=1; next} $0 ~ "# " m " END" {skip=0; next} !skip {print} ' "$ak" > "${ak}.btnew" && mv "${ak}.btnew" "$ak" } ACCESSOR_PUBKEY_TEXT="" if [[ -n "$ALLOW_KNOWN_NAME" ]]; then f="${AUTHORIZED_STORE}/${ALLOW_KNOWN_NAME}.pub" if [[ -f "$f" ]]; then ACCESSOR_PUBKEY_TEXT="$(cat "$f")" else echo "Known accessor key not found: $f" >&2 exit 1 fi fi if [[ -n "$ALLOW_KEY_FILE" ]]; then if [[ -f "$ALLOW_KEY_FILE" ]]; then ACCESSOR_PUBKEY_TEXT="$(cat "$ALLOW_KEY_FILE")" else echo "Given key file not found: $ALLOW_KEY_FILE" >&2 exit 1 fi fi if [[ -n "$ACCESSOR_PUBKEY_TEXT" ]]; then add_temp_authorized_key "$ACCESSOR_PUBKEY_TEXT" fi # ---------------------------- # Banner # ---------------------------- echo "⏳ Sharing '${FOLDER}' via reverse SSH:" echo " local sshd port : ${LOCAL_SSH_PORT}" echo " remote bind port : ${TUNNEL_PORT} (on ${REMOTE_HOST})" echo " remote user : ${REMOTE_USER}" echo " duration : ${DURATION}" if [[ -n "$ACCESSOR_PUBKEY_TEXT" ]]; then echo " accessor key : temporarily authorized (restricted)" fi echo # ---------------------------- # Invite (optional) # If accessor key was pre-authorized, we can omit the auth-setup line. # ---------------------------- if $INVITE; then INVITE_CMD="backtunnel-access '${FOLDER}' from ${REMOTE_USER}@${REMOTE_HOST} -p ${TUNNEL_PORT} -m '${INVITE_MOUNT}'" if [[ -z "$ACCESSOR_PUBKEY_TEXT" ]]; then AUTH_CMD="backtunnel-auth-setup -p ${TUNNEL_PORT} ${REMOTE_USER}@localhost" INVITE_TEXT=$( cat < "${INVITE_FILE}" echo "Saved invite to: ${INVITE_FILE}" fi if $QR; then if command -v qrencode >/dev/null 2>&1; then echo echo "📱 QR (scan to copy the command):" printf "%s" "${INVITE_CMD}" | qrencode -t ansiutf8 else echo "⚠️ 'qrencode' not installed; skipping QR." fi fi echo fi echo "Tip: On the remote side, mount with:" echo " backtunnel-access '${FOLDER}' from ${REMOTE_USER}@${REMOTE_HOST} -p ${TUNNEL_PORT}" echo "Listener will appear on: ${REMOTE_HOST}:${TUNNEL_PORT} (run access there)." echo "To stop sharing early: press Ctrl+C in this window." # ---------------------------- # Pre-flight: warn if remote loopback port already in use (best-effort) # ---------------------------- if ssh -o BatchMode=yes -o ConnectTimeout=5 "${REMOTE_USER}@${REMOTE_HOST}" \ "command -v nc >/dev/null 2>&1 && nc -z 127.0.0.1 ${TUNNEL_PORT}"; then echo "⚠️ Port ${TUNNEL_PORT} on remote 127.0.0.1 appears in use; choose another with -p." >&2 # You may 'exit 1' here if you prefer a hard failure fi # ---------------------------- # Cleanup & run SSH # ---------------------------- SSH_PID="" # shellcheck disable=SC2317 cleanup() { # stop ssh child if running if [[ -n "${SSH_PID:-}" ]] && kill -0 "$SSH_PID" 2>/dev/null; then echo "⏹️ Stopping share..." kill -TERM "$SSH_PID" 2>/dev/null || true wait "$SSH_PID" 2>/dev/null || true fi # remove temporary authorized key if we added one remove_temp_authorized_key } trap cleanup INT TERM EXIT # Start reverse-tunnel ssh in background with keepalives + fast-fail on -R bind ssh -N \ -o ExitOnForwardFailure=yes \ -o ServerAliveInterval=15 \ -o ServerAliveCountMax=3 \ -R "${TUNNEL_PORT}:localhost:${LOCAL_SSH_PORT}" \ -- "${REMOTE_USER}@${REMOTE_HOST}" & SSH_PID=$! # Wait for ssh to exit, but bounded by duration: # Use 'timeout … tail --pid=PID -f /dev/null' so we don't need bash -c 'wait "$1"' if timeout "$DURATION" tail --pid="$SSH_PID" -f /dev/null; then # ssh exited on its own before timeout exit 0 else rc=$? if [[ $rc -eq 124 ]]; then echo "⏹️ Sharing ended: reached duration (${DURATION})." # ensure the child is gone if kill -0 "$SSH_PID" 2>/dev/null; then kill -TERM "$SSH_PID" 2>/dev/null || true wait "$SSH_PID" 2>/dev/null || true fi exit 0 fi # other error from timeout/tail exit "$rc" fi