Files
BackTunnel/scripts/backtunnel-share

312 lines
9.6 KiB
Bash

#!/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 <PORT> -l|--local-ssh-port <PORT>
# -i|--invite [--invite-mount <PATH>] [--invite-file <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 <<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
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')
-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 <<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:
fusermount -u '${INVITE_MOUNT}' || fusermount3 -u '${INVITE_MOUNT}'
EOT
)
echo "🔗 Invite (copy to chat):"
echo "------------------------------------------------------------"
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."
# --- 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