Introduce `backtunnel-authorize` for managing restricted SFTP-only keys, and update `backtunnel-share` to support temporary accessor key authorization via `--allow-key` and `--allow-known`. Extend bash completion with profile, accessor, and SSH host suggestions. Revamp README sections to include updated workflows, quick starts, and key management details.
465 lines
14 KiB
Bash
465 lines
14 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]
|
|
#
|
|
# 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
|
|
|
|
# ----------------------------
|
|
# 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 <<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
|
|
# ----------------------------
|
|
[[ $# -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 <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() {
|
|
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 <<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
|
|
)
|
|
else
|
|
AUTH_CMD=""
|
|
INVITE_TEXT=$(
|
|
cat <<EOT
|
|
|
|
# Mount the share:
|
|
${INVITE_CMD}
|
|
|
|
# Unmount when done:
|
|
fusermount -u '${INVITE_MOUNT}' || fusermount3 -u '${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)
|
|
# ----------------------------
|
|
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
|