Add accessor key authorization and enhance completion logic

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.
This commit is contained in:
2025-09-20 17:17:26 +02:00
parent cb81c1671b
commit fcbd6514cc
8 changed files with 645 additions and 420 deletions

View File

@@ -1,21 +1,34 @@
#!/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
# 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 search order: user → system → packaged example
# ----------------------------
# 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"
# resolve CONFIG_FILE to first existing
if [[ -f "$CONFIG_USER" ]]; then
CONFIG_FILE="$CONFIG_USER"
elif [[ -f "$CONFIG_SYS" ]]; then
@@ -24,7 +37,10 @@ else
CONFIG_FILE="$CONFIG_PKG"
fi
# shellcheck disable=SC2317 # invoked later at runtime; ShellCheck can't see call path
# ----------------------------
# INI helpers
# ----------------------------
# shellcheck disable=SC2317
ini_get() { # ini_get SECTION KEY -> value
local sec="$1" key="$2"
awk -v s="[""$sec""]" -v k="$key" '
@@ -78,15 +94,23 @@ profile_apply_defaults() { # set globals if unset; named overrides default
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
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=false # print a ready-to-copy access command
INVITE_MOUNT="$HOME/remote-rssh"
INVITE_FILE=""
QR=false # render invite as terminal QR (requires qrencode)
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
@@ -96,7 +120,7 @@ Usage:
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
remoteuser:remotehost Or remoteuser@remotehost (or @profile)
for Literal keyword
<duration> e.g. 30m, 2h, 1d (passed to 'timeout')
@@ -107,17 +131,23 @@ Options:
--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 alice@vps.example.com for 2h -i --qr
$(basename "$0") ~/projects with @work for 2h -i --qr --allow-known alice
EOF
exit 1
}
# --- basic positional parsing ---
# ----------------------------
# Positional parsing
# ----------------------------
[[ $# -lt 5 ]] && usage
FOLDER=$1
@@ -139,7 +169,9 @@ profile_apply_defaults "$profile_name"
# Expand @name -> user@host for actual ssh use
REMOTE="$(profile_expand_remote "$REMOTE")"
# --- optional flags ---
# ----------------------------
# Optional flags
# ----------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
-p|--tunnel-port)
@@ -170,6 +202,16 @@ while [[ $# -gt 0 ]]; do
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
;;
@@ -180,13 +222,17 @@ while [[ $# -gt 0 ]]; do
esac
done
# --- validate duration (timeout supports s,m,h,d) ---
# ----------------------------
# 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 ---
# ----------------------------
# Split remote user/host
# ----------------------------
REMOTE_USER="" REMOTE_HOST=""
if [[ "$REMOTE" == *:* ]]; then
REMOTE_USER=${REMOTE%%:*}
@@ -195,30 +241,120 @@ elif [[ "$REMOTE" == *"@"* ]]; then
REMOTE_USER=${REMOTE%%@*}
REMOTE_HOST=${REMOTE#*@}
else
echo "Invalid remote format. Use remoteuser:remotehost or remoteuser@remotehost" >&2
echo "Invalid remote format. Use remoteuser:remotehost or remoteuser@remotehost or @profile" >&2
exit 1
fi
# --- deps check ---
# ----------------------------
# 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
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
# --- print invite (optional) ---
# ----------------------------
# 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}'"
# 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
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}
@@ -228,13 +364,25 @@ ${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 "------------------------------------------------------------"
echo "${AUTH_CMD}"
[[ -n "$AUTH_CMD" ]] && echo "${AUTH_CMD}"
echo "${INVITE_CMD}"
echo "------------------------------------------------------------"
@@ -259,25 +407,30 @@ echo " backtunnel-access '${FOLDER}' from ${REMOTE_USER}@${REMOTE_HOST} -p $
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) ---
# ----------------------------
# 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
# You may 'exit 1' here if you prefer a hard failure
fi
# --- run ssh in background; trap Ctrl-C/TERM/EXIT to stop it cleanly ---
# ----------------------------
# Cleanup & run SSH
# ----------------------------
SSH_PID=""
# shellcheck disable=SC2317 # cleanup is invoked indirectly via trap
# 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
# give ssh a moment to exit gracefully
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