Introduce `backtunnel-umount` as a portable unmount helper, preferring `fusermount3`, `fusermount`, or `umount`. Add `BACKTUNNEL_HOSTKEY_POLICY` for configurable host key handling in `backtunnel-share` and `backtunnel-access`. Update TUIs for remote folder prompts and mount point handling. Enhance bash completion for TUI commands with directory suggestions. Revamp terminal selection logic in `backtunnel-open-term` to prioritize modern emulators like wezterm. Extend tests with scaffolds for host key policy and unmount behavior. Update README with new scripts, workflows, features, and troubleshooting tips.
579 lines
19 KiB
Bash
579 lines
19 KiB
Bash
#!/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 <duration> [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 <PORT> Remote port to expose with -R (default: 2222)
|
|
# -l|--local-ssh-port <PORT> Local sshd port to forward to (default: 22)
|
|
# -i|--invite Print an invite command (and/or QR) for chat
|
|
# --invite-mount <PATH> Suggested mount point in invite (default: $HOME/remote-rssh)
|
|
# --invite-file <FILE> Also write invite text (with unmount hint) to FILE
|
|
# --qr Also render the invite command as a QR code (qrencode)
|
|
# --allow-key <FILE> Temporarily authorize accessor's public key for this session
|
|
# --allow-known <NAME> 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 <<EOF
|
|
Usage:
|
|
$(basename "$0") /path/to/folder with remoteuser:remotehost for <duration> [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
|
|
<duration> 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 <pubkey_text>
|
|
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 <<EOT
|
|
|
|
# 1) (one-time) install a tunnel-only, SFTP-only key via the reverse tunnel:
|
|
${AUTH_CMD}
|
|
|
|
# 2) mount the share:
|
|
${INVITE_CMD}
|
|
|
|
# Unmount when done:
|
|
backtunnel-umount '${INVITE_MOUNT}'
|
|
EOT
|
|
)
|
|
else
|
|
AUTH_CMD=""
|
|
INVITE_TEXT=$(
|
|
cat <<EOT
|
|
|
|
# Mount the share:
|
|
${INVITE_CMD}
|
|
|
|
# Unmount when done:
|
|
backtunnel-umount '${INVITE_MOUNT}'
|
|
EOT
|
|
)
|
|
fi
|
|
|
|
echo "🔗 Invite (copy to chat):"
|
|
echo "------------------------------------------------------------"
|
|
[[ -n "$AUTH_CMD" ]] && echo "${AUTH_CMD}"
|
|
echo "${INVITE_CMD}"
|
|
echo "------------------------------------------------------------"
|
|
|
|
if [[ -n "${INVITE_FILE}" ]]; then
|
|
printf "%s\n" "${INVITE_TEXT}" > "${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
|