#!/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: -p|--tunnel-port -l|--local-ssh-port # -i|--invite [--invite-mount ] [--invite-file ] [--qr] -h|--help set -euo pipefail # Config search order: user → system → packaged example CONFIG_USER="${XDG_CONFIG_HOME:-$HOME/.config}/backtunnel/profiles.ini" CONFIG_SYS="/etc/backtunnel/profiles.ini" CONFIG_PKG="/usr/share/backtunnel/profiles.ini" # resolve CONFIG_FILE to first existing if [[ -f "$CONFIG_USER" ]]; then CONFIG_FILE="$CONFIG_USER" elif [[ -f "$CONFIG_SYS" ]]; then CONFIG_FILE="$CONFIG_SYS" else CONFIG_FILE="$CONFIG_PKG" fi # shellcheck disable=SC2317 # invoked later at runtime; ShellCheck can't see call path 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 } 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) 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 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') -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 alice@vps.example.com for 2h -i --qr EOF exit 1 } # --- basic 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 ;; -h|--help) usage ;; *) echo "Unknown option: $1" >&2 usage ;; esac done # --- validate duration (timeout supports 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 --- 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" >&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; } # used for --pid wait 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}" echo # --- print invite (optional) --- if $INVITE; then INVITE_CMD="backtunnel-access '${FOLDER}' from ${REMOTE_USER}@${REMOTE_HOST} -p ${TUNNEL_PORT} -m '${INVITE_MOUNT}'" # Also provide a one-time auth bootstrap that installs a restricted, tunnel-only SFTP key 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." # --- optional 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 # Uncomment the next line to make it a hard failure: # exit 1 fi # --- run ssh in background; trap Ctrl-C/TERM/EXIT to stop it cleanly --- SSH_PID="" # shellcheck disable=SC2317 # cleanup is invoked indirectly via trap cleanup() { if [[ -n "${SSH_PID:-}" ]] && kill -0 "$SSH_PID" 2>/dev/null; then echo "⏹️ Stopping share..." kill -TERM "$SSH_PID" 2>/dev/null || true # give ssh a moment to exit gracefully wait "$SSH_PID" 2>/dev/null || true fi } 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