#!/usr/bin/env bash # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (c) 2025 LUXIM d.o.o., Slovenia # Author: Matjaž Mozetič # # Name: backtunnel-share # Summary: Time-bounded reverse-SSH tunnel to expose the local SSH service for remote, temporary SFTP-based access. # Description: # Sets up a reverse SSH tunnel (ssh -R) from a remote host back to the local sshd, for a fixed duration. # Prints an optional “invite” command that the remote side can run to mount a given local folder over SFTP # using a companion tool. Optionally and temporarily adds a restricted public key entry to authorized_keys, # scoped to localhost and internal-sftp only, and removes it on exit. # # Usage: # backtunnel-share /path/to/folder with remoteuser:remotehost for [options] # # Examples: # backtunnel-share ~/projects with alice:vps.example.com for 2h # backtunnel-share ~/projects with alice@vps.example.com for 1d -p 4422 -l 2222 # backtunnel-share ~/projects with @work for 2h -i --qr --allow-known alice # # Dependencies: # - bash >= 4.x # - ssh, timeout, tail # - optional: qrencode (for --qr) # # Configuration: # Profiles file precedence (high → low): # - ${XDG_CONFIG_HOME:-$HOME/.config}/backtunnel/profiles.ini # - /etc/backtunnel/profiles.ini # - /usr/share/backtunnel/profiles.ini # Sections: # - [default] global defaults # - [name] referenced via @name → expands to user@host and overrides select defaults # # Exit codes: # 0 success (including duration reached) # 1 invalid usage/arguments or validation failure # 124 timeout reached (handled and normalized to 0 when expected) # 2+ other runtime errors (from commands like ssh/timeout/tail) # # Security: # - Reverse tunnel binds on remote loopback only (127.0.0.1:PORT). # - Temporary authorized_keys entries (if enabled) are restricted with: # from="127.0.0.1",command="internal-sftp",restrict # and are removed on exit via trap. # - ~/.ssh perms are enforced (700 dir, 600 authorized_keys). # # Portability/Assumptions: # - Uses bash arrays, [[ ]] tests, and bash-specific parameter expansions. # - Uses awk for INI parsing (best-effort). # # Notes: # - set -euo pipefail: stop on error/undefined vars; treat pipeline errors as failures. # - Traps ensure SSH child is terminated and temporary keys are cleaned up. # 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 # Host key checking policy: env BACKTUNNEL_HOSTKEY_POLICY = yes|no|ask|accept-new (default: accept-new) HKP="${BACKTUNNEL_HOSTKEY_POLICY:-accept-new}" case "$HKP" in yes|no|ask|accept-new) ;; *) HKP="accept-new" ;; esac # ---------------------------- # Config discovery # Purpose: choose the highest-precedence profiles.ini available. # ---------------------------- 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: read a value from CONFIG_FILE INI [SECTION] key=value # Arguments: # $1: section name # $2: key # Env: # CONFIG_FILE (read) # Returns: # prints the value on success; empty string if not found # Notes: # - Best-effort parsing; trims spaces around '=' and the value. 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: expand @name -> user@host based on profiles.ini, else pass through. # Arguments: # $1: input remote spec (e.g., @work or user@host or user:host) # Returns: # prints expanded remote (user@host) if @name is resolvable; otherwise returns input unchanged 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 global defaults from [default], then override from named profile. # Arguments: # $1: profile name (without '@'), may be empty # Side effects: # - Modifies global variables: TUNNEL_PORT, LOCAL_SSH_PORT, INVITE_MOUNT, INVITE, QR, DURATION # Notes: # - Only applies default values if globals still hold built-in defaults. 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 # Purpose: initialize built-in defaults before applying profiles and flags. # ---------------------------- 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 # Purpose: enforce command grammar and capture the five required positionals. # ---------------------------- [[ $# -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 # Purpose: parse options and override derived defaults. # ---------------------------- 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 # Purpose: accept forms like 30m, 2h, 1d (s/m/h/d). # ---------------------------- if [[ ! "$DURATION" =~ ^[0-9]+[smhd]$ ]]; then echo "Invalid duration '$DURATION' (use forms like 30m, 2h, 1d)." >&2 exit 1 fi # ---------------------------- # Split remote user/host # Purpose: support user:host or user@host; reject anything else (except @profile pre-expanded). # ---------------------------- 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 # Purpose: fail fast if mandatory tools are missing. # ---------------------------- 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 # Purpose: optionally add and later remove a restricted authorized_keys entry. # ---------------------------- # restrict_key_line: produce a hardened authorized_keys line for an OpenSSH public key. # Arguments: # $1: public key text (one line, ssh-*) # Returns: # prints a restricted options prefix + key; nonzero if key is invalid 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: append a uniquely marked restricted key block to ~/.ssh/authorized_keys. # Arguments: # $1: public key text # Side effects: # - Ensures ~/.ssh perms (700) and authorized_keys perms (600) # - Writes marker lines and sets ADDED_MARKER_ID for later removal 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: delete the previously added marked block from authorized_keys. # Env: # ADDED_MARKER_ID (read) # Notes: # No-op if no marker or authorized_keys missing. Best-effort; ignores errors. 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 # Purpose: inform the user what will happen and where to connect from the remote. # ---------------------------- 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) # Purpose: print a ready-to-copy command (and QR) for the remote side. # Notes: # If a key was not pre-authorized, prepend an auth-setup step executed over the tunnel. # ---------------------------- 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) # Purpose: give an actionable warning before attempting the -R bind. # ---------------------------- if ssh -o BatchMode=yes -o StrictHostKeyChecking="$HKP" -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 # Purpose: start the tunnel, wait bounded by duration, and guarantee cleanup via trap. # ---------------------------- SSH_PID="" # shellcheck disable=SC2317 # cleanup: stop the background ssh process and remove any temporary authorized key. # Notes: # - Invoked on INT/TERM/EXIT to ensure resources are released. 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 \ -o StrictHostKeyChecking="$HKP" \ -R "${TUNNEL_PORT}:localhost:${LOCAL_SSH_PORT}" \ -- "${REMOTE_USER}@${REMOTE_HOST}" & SSH_PID=$! # Wait for ssh to exit, but bounded by duration: # Rationale: use 'timeout … tail --pid=PID -f /dev/null' to avoid subshell/wait-loop complexity. 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