Files
tunnelforge/tunnelforge.sh

10355 lines
399 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# ╔════════════════════════════════════════════════════════════════════╗
# ║ ▀▀█▀▀ █ █ █▄ █ █▄ █ █▀▀ █ █▀▀ █▀█ █▀█ █▀▀ █▀▀ ║
# ║ █ █ █ █ ▀█ █ ▀█ █▀▀ █ █▀ █ █ █▀█ █ █ █▀▀ ║
# ║ █ ▀▀ █ █ █ █ ▀▀▀ ▀▀▀ █ ▀▀▀ █ █ ▀▀▀ ▀▀▀ ║
# ╚════════════════════════════════════════════════════════════════════╝
#
# TunnelForge — SSH Tunnel Manager
# Copyright (C) 2026 SamNet Technologies, LLC
#
# Single-file bash tool with TUI menu, live dashboard,
# DNS leak protection, kill switch, server hardening, and Telegram bot.
#
# Version : 1.0.0
# Author : SamNet Technologies, LLC
# License : GNU General Public License v3.0
# Repo : git.samnet.dev/SamNet-dev/tunnelforge
# Usage : tunnelforge [command] [options]
# Run 'tunnelforge help' for full command reference.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# ============================================================================
# STRICT MODE & BASH VERSION CHECK
# ============================================================================
set -eo pipefail
MIN_BASH_VERSION="4.3"
check_bash_version() {
local major="${BASH_VERSINFO[0]}"
local minor="${BASH_VERSINFO[1]}"
local req_major="${MIN_BASH_VERSION%%.*}"
local req_minor="${MIN_BASH_VERSION##*.}"
if (( major < req_major )) || \
(( major == req_major && minor < req_minor )); then
echo "ERROR: TunnelForge requires bash ${MIN_BASH_VERSION}+" \
"(found ${BASH_VERSION})" >&2
exit 1
fi
}
check_bash_version
# ============================================================================
# VERSION & GLOBAL CONSTANTS
# ============================================================================
readonly VERSION="1.0.0"
readonly GITHUB_REPO="SamNet-dev/tunnelforge"
readonly GITEA_RAW="https://git.samnet.dev/SamNet-dev/tunnelforge/raw/branch/main"
readonly APP_NAME="TunnelForge"
readonly APP_NAME_LOWER="tunnelforge"
# Installation directories
readonly INSTALL_DIR="/opt/tunnelforge"
readonly CONFIG_DIR="${INSTALL_DIR}/config"
readonly PROFILES_DIR="${INSTALL_DIR}/profiles"
readonly PID_DIR="${INSTALL_DIR}/pids"
readonly LOG_DIR="${INSTALL_DIR}/logs"
readonly BACKUP_DIR="${INSTALL_DIR}/backups"
readonly DATA_DIR="${INSTALL_DIR}/data"
readonly BIN_LINK="/usr/local/bin/tunnelforge"
# Config files
readonly MAIN_CONFIG="${CONFIG_DIR}/tunnelforge.conf"
# Temp / lock
TMP_DIR=""
# Background Telegram PIDs for cleanup
declare -a _TG_BG_PIDS=()
# SSH ControlMaster
readonly SSH_CONTROL_DIR="${INSTALL_DIR}/sockets"
# Bandwidth & reconnect history
readonly BW_HISTORY_DIR="${DATA_DIR}/bandwidth"
readonly RECONNECT_LOG_DIR="${DATA_DIR}/reconnects"
# ============================================================================
# TEMP FILE CLEANUP TRAP
# ============================================================================
cleanup() {
local exit_code=$?
tput cnorm 2>/dev/null || true
tput rmcup 2>/dev/null || true
# Reap background Telegram sends
local _tg_p
for _tg_p in "${_TG_BG_PIDS[@]}"; do
kill "$_tg_p" 2>/dev/null || true
wait "$_tg_p" 2>/dev/null || true
done
rm -rf "${TMP_DIR}" 2>/dev/null
rm -f "${PID_DIR}"/*.lock "${CONFIG_DIR}"/*.lock "${PROFILES_DIR}"/*.lock 2>/dev/null
# Clean stale SSH ControlMaster sockets
find "${SSH_CONTROL_DIR}" -type s -delete 2>/dev/null || true
# Clean up our own mkdir-based locks (stale after SIGKILL)
local _cleanup_lck _cleanup_pid
for _cleanup_lck in "${CONFIG_DIR}"/*.lck "${PROFILES_DIR}"/*.lck "${PID_DIR}"/*.lck "${BW_HISTORY_DIR}"/*.lck; do
if [[ -d "$_cleanup_lck" ]]; then
_cleanup_pid=$(cat "${_cleanup_lck}/pid" 2>/dev/null) || true
if [[ "${_cleanup_pid}" == "$$" ]] || [[ -z "$_cleanup_pid" ]]; then
rm -f "${_cleanup_lck}/pid" 2>/dev/null || true
rmdir "$_cleanup_lck" 2>/dev/null || true
fi
fi
done
exit "${exit_code}"
}
trap cleanup EXIT INT TERM HUP QUIT
TMP_DIR=$(mktemp -d "/tmp/tunnelforge.XXXXXX" 2>/dev/null) || {
echo "FATAL: Cannot create secure temporary directory" >&2
exit 1
}
chmod 700 "${TMP_DIR}" 2>/dev/null || true
readonly TMP_DIR
# ============================================================================
# CONFIGURATION DEFAULTS (declare -gA CONFIG)
# ============================================================================
declare -gA CONFIG=(
# SSH defaults
[SSH_DEFAULT_USER]="root"
[SSH_DEFAULT_PORT]="22"
[SSH_DEFAULT_KEY]=""
[SSH_CONNECT_TIMEOUT]="10"
[SSH_SERVER_ALIVE_INTERVAL]="30"
[SSH_SERVER_ALIVE_COUNT_MAX]="3"
[SSH_STRICT_HOST_KEY]="yes"
# AutoSSH
[AUTOSSH_ENABLED]="true"
[AUTOSSH_POLL]="30"
[AUTOSSH_FIRST_POLL]="30"
[AUTOSSH_GATETIME]="30"
[AUTOSSH_MONITOR_PORT]="0"
[AUTOSSH_LOG_LEVEL]="1"
# ControlMaster
[CONTROLMASTER_ENABLED]="false"
[CONTROLMASTER_PERSIST]="600"
# Security
[DNS_LEAK_PROTECTION]="false"
[DNS_SERVER_1]="1.1.1.1"
[DNS_SERVER_2]="1.0.0.1"
[KILL_SWITCH]="false"
# Telegram
[TELEGRAM_ENABLED]="false"
[TELEGRAM_BOT_TOKEN]=""
[TELEGRAM_CHAT_ID]=""
[TELEGRAM_ALERTS]="true"
[TELEGRAM_PERIODIC_STATUS]="false"
[TELEGRAM_STATUS_INTERVAL]="3600"
# Dashboard
[DASHBOARD_REFRESH]="5"
[DASHBOARD_THEME]="retro"
# Logging
[LOG_LEVEL]="info"
[LOG_JSON]="false"
[LOG_MAX_SIZE]="10485760"
[LOG_ROTATE_COUNT]="5"
# General
[AUTO_UPDATE_CHECK]="false"
)
config_get() {
local key="$1"
local default="${2:-}"
echo "${CONFIG[$key]:-$default}"
}
config_set() {
local key="$1"
local value="$2"
CONFIG["$key"]="$value"
}
# ============================================================================
# COLORS & TERMINAL DETECTION
# ============================================================================
if [[ -t 1 ]] && [[ -t 2 ]]; then
IS_TTY=true
else
IS_TTY=false
fi
if [[ "${IS_TTY}" == true ]] && [[ "${TERM:-dumb}" != "dumb" ]] && [[ -z "${NO_COLOR:-}" ]]; then
RED=$'\033[0;31m'
GREEN=$'\033[0;32m'
YELLOW=$'\033[0;33m'
BLUE=$'\033[0;34m'
MAGENTA=$'\033[0;35m'
CYAN=$'\033[0;36m'
WHITE=$'\033[0;37m'
BOLD=$'\033[1m'
BOLD_RED=$'\033[1;31m'
BOLD_GREEN=$'\033[1;32m'
BOLD_YELLOW=$'\033[1;33m'
BOLD_BLUE=$'\033[1;34m'
BOLD_MAGENTA=$'\033[1;35m'
BOLD_CYAN=$'\033[1;36m'
BOLD_WHITE=$'\033[1;37m'
BG_RED=$'\033[41m'
BG_GREEN=$'\033[42m'
BG_YELLOW=$'\033[43m'
BG_BLUE=$'\033[44m'
DIM=$'\033[2m'
UNDERLINE=$'\033[4m'
REVERSE=$'\033[7m'
RESET=$'\033[0m'
# Status indicators (Unicode + color)
readonly STATUS_OK="${GREEN}${RESET}"
readonly STATUS_FAIL="${RED}${RESET}"
readonly STATUS_WARN="${YELLOW}${RESET}"
readonly STATUS_STOP="${DIM}${RESET}"
readonly STATUS_SPIN="${CYAN}${RESET}"
else
RED='' GREEN='' YELLOW='' BLUE='' MAGENTA='' CYAN='' WHITE=''
BOLD='' BOLD_RED='' BOLD_GREEN='' BOLD_YELLOW='' BOLD_BLUE=''
BOLD_MAGENTA='' BOLD_CYAN='' BOLD_WHITE=''
BG_RED='' BG_GREEN='' BG_YELLOW='' BG_BLUE=''
DIM='' UNDERLINE='' REVERSE='' RESET=''
IS_TTY=false
# Status indicators (ASCII fallback for dumb/pipe/NO_COLOR)
readonly STATUS_OK="*"
readonly STATUS_FAIL="x"
readonly STATUS_WARN="!"
readonly STATUS_STOP="-"
readonly STATUS_SPIN="+"
fi
# ============================================================================
# LOGGING
# ============================================================================
declare -gA LOG_LEVELS=( [debug]=0 [info]=1 [success]=2 [warn]=3 [error]=4 )
_get_log_level_num() { echo "${LOG_LEVELS[${1:-info}]:-1}"; }
_should_log() {
local msg_level="$1"
local configured_level
configured_level=$(config_get "LOG_LEVEL" "info")
local msg_num configured_num
msg_num=$(_get_log_level_num "$msg_level")
configured_num=$(_get_log_level_num "$configured_level")
if (( msg_num >= configured_num )); then return 0; fi
return 1
}
log_json() {
local level="$1" message="$2"
local ts
ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
# Escape JSON special characters: backslash first, then quotes, then control chars
message="${message//\\/\\\\}"
message="${message//\"/\\\"}"
message="${message//$'\n'/\\n}"
message="${message//$'\t'/\\t}"
message="${message//$'\r'/\\r}"
printf '{"timestamp":"%s","level":"%s","app":"%s","message":"%s"}\n' \
"$ts" "$level" "$APP_NAME_LOWER" "$message"
}
_log() {
local level="$1" color="$2" prefix="$3" message="$4"
_should_log "$level" || return 0
if [[ "$(config_get LOG_JSON false)" == "true" ]]; then
log_json "$level" "$message" \
>> "${LOG_DIR}/${APP_NAME_LOWER}.log" 2>/dev/null || true
fi
local ts
ts=$(date '+%H:%M:%S')
if [[ "${IS_TTY}" == true ]]; then
printf "${DIM}[%s]${RESET} ${color}${prefix}${RESET} %s\n" \
"$ts" "$message" >&2
else
printf "[%s] %s %s\n" "$ts" "$prefix" "$message" >&2
fi
}
log_debug() { _log "debug" "${DIM}" "[DEBUG]" "$1"; }
log_info() { _log "info" "${CYAN}" "[INFO] " "$1"; }
log_success() { _log "success" "${GREEN}" "[ OK ]" "$1"; }
log_warn() { _log "warn" "${YELLOW}" "[WARN] " "$1"; }
log_error() { _log "error" "${RED}" "[ERROR]" "$1"; }
log_file() {
local level="$1" message="$2"
if [[ "$(config_get LOG_JSON false)" == "true" ]]; then
log_json "$level" "$message" \
>> "${LOG_DIR}/${APP_NAME_LOWER}.log" 2>/dev/null || true
else
local ts
ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
printf "[%s] [%s] %s\n" "$ts" "$level" "$message" \
>> "${LOG_DIR}/${APP_NAME_LOWER}.log" 2>/dev/null || true
fi
}
# ============================================================================
# UTILITY FUNCTIONS
# ============================================================================
# Drain trailing bytes from multi-byte escape sequences (arrow keys, etc.)
# Call after read -rsn1: if key is ESC, consume the rest and blank the var.
# Usage: _drain_esc varname
_drain_esc() {
local -n _de_ref="$1"
if [[ "${_de_ref}" == $'\033' ]]; then
local _de_trash
read -rsn2 -t 0.1 _de_trash </dev/tty 2>/dev/null || true
_de_ref=""
fi
}
validate_port() {
local port="$1"
if [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )); then
return 0
fi
return 1
}
# Check if a local port is already in use or assigned
# Returns 0 if free, 1 if busy. Prints suggestion on conflict.
_check_port_conflict() {
local _cp_port="$1" _cp_type="${2:-local}"
local _cp_busy=false _cp_who=""
# Check active listeners via ss
if command -v ss &>/dev/null; then
if ss -tln 2>/dev/null | tail -n +2 | grep -qE "[:.]${_cp_port}[[:space:]]"; then
_cp_busy=true
_cp_who="system process"
fi
fi
# Check other TunnelForge profiles
if [[ -d "$PROFILES_DIR" ]]; then
local _cp_f _cp_pname
for _cp_f in "$PROFILES_DIR"/*.conf; do
[[ -f "$_cp_f" ]] || continue
_cp_pname=$(basename "$_cp_f" .conf)
local _cp_pport=""
_cp_pport=$(grep -oE "^LOCAL_PORT='[0-9]+'" "$_cp_f" 2>/dev/null | cut -d"'" -f2) || true
if [[ "$_cp_pport" == "$_cp_port" ]]; then
_cp_busy=true
_cp_who="profile '${_cp_pname}'"
break
fi
done
fi
if [[ "$_cp_busy" == true ]]; then
printf " ${YELLOW}! Port %s is used by %s${RESET}\n" "$_cp_port" "$_cp_who" >/dev/tty
# Suggest next free port
local _cp_try=$(( _cp_port + 1 ))
local _cp_max=$(( _cp_port + 20 ))
while (( _cp_try <= _cp_max && _cp_try <= 65535 )); do
local _cp_free=true
if command -v ss &>/dev/null; then
if ss -tln 2>/dev/null | tail -n +2 | grep -qE "[:.]${_cp_try}[[:space:]]"; then
_cp_free=false
fi
fi
if [[ "$_cp_free" == true ]] && [[ -d "$PROFILES_DIR" ]]; then
for _cp_f in "$PROFILES_DIR"/*.conf; do
[[ -f "$_cp_f" ]] || continue
local _cp_pp=""
_cp_pp=$(grep -oE "^LOCAL_PORT='[0-9]+'" "$_cp_f" 2>/dev/null | cut -d"'" -f2) || true
if [[ "$_cp_pp" == "$_cp_try" ]]; then
_cp_free=false; break
fi
done
fi
if [[ "$_cp_free" == true ]]; then
printf " ${DIM}Suggested: %s${RESET}\n" "$_cp_try" >/dev/tty
return 1
fi
(( ++_cp_try ))
done
return 1
fi
return 0
}
validate_ip() {
local ip="$1"
if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
local IFS='.'
read -ra octets <<< "$ip"
for octet in "${octets[@]}"; do
# Reject leading zeros (octal ambiguity with iptables/ssh)
if [[ "$octet" =~ ^0[0-9] ]]; then return 1; fi
if (( 10#$octet > 255 )); then return 1; fi
done
return 0
fi
return 1
}
validate_ip6() {
local ip="$1"
# Accept bracketed form [::1]
if [[ "$ip" =~ ^\[([0-9a-fA-F:]+)\]$ ]]; then
ip="${BASH_REMATCH[1]}"
fi
# Basic IPv6: must contain at least one colon, only hex digits and colons
if [[ "$ip" =~ ^[0-9a-fA-F]*:[0-9a-fA-F:]*$ ]]; then
# Reject more than one :: (invalid shorthand)
local _dc="${ip//[^:]/}"
if [[ "${ip}" == *"::"*"::"* ]]; then return 1; fi
return 0
fi
return 1
}
validate_hostname() {
local host="$1"
validate_ip "$host" && return 0
# Accept bracket-wrapped IPv6 (e.g., [::1], [2001:db8::1])
if [[ "$host" =~ ^\[[0-9a-fA-F:]+\]$ ]]; then return 0; fi
# Accept bare IPv6 (e.g., ::1, 2001:db8::1)
if [[ "$host" =~ ^[0-9a-fA-F]*:[0-9a-fA-F:]+$ ]]; then return 0; fi
[[ "$host" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$ ]]
}
validate_profile_name() {
local name="$1"
[[ "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ ]]
}
format_bytes() {
local bytes="${1:-0}"
[[ "$bytes" =~ ^[0-9]+$ ]] || bytes=0
if (( bytes < 1024 )); then
printf "%d B" "$bytes"
elif (( bytes < 1048576 )); then
printf "%d.%d KB" "$(( bytes / 1024 ))" "$(( (bytes % 1024) * 10 / 1024 ))"
elif (( bytes < 1073741824 )); then
printf "%d.%d MB" "$(( bytes / 1048576 ))" "$(( (bytes % 1048576) * 10 / 1048576 ))"
else
printf "%d.%02d GB" "$(( bytes / 1073741824 ))" "$(( (bytes % 1073741824) * 100 / 1073741824 ))"
fi
}
format_duration() {
local seconds="${1:-0}"
[[ "$seconds" =~ ^[0-9]+$ ]] || seconds=0
local d=$(( seconds / 86400 ))
local h=$(( (seconds % 86400) / 3600 ))
local m=$(( (seconds % 3600) / 60 ))
local s=$(( seconds % 60 ))
if (( d > 0 )); then
printf "%dd %dh %dm" "$d" "$h" "$m"
elif (( h > 0 )); then
printf "%dh %dm %ds" "$h" "$m" "$s"
elif (( m > 0 )); then
printf "%dm %ds" "$m" "$s"
else
printf "%ds" "$s"
fi
}
get_public_ip() {
local ip="" svc
local services=(
"https://api.ipify.org"
"https://ifconfig.me/ip"
"https://icanhazip.com"
"https://ipecho.net/plain"
)
for svc in "${services[@]}"; do
ip=$(curl -s --max-time 5 "$svc" 2>/dev/null | tr -d '[:space:]' || true)
if validate_ip "$ip" || validate_ip6 "$ip"; then
echo "$ip"; return 0
fi
done
echo "unknown"; return 1
}
check_root() {
local hint="${1:-}"
if [[ $EUID -ne 0 ]]; then
log_error "This operation requires root privileges"
log_info "Run with: sudo tunnelforge ${hint}"
return 1
fi
}
confirm_action() {
local message="${1:-Are you sure?}"
local default="${2:-n}"
local prompt
if [[ "$default" == "y" ]]; then
prompt="${message} [Y/n]: "
else
prompt="${message} [y/N]: "
fi
printf "${BOLD}%s${RESET}" "$prompt" >/dev/tty
local answer
read -r answer </dev/tty || true
answer="${answer:-$default}"
case "${answer,,}" in
y|yes) return 0 ;;
*) return 1 ;;
esac
}
print_line() {
local char="${1:-}" width="${2:-$(get_term_width)}"
local i
local line=""
for (( i=0; i<width; i++ )); do
line+="$char"
done
printf '%s\n' "$line"
}
spinner() {
local pid="$1" message="${2:-Working...}"
local -a chars
if [[ "${IS_TTY}" == true ]] && [[ "${TERM:-dumb}" != "dumb" ]] && [[ -z "${NO_COLOR:-}" ]]; then
chars=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
else
chars=('|' '/' '-' '\')
fi
local i=0
while kill -0 "$pid" 2>/dev/null; do
printf "\r${CYAN}%s${RESET} %s" "${chars[$i]}" "$message" >&2
i=$(( (i + 1) % ${#chars[@]} ))
sleep 0.1
done
printf "\r%*s\r" $(( ${#message} + 3 )) "" >&2
}
is_port_in_use() {
local port="$1" bind_addr="${2:-127.0.0.1}"
if command -v ss &>/dev/null; then
local _ss_pattern
if [[ "$bind_addr" == "0.0.0.0" ]] || [[ "$bind_addr" == "::" ]] || [[ "$bind_addr" == "[::]" ]]; then
_ss_pattern="\\*"
elif [[ "$bind_addr" =~ : ]]; then
# IPv6 — ss shows as [addr]:port; escape brackets for grep
local _stripped="${bind_addr#\[}"
_stripped="${_stripped%\]}"
_ss_pattern="\\[${_stripped}\\]"
else
_ss_pattern="${bind_addr//./\\.}"
fi
ss -tln sport = :"${port}" 2>/dev/null | tail -n +2 | grep -qE "(${_ss_pattern}|\\*):" 2>/dev/null
elif command -v netstat &>/dev/null; then
netstat -tln 2>/dev/null | grep -qE "(${bind_addr//./\\.}|0\\.0\\.0\\.0):${port}([[:space:]]|$)"
else
return 1
fi
}
get_term_width() {
tput cols 2>/dev/null || echo 80
}
# ============================================================================
# OS DETECTION & PACKAGE MANAGEMENT
# ============================================================================
declare -g OS_ID=""
declare -g OS_VERSION=""
declare -g OS_FAMILY=""
declare -g PKG_MANAGER=""
declare -g PKG_INSTALL=""
declare -g PKG_UPDATE=""
declare -g INIT_SYSTEM=""
detect_os() {
if [[ -f /etc/os-release ]]; then
# Parse directly — sourcing collides with our readonly VERSION
OS_ID=$(grep -m1 '^ID=' /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"') || true
OS_VERSION=$(grep -m1 '^VERSION_ID=' /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"') || true
: "${OS_ID:=unknown}" "${OS_VERSION:=unknown}"
elif [[ -f /etc/redhat-release ]]; then
OS_ID="rhel"
OS_VERSION=$(grep -oE '[0-9]+\.[0-9]+' /etc/redhat-release | head -1 || true)
else
OS_ID="unknown"
OS_VERSION="unknown"
fi
case "${OS_ID}" in
ubuntu|debian|raspbian|linuxmint|pop|kali|parrot)
OS_FAMILY="debian"
PKG_MANAGER="apt-get"
PKG_INSTALL="apt-get install -y"
PKG_UPDATE="apt-get update"
;;
fedora|centos|rhel|rocky|almalinux|ol)
OS_FAMILY="rhel"
if command -v dnf &>/dev/null; then
PKG_MANAGER="dnf"
PKG_INSTALL="dnf install -y"
PKG_UPDATE="dnf check-update"
else
PKG_MANAGER="yum"
PKG_INSTALL="yum install -y"
PKG_UPDATE="yum check-update"
fi
;;
arch|manjaro|endeavouros)
OS_FAMILY="arch"
PKG_MANAGER="pacman"
PKG_INSTALL="pacman -S --noconfirm"
PKG_UPDATE="pacman -Sy"
;;
alpine)
OS_FAMILY="alpine"
PKG_MANAGER="apk"
PKG_INSTALL="apk add"
PKG_UPDATE="apk update"
;;
opensuse*|sles)
OS_FAMILY="suse"
PKG_MANAGER="zypper"
PKG_INSTALL="zypper install -y"
PKG_UPDATE="zypper refresh"
;;
*)
OS_FAMILY="unknown"
log_warn "Unknown OS: ${OS_ID}. Some features may not work."
;;
esac
# Detect init system
if command -v systemctl &>/dev/null && [[ -d /run/systemd/system ]]; then
INIT_SYSTEM="systemd"
elif command -v rc-service &>/dev/null; then
INIT_SYSTEM="openrc"
elif [[ -d /etc/init.d ]]; then
INIT_SYSTEM="sysvinit"
else
INIT_SYSTEM="unknown"
fi
log_debug "Detected OS: ${OS_ID} ${OS_VERSION} (${OS_FAMILY}), init: ${INIT_SYSTEM}"
}
install_package() {
local pkg="$1"
local pkg_name="$pkg"
case "${OS_FAMILY}" in
debian)
case "$pkg" in
openssh-client) pkg_name="openssh-client" ;;
ncurses) pkg_name="ncurses-bin" ;;
esac ;;
rhel)
case "$pkg" in
openssh-client) pkg_name="openssh-clients" ;;
ncurses) pkg_name="ncurses" ;;
iproute2) pkg_name="iproute" ;;
esac ;;
arch)
case "$pkg" in
openssh-client) pkg_name="openssh" ;;
ncurses) pkg_name="ncurses" ;;
iproute2) pkg_name="iproute2" ;;
esac ;;
alpine)
case "$pkg" in
openssh-client) pkg_name="openssh-client" ;;
ncurses) pkg_name="ncurses" ;;
iproute2) pkg_name="iproute2" ;;
esac ;;
suse)
case "$pkg" in
openssh-client) pkg_name="openssh" ;;
ncurses) pkg_name="ncurses-utils" ;;
iproute2) pkg_name="iproute2" ;;
esac ;;
esac
if [[ -z "$PKG_INSTALL" ]]; then
log_error "No package manager configured for this OS"
return 1
fi
log_info "Installing ${pkg_name}..."
if ${PKG_INSTALL} "${pkg_name}" &>/dev/null; then
log_success "Installed ${pkg_name}"
return 0
else
log_error "Failed to install ${pkg_name}"
return 1
fi
}
check_dependencies() {
local missing=()
local -A deps=(
[ssh]="openssh-client"
[autossh]="autossh"
[sshpass]="sshpass"
[iptables]="iptables"
[curl]="curl"
[ip]="iproute2"
[tput]="ncurses"
[bc]="bc"
[jq]="jq"
)
log_info "Checking dependencies..."
for cmd in "${!deps[@]}"; do
if ! command -v "$cmd" &>/dev/null; then
missing+=("${deps[$cmd]}")
log_warn "Missing: ${cmd} (package: ${deps[$cmd]})"
else
log_debug "Found: ${cmd}"
fi
done
if [[ ${#missing[@]} -eq 0 ]]; then
log_success "All dependencies satisfied"
return 0
fi
log_info "Missing ${#missing[@]} package(s): ${missing[*]}"
if ! check_root; then
log_error "Root access needed to install missing packages"
return 1
fi
log_info "Updating package cache..."
${PKG_UPDATE} &>/dev/null || true
local failed=0
for pkg in "${missing[@]}"; do
install_package "$pkg" || ((++failed))
done
if (( failed > 0 )); then
log_error "${failed} package(s) failed to install"
return 1
fi
log_success "All dependencies installed"
return 0
}
# ============================================================================
# SETTINGS LOAD / SAVE (safe whitelist-validated parser)
# ============================================================================
readonly CONFIG_WHITELIST=(
SSH_DEFAULT_USER SSH_DEFAULT_PORT SSH_DEFAULT_KEY
SSH_CONNECT_TIMEOUT SSH_SERVER_ALIVE_INTERVAL SSH_SERVER_ALIVE_COUNT_MAX
SSH_STRICT_HOST_KEY
AUTOSSH_ENABLED AUTOSSH_POLL AUTOSSH_GATETIME AUTOSSH_MONITOR_PORT
AUTOSSH_FIRST_POLL AUTOSSH_LOG_LEVEL
CONTROLMASTER_ENABLED CONTROLMASTER_PERSIST
DNS_LEAK_PROTECTION DNS_SERVER_1 DNS_SERVER_2 KILL_SWITCH
TELEGRAM_ENABLED TELEGRAM_BOT_TOKEN TELEGRAM_CHAT_ID
TELEGRAM_ALERTS TELEGRAM_PERIODIC_STATUS TELEGRAM_STATUS_INTERVAL
DASHBOARD_REFRESH DASHBOARD_THEME
LOG_LEVEL LOG_JSON LOG_MAX_SIZE LOG_ROTATE_COUNT
AUTO_UPDATE_CHECK
)
_is_whitelisted() {
local key="$1" k
for k in "${CONFIG_WHITELIST[@]}"; do
if [[ "$k" == "$key" ]]; then return 0; fi
done
return 1
}
load_settings() {
local config_file="${1:-$MAIN_CONFIG}"
[[ -f "$config_file" ]] || return 0
log_debug "Loading settings from: ${config_file}"
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
if [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then
local key="${BASH_REMATCH[1]}"
local value="${BASH_REMATCH[2]}"
# Strip matched outer quote pairs, then un-escape
if [[ "$value" == \'*\' ]]; then
value="${value#\'}"; value="${value%\'}"
value="${value//\'\\\'\'/\'}"
elif [[ "$value" == \"*\" ]]; then
value="${value#\"}"; value="${value%\"}"
fi
if _is_whitelisted "$key"; then
CONFIG["$key"]="$value"
if [[ "$key" == "TELEGRAM_BOT_TOKEN" ]] || [[ "$key" == "TELEGRAM_CHAT_ID" ]]; then
log_debug "Loaded: ${key}=****"
else
log_debug "Loaded: ${key}=${value}"
fi
else
log_warn "Ignoring unknown config key: ${key}"
fi
fi
done < "$config_file"
log_debug "Settings loaded"
}
save_settings() {
local config_file="${1:-$MAIN_CONFIG}"
# Acquire file-level lock to prevent concurrent writer races
local _ss_lock_fd="" _ss_lock_dir=""
_ss_unlock() {
if [[ -n "${_ss_lock_fd:-}" ]]; then exec {_ss_lock_fd}>&- 2>/dev/null || true; fi
if [[ -n "${_ss_lock_dir:-}" ]]; then
rm -f "${_ss_lock_dir}/pid" 2>/dev/null || true
rmdir "${_ss_lock_dir}" 2>/dev/null || true
_ss_lock_dir=""
fi
}
if command -v flock &>/dev/null; then
exec {_ss_lock_fd}>"${config_file}.lock"
flock -w 5 "$_ss_lock_fd" 2>/dev/null || { log_warn "Could not acquire settings lock"; _ss_unlock; return 1; }
else
_ss_lock_dir="${config_file}.lck"
local _ss_try=0
while ! mkdir "$_ss_lock_dir" 2>/dev/null; do
local _ss_stale_pid=""
_ss_stale_pid=$(cat "${_ss_lock_dir}/pid" 2>/dev/null) || true
if [[ -n "$_ss_stale_pid" ]] && ! kill -0 "$_ss_stale_pid" 2>/dev/null; then
rm -f "${_ss_lock_dir}/pid" 2>/dev/null || true
rmdir "$_ss_lock_dir" 2>/dev/null || true
continue
fi
if (( ++_ss_try >= 10 )); then log_warn "Could not acquire settings lock"; return 1; fi
sleep 0.5
done
printf '%s' "$$" > "${_ss_lock_dir}/pid" 2>/dev/null || true
fi
local tmp_file
tmp_file=$(mktemp "${TMP_DIR}/config.XXXXXX")
mkdir -p "$(dirname "$config_file")" 2>/dev/null || true
cat > "$tmp_file" <<'HEADER'
# ============================================================================
# TunnelForge Configuration
# Generated automatically — edit with care
# ============================================================================
HEADER
local key _sv
{
for key in "${CONFIG_WHITELIST[@]}"; do
if [[ -n "${CONFIG[$key]+x}" ]]; then
_sv="${CONFIG[$key]//$'\n'/}"
_sv="${_sv//\'/\'\\\'\'}"
printf "%s='%s'\n" "$key" "$_sv"
fi
done
} >> "$tmp_file"
# Set permissions before mv so there's no window of insecure perms
chmod 600 "$tmp_file" 2>/dev/null || true
# mv fails across filesystems (/tmp → /etc), fall back to cp to same-dir temp then mv
if mv "$tmp_file" "$config_file" 2>/dev/null || \
{ cp "$tmp_file" "${config_file}.tmp.$$" 2>/dev/null && \
mv "${config_file}.tmp.$$" "$config_file" 2>/dev/null && \
rm -f "$tmp_file" 2>/dev/null; }; then
log_debug "Settings saved to: ${config_file}"
_ss_unlock
return 0
fi
rm -f "${config_file}.tmp.$$" 2>/dev/null
rm -f "$tmp_file" 2>/dev/null
log_error "Failed to save settings to: ${config_file}"
_ss_unlock
return 1
}
# ============================================================================
# DIRECTORY INITIALIZATION
# ============================================================================
init_directories() {
local dirs=(
"$INSTALL_DIR" "$CONFIG_DIR" "$PROFILES_DIR"
"$PID_DIR" "$LOG_DIR" "$BACKUP_DIR"
"$DATA_DIR" "$SSH_CONTROL_DIR"
"$BW_HISTORY_DIR" "$RECONNECT_LOG_DIR"
)
for dir in "${dirs[@]}"; do
if [[ ! -d "$dir" ]]; then
mkdir -p "$dir" 2>/dev/null || {
log_error "Failed to create directory: ${dir}"
return 1
}
fi
done
chmod 700 "$CONFIG_DIR" 2>/dev/null || true
chmod 700 "$SSH_CONTROL_DIR" 2>/dev/null || true
chmod 700 "$LOG_DIR" 2>/dev/null || true
chmod 700 "$PROFILES_DIR" 2>/dev/null || true
chmod 700 "$PID_DIR" 2>/dev/null || true
chmod 700 "$BACKUP_DIR" 2>/dev/null || true
chmod 700 "$DATA_DIR" 2>/dev/null || true
chmod 700 "$BW_HISTORY_DIR" 2>/dev/null || true
chmod 700 "$RECONNECT_LOG_DIR" 2>/dev/null || true
chmod 755 "$INSTALL_DIR" 2>/dev/null || true
log_debug "Directories initialized"
}
# ============================================================================
# PROFILE MANAGEMENT
# ============================================================================
readonly PROFILE_FIELDS=(
PROFILE_NAME TUNNEL_TYPE
SSH_HOST SSH_PORT SSH_USER SSH_PASSWORD IDENTITY_KEY
LOCAL_BIND_ADDR LOCAL_PORT REMOTE_HOST REMOTE_PORT
JUMP_HOSTS SSH_OPTIONS
AUTOSSH_ENABLED AUTOSSH_MONITOR_PORT
DNS_LEAK_PROTECTION KILL_SWITCH AUTOSTART
OBFS_MODE OBFS_PORT OBFS_LOCAL_PORT OBFS_PSK
DESCRIPTION
)
_profile_path() { echo "${PROFILES_DIR}/${1}.conf"; }
create_profile() {
local name="$1"
if ! validate_profile_name "$name"; then
log_error "Invalid profile name: '${name}'"
log_info "Use letters, numbers, hyphens, underscores (max 64 chars)"
return 1
fi
local profile_file
profile_file=$(_profile_path "$name")
if [[ -f "$profile_file" ]]; then
log_error "Profile '${name}' already exists"
return 1
fi
local -A profile=(
[PROFILE_NAME]="$name"
[TUNNEL_TYPE]="socks5"
[SSH_HOST]=""
[SSH_PORT]="$(config_get SSH_DEFAULT_PORT 22)"
[SSH_USER]="$(config_get SSH_DEFAULT_USER root)"
[IDENTITY_KEY]="$(config_get SSH_DEFAULT_KEY)"
[LOCAL_BIND_ADDR]="127.0.0.1"
[LOCAL_PORT]="1080"
[REMOTE_HOST]=""
[REMOTE_PORT]=""
[JUMP_HOSTS]=""
[SSH_OPTIONS]=""
[AUTOSSH_ENABLED]="$(config_get AUTOSSH_ENABLED true)"
[AUTOSSH_MONITOR_PORT]="0"
[DNS_LEAK_PROTECTION]="false"
[KILL_SWITCH]="false"
[AUTOSTART]="false"
[DESCRIPTION]=""
)
_save_profile_data "$profile_file" profile
log_success "Profile '${name}' created"
}
load_profile() {
local name="$1"
local -n _profile_ref="$2"
local profile_file
profile_file=$(_profile_path "$name")
if [[ ! -f "$profile_file" ]]; then
log_error "Profile '${name}' not found"
return 1
fi
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
if [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then
local key="${BASH_REMATCH[1]}"
local value="${BASH_REMATCH[2]}"
# Strip matched outer quote pairs, then un-escape
if [[ "$value" == \'*\' ]]; then
value="${value#\'}"; value="${value%\'}"
value="${value//\'\\\'\'/\'}"
elif [[ "$value" == \"*\" ]]; then
value="${value#\"}"; value="${value%\"}"
fi
local valid=false field
for field in "${PROFILE_FIELDS[@]}"; do
[[ "$field" == "$key" ]] && { valid=true; break; }
done
if [[ "$valid" == true ]]; then _profile_ref["$key"]="$value"; fi
fi
done < "$profile_file"
# Validate critical fields against injection
local _fld _fval
for _fld in SSH_USER SSH_HOST REMOTE_HOST; do
_fval="${_profile_ref[$_fld]:-}"
[[ -z "$_fval" ]] && continue
if [[ "$_fval" =~ [[:cntrl:]] ]]; then
log_error "Profile '${name}': ${_fld} contains control characters"
return 1
fi
done
if [[ -n "${_profile_ref[SSH_USER]:-}" ]] && \
! [[ "${_profile_ref[SSH_USER]}" =~ ^[a-zA-Z0-9._@-]+$ ]]; then
log_error "Profile '${name}': SSH_USER contains invalid characters"
return 1
fi
if [[ -n "${_profile_ref[SSH_HOST]:-}" ]] && \
! [[ "${_profile_ref[SSH_HOST]}" =~ ^[a-zA-Z0-9._:%-]+$|^\[[0-9a-fA-F:]+\]$ ]]; then
log_error "Profile '${name}': SSH_HOST contains invalid characters"
return 1
fi
if [[ -n "${_profile_ref[REMOTE_HOST]:-}" ]] && \
! [[ "${_profile_ref[REMOTE_HOST]}" =~ ^[a-zA-Z0-9._:%-]+$|^\[[0-9a-fA-F:]+\]$ ]]; then
log_error "Profile '${name}': REMOTE_HOST contains invalid characters"
return 1
fi
# Validate LOCAL_BIND_ADDR (used directly in SSH -D/-L/-R commands)
if [[ -n "${_profile_ref[LOCAL_BIND_ADDR]:-}" ]] && \
! { validate_ip "${_profile_ref[LOCAL_BIND_ADDR]}" || \
validate_ip6 "${_profile_ref[LOCAL_BIND_ADDR]}" || \
[[ "${_profile_ref[LOCAL_BIND_ADDR]}" == "localhost" ]] || \
[[ "${_profile_ref[LOCAL_BIND_ADDR]}" == "*" ]] || \
[[ "${_profile_ref[LOCAL_BIND_ADDR]}" == "0.0.0.0" ]]; }; then
log_error "Profile '${name}': LOCAL_BIND_ADDR is not a valid address"
return 1
fi
# Validate OBFS_MODE enum
if [[ -n "${_profile_ref[OBFS_MODE]:-}" ]] && \
! [[ "${_profile_ref[OBFS_MODE]}" =~ ^(none|stunnel)$ ]]; then
log_error "Profile '${name}': OBFS_MODE must be 'none' or 'stunnel'"
return 1
fi
log_debug "Profile '${name}' loaded"
}
_save_profile_data() {
local file="$1"
local -n _data_ref="$2"
mkdir -p "$(dirname "$file")" 2>/dev/null || true
# Acquire file-level lock to prevent concurrent writer races
local _spd_lock_fd="" _spd_lock_dir=""
_spd_unlock() {
if [[ -n "${_spd_lock_fd:-}" ]]; then exec {_spd_lock_fd}>&- 2>/dev/null || true; fi
if [[ -n "${_spd_lock_dir:-}" ]]; then
rm -f "${_spd_lock_dir}/pid" 2>/dev/null || true
rmdir "${_spd_lock_dir}" 2>/dev/null || true
_spd_lock_dir=""
fi
}
if command -v flock &>/dev/null; then
exec {_spd_lock_fd}>"${file}.lock"
flock -w 5 "$_spd_lock_fd" 2>/dev/null || { log_warn "Could not acquire profile lock"; _spd_unlock; return 1; }
else
_spd_lock_dir="${file}.lck"
local _spd_try=0
while ! mkdir "$_spd_lock_dir" 2>/dev/null; do
local _spd_stale_pid=""
_spd_stale_pid=$(cat "${_spd_lock_dir}/pid" 2>/dev/null) || true
if [[ -n "$_spd_stale_pid" ]] && ! kill -0 "$_spd_stale_pid" 2>/dev/null; then
rm -f "${_spd_lock_dir}/pid" 2>/dev/null || true
rmdir "$_spd_lock_dir" 2>/dev/null || true
continue
fi
if (( ++_spd_try >= 10 )); then log_warn "Could not acquire profile lock"; return 1; fi
sleep 0.5
done
printf '%s' "$$" > "${_spd_lock_dir}/pid" 2>/dev/null || true
fi
local tmp_file
tmp_file=$(mktemp "${TMP_DIR}/profile.XXXXXX")
{
printf "# TunnelForge Profile\n"
printf "# Generated: %s\n\n" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
local _sv field
for field in "${PROFILE_FIELDS[@]}"; do
if [[ -n "${_data_ref[$field]+x}" ]]; then
_sv="${_data_ref[$field]//$'\n'/}"
_sv="${_sv//\'/\'\\\'\'}"
printf "%s='%s'\n" "$field" "$_sv"
fi
done
} > "$tmp_file"
# Set permissions before mv so there's no window of insecure perms
chmod 600 "$tmp_file" 2>/dev/null || true
# mv fails across filesystems (/tmp → /opt), fall back to cp to same-dir temp then mv
if ! { mv "$tmp_file" "$file" 2>/dev/null || \
{ cp "$tmp_file" "${file}.tmp.$$" 2>/dev/null && \
mv "${file}.tmp.$$" "$file" 2>/dev/null && \
rm -f "$tmp_file" 2>/dev/null; }; }; then
rm -f "$tmp_file" "${file}.tmp.$$" 2>/dev/null
log_error "Failed to save profile: ${file}"
_spd_unlock
return 1
fi
_spd_unlock
}
save_profile() {
local name="$1"
local -n _prof_ref="$2"
_save_profile_data "$(_profile_path "$name")" _prof_ref || { log_error "Failed to save profile '${name}'"; return 1; }
log_debug "Profile '${name}' saved"
}
delete_profile() {
local name="$1"
local profile_file
profile_file=$(_profile_path "$name")
if [[ ! -f "$profile_file" ]]; then
log_error "Profile '${name}' not found"
return 1
fi
# Stop tunnel if running
if is_tunnel_running "$name"; then
log_info "Stopping running tunnel '${name}'..."
stop_tunnel "$name" || log_warn "Could not stop tunnel '${name}'"
fi
rm -f "$profile_file" 2>/dev/null || true
rm -f "${PID_DIR}/${name}.pid" 2>/dev/null || true
rm -f "${BW_HISTORY_DIR}/${name}.dat" 2>/dev/null || true
rm -f "${RECONNECT_LOG_DIR}/${name}.log" 2>/dev/null || true
log_success "Profile '${name}' deleted"
}
list_profiles() {
[[ -d "$PROFILES_DIR" ]] || return 0
local f _base
for f in "${PROFILES_DIR}"/*.conf; do
[[ -f "$f" ]] || continue
# Extract profile name without forking basename
_base="${f##*/}" # strip directory
_base="${_base%.conf}" # strip .conf extension
printf '%s\n' "$_base"
done
}
get_profile_field() {
local name="$1" field="$2"
local -A _gpf
if load_profile "$name" _gpf; then
echo "${_gpf[$field]:-}"
else
return 1
fi
}
update_profile_field() {
local name="$1" field="$2" value="$3"
local -A _upf
load_profile "$name" _upf || return 1
_upf["$field"]="$value"
save_profile "$name" _upf
}
# ============================================================================
# SSH COMMAND BUILDERS
# ============================================================================
_validate_jump_hosts() {
local hosts="$1"
[[ -z "$hosts" ]] && return 0
if [[ ! "$hosts" =~ ^([a-zA-Z0-9._@:%-]+,)*[a-zA-Z0-9._@:%-]+$ ]]; then
log_error "Invalid JUMP_HOSTS format: ${hosts}"
return 1
fi
return 0
}
get_ssh_base_opts() {
local -n _opts_profile="$1"
local opts=()
opts+=(-o "ConnectTimeout=$(config_get SSH_CONNECT_TIMEOUT 10)")
opts+=(-o "ServerAliveInterval=$(config_get SSH_SERVER_ALIVE_INTERVAL 30)")
opts+=(-o "ServerAliveCountMax=$(config_get SSH_SERVER_ALIVE_COUNT_MAX 3)")
local strict
strict=$(config_get SSH_STRICT_HOST_KEY "yes")
opts+=(-o "StrictHostKeyChecking=${strict}")
opts+=(-N) # no remote command
opts+=(-T) # no pseudo-TTY
opts+=(-o "ExitOnForwardFailure=yes")
# Identity key
local key="${_opts_profile[IDENTITY_KEY]:-}"
if [[ -n "$key" ]] && [[ -f "$key" ]]; then opts+=(-i "$key"); fi
# Port
local _ssh_port="${_opts_profile[SSH_PORT]:-22}"
if ! validate_port "$_ssh_port"; then
log_error "Invalid SSH port: ${_ssh_port}"
return 1
fi
opts+=(-p "$_ssh_port")
# Extra SSH options (allowlist — only known-safe options accepted)
local extra="${_opts_profile[SSH_OPTIONS]:-}"
if [[ -n "$extra" ]]; then
local -a _extra_arr
read -ra _extra_arr <<< "$extra" || true
local -a _validated_opts=()
local _opt _opt_name _skip_next=false
for _opt in "${_extra_arr[@]}"; do
if [[ "$_skip_next" == true ]]; then
_skip_next=false
_opt_name="${_opt%%=*}"
if ! printf '%s' "$_opt_name" | grep -qiE '^(Compression|TCPKeepAlive|IPQoS|RekeyLimit|Ciphers|MACs|KexAlgorithms|HostKeyAlgorithms|PubkeyAcceptedAlgorithms|PubkeyAcceptedKeyTypes|ConnectionAttempts|ConnectTimeout|NumberOfPasswordPrompts|PreferredAuthentications|AddressFamily|BatchMode|CheckHostIP|HashKnownHosts|NoHostAuthenticationForLocalhost|PasswordAuthentication|StrictHostKeyChecking|UpdateHostKeys|VerifyHostKeyDNS|VisualHostKey|LogLevel|ServerAliveInterval|ServerAliveCountMax|GSSAPIAuthentication|GSSAPIDelegateCredentials)$'; then
log_error "SSH option not in allowlist: ${_opt}"
return 1
fi
_validated_opts+=(-o "$_opt")
continue
fi
if [[ "$_opt" == "-o" ]]; then
_skip_next=true; continue
fi
_opt_name="${_opt%%=*}"
if ! printf '%s' "$_opt_name" | grep -qiE '^(Compression|TCPKeepAlive|IPQoS|RekeyLimit|Ciphers|MACs|KexAlgorithms|HostKeyAlgorithms|PubkeyAcceptedAlgorithms|PubkeyAcceptedKeyTypes|ConnectionAttempts|ConnectTimeout|NumberOfPasswordPrompts|PreferredAuthentications|AddressFamily|BatchMode|CheckHostIP|HashKnownHosts|NoHostAuthenticationForLocalhost|PasswordAuthentication|StrictHostKeyChecking|UpdateHostKeys|VerifyHostKeyDNS|VisualHostKey|LogLevel|ServerAliveInterval|ServerAliveCountMax|GSSAPIAuthentication|GSSAPIDelegateCredentials)$'; then
log_error "SSH option not in allowlist: ${_opt}"
return 1
fi
_validated_opts+=(-o "$_opt")
done
if [[ "$_skip_next" == true ]]; then
log_error "SSH option -o without value"
return 1
fi
opts+=("${_validated_opts[@]}")
fi
# ControlMaster
if [[ "$(config_get CONTROLMASTER_ENABLED false)" == "true" ]]; then
opts+=(-o "ControlMaster=auto")
opts+=(-o "ControlPath=${SSH_CONTROL_DIR}/%C")
opts+=(-o "ControlPersist=$(config_get CONTROLMASTER_PERSIST 600)")
fi
printf '%s\n' "${opts[@]}"
}
# Wrap bare IPv6 addresses in brackets for SSH forwarding specs
_bracket_ipv6() {
local addr="$1"
if [[ "$addr" =~ : ]] && [[ "$addr" != \[* ]]; then
printf '[%s]' "$addr"
else
printf '%s' "$addr"
fi
}
_unbracket_ipv6() {
local addr="$1"
addr="${addr#\[}"
addr="${addr%\]}"
printf '%s' "$addr"
}
# ── Obfuscation-aware jump/proxy options builder ──
# Appends the correct jump/proxy flags based on OBFS_MODE.
# When OBFS_MODE=stunnel: uses ProxyCommand with openssl s_client.
# When no obfuscation: uses standard -J for jump hosts.
# Args: profile_nameref cmd_array_nameref
_build_obfs_proxy_or_jump() {
local -n _ob_prof="$1"
local -n _ob_cmd="$2"
local _ob_mode="${_ob_prof[OBFS_MODE]:-none}"
local _ob_port="${_ob_prof[OBFS_PORT]:-443}"
local _ob_jump="${_ob_prof[JUMP_HOSTS]:-}"
# Validate port is numeric to prevent injection in ProxyCommand
if [[ -n "$_ob_port" ]] && ! [[ "$_ob_port" =~ ^[0-9]+$ ]]; then
log_error "OBFS_PORT must be numeric"; return 1
fi
if [[ "$_ob_mode" == "stunnel" ]]; then
if [[ -n "$_ob_jump" ]]; then
# Jump host + stunnel: wrap the jump connection in TLS
_validate_jump_hosts "$_ob_jump" || return 1
# Parse first jump host: user@host:port
local _jh="${_ob_jump%%,*}"
local _juser="" _jhost="" _jport="22"
if [[ "$_jh" == *@* ]]; then
_juser="${_jh%%@*}"
_jh="${_jh#*@}"
fi
if [[ "$_jh" == *:* ]]; then
_jport="${_jh##*:}"
_jhost="${_jh%%:*}"
else
_jhost="$_jh"
fi
# Validate parsed jump host/port to prevent ProxyCommand injection
if ! [[ "$_jport" =~ ^[0-9]+$ ]]; then
log_error "Jump host port must be numeric"; return 1
fi
if ! [[ "$_jhost" =~ ^[a-zA-Z0-9._:-]+$ ]]; then
log_error "Jump host contains invalid characters"; return 1
fi
local _jdest="${_juser:+${_juser}@}${_jhost}"
# Nested ProxyCommand: connect to jump via stunnel, then -W to target
local _inner_pc="openssl s_client -connect ${_jhost}:${_ob_port} -quiet 2>/dev/null"
_ob_cmd+=(-o "ProxyCommand=ssh -o 'ProxyCommand=${_inner_pc}' -p ${_jport} -W %h:%p ${_jdest}")
else
# Direct tunnel + stunnel: openssl s_client as ProxyCommand
_ob_cmd+=(-o "ProxyCommand=openssl s_client -connect %h:${_ob_port} -quiet 2>/dev/null")
fi
else
# No obfuscation: standard -J for jump hosts
if [[ -n "$_ob_jump" ]]; then
_validate_jump_hosts "$_ob_jump" || return 1
_ob_cmd+=(-J "$_ob_jump")
fi
fi
return 0
}
build_socks5_cmd() {
local -n _s5="$1"
local cmd=() base_opts _base_output
_base_output=$(get_ssh_base_opts _s5) || return 1
mapfile -t base_opts <<< "$_base_output"
cmd+=(ssh "${base_opts[@]}")
local bind
bind=$(_bracket_ipv6 "${_s5[LOCAL_BIND_ADDR]:-127.0.0.1}")
local port="${_s5[LOCAL_PORT]:-1080}"
cmd+=(-D "${bind}:${port}")
_build_obfs_proxy_or_jump _s5 cmd || return 1
local user="${_s5[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}"
local _ssh_dest
_ssh_dest=$(_unbracket_ipv6 "${_s5[SSH_HOST]}")
cmd+=("${user}@${_ssh_dest}")
printf '%s\n' "${cmd[@]}"
}
build_local_forward_cmd() {
local -n _lf="$1"
local cmd=() base_opts _base_output
_base_output=$(get_ssh_base_opts _lf) || return 1
mapfile -t base_opts <<< "$_base_output"
cmd+=(ssh "${base_opts[@]}")
local bind rhost
bind=$(_bracket_ipv6 "${_lf[LOCAL_BIND_ADDR]:-127.0.0.1}")
rhost=$(_bracket_ipv6 "${_lf[REMOTE_HOST]}")
cmd+=(-L "${bind}:${_lf[LOCAL_PORT]}:${rhost}:${_lf[REMOTE_PORT]}")
_build_obfs_proxy_or_jump _lf cmd || return 1
local user="${_lf[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}"
local _ssh_dest
_ssh_dest=$(_unbracket_ipv6 "${_lf[SSH_HOST]}")
cmd+=("${user}@${_ssh_dest}")
printf '%s\n' "${cmd[@]}"
}
build_remote_forward_cmd() {
local -n _rf="$1"
local cmd=() base_opts _base_output
_base_output=$(get_ssh_base_opts _rf) || return 1
mapfile -t base_opts <<< "$_base_output"
cmd+=(ssh "${base_opts[@]}")
local rhost rbind
rhost=$(_bracket_ipv6 "${_rf[REMOTE_HOST]:-localhost}")
rbind="${_rf[LOCAL_BIND_ADDR]:-}"
if [[ -n "$rbind" ]]; then
rbind=$(_bracket_ipv6 "$rbind")
cmd+=(-R "${rbind}:${_rf[REMOTE_PORT]}:${rhost}:${_rf[LOCAL_PORT]}")
else
cmd+=(-R "${_rf[REMOTE_PORT]}:${rhost}:${_rf[LOCAL_PORT]}")
fi
_build_obfs_proxy_or_jump _rf cmd || return 1
local user="${_rf[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}"
local _ssh_dest
_ssh_dest=$(_unbracket_ipv6 "${_rf[SSH_HOST]}")
cmd+=("${user}@${_ssh_dest}")
printf '%s\n' "${cmd[@]}"
}
build_tunnel_cmd() {
local name="$1"
local -A _bt
load_profile "$name" _bt || return 1
case "${_bt[TUNNEL_TYPE]:-socks5}" in
socks5) build_socks5_cmd _bt ;;
local) build_local_forward_cmd _bt ;;
remote) build_remote_forward_cmd _bt ;;
jump) # Legacy: dispatch based on whether remote target is set
if [[ -n "${_bt[REMOTE_HOST]:-}" ]] && [[ -n "${_bt[REMOTE_PORT]:-}" ]]; then
build_local_forward_cmd _bt
else
build_socks5_cmd _bt
fi ;;
*)
log_error "Unknown tunnel type: ${_bt[TUNNEL_TYPE]}"
return 1 ;;
esac
}
# ============================================================================
# TUNNEL LIFECYCLE
# ============================================================================
_pid_file() { echo "${PID_DIR}/${1}.pid"; }
_log_file() { echo "${LOG_DIR}/${1}.log"; }
is_tunnel_running() {
local name="$1"
local pid_file="${PID_DIR}/${name}.pid"
[[ -f "$pid_file" ]] || return 1
local pid=""
read -r pid < "$pid_file" 2>/dev/null || true
if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
return 1
fi
return 0
}
_clean_stale_pid() {
local name="$1"
local pid_file="${PID_DIR}/${name}.pid"
[[ -f "$pid_file" ]] || return 0
local pid=""
read -r pid < "$pid_file" 2>/dev/null || true
if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
rm -f "$pid_file" "${pid_file}.autossh" "${PID_DIR}/${name}.stunnel" \
"${PID_DIR}/${name}.started" "${PID_DIR}/${name}.askpass" "${PID_DIR}/${name}.pass" 2>/dev/null
fi
return 0
}
get_tunnel_pid() {
local pid_file="${PID_DIR}/${1}.pid"
if [[ -f "$pid_file" ]]; then
local _pid=""
read -r _pid < "$pid_file" 2>/dev/null || true
printf '%s' "$_pid"
fi
return 0
}
_record_reconnect() {
local name="$1" reason="${2:-unknown}"
printf "%s|%s\n" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$reason" \
>> "${RECONNECT_LOG_DIR}/${name}.log" 2>/dev/null || true
_notify_reconnect "$name" "$reason"
return 0
}
start_tunnel() {
local name="$1"
# Per-profile lock to prevent concurrent start/stop races
local _st_lock_fd="" _st_lock_dir=""
_st_unlock() {
if [[ -n "${_st_lock_fd:-}" ]]; then exec {_st_lock_fd}>&- 2>/dev/null || true; fi
if [[ -n "${_st_lock_dir:-}" ]]; then
rm -f "${_st_lock_dir}/pid" 2>/dev/null || true
rmdir "${_st_lock_dir}" 2>/dev/null || true
_st_lock_dir=""
fi
rm -f "${PID_DIR}/${name}.lock" 2>/dev/null || true
}
if command -v flock &>/dev/null; then
exec {_st_lock_fd}>"${PID_DIR}/${name}.lock" 2>/dev/null || { log_error "Could not open lock file for '${name}'"; return 1; }
flock -w 10 "$_st_lock_fd" 2>/dev/null || { log_error "Could not acquire lock for '${name}'"; _st_unlock; return 1; }
else
_st_lock_dir="${PID_DIR}/${name}.lck"
local _st_try=0
while ! mkdir "$_st_lock_dir" 2>/dev/null; do
local _st_stale_pid=""
_st_stale_pid=$(cat "${_st_lock_dir}/pid" 2>/dev/null) || true
if [[ -n "$_st_stale_pid" ]] && ! kill -0 "$_st_stale_pid" 2>/dev/null; then
rm -f "${_st_lock_dir}/pid" 2>/dev/null || true
rmdir "$_st_lock_dir" 2>/dev/null || true
continue
fi
if (( ++_st_try >= 20 )); then log_error "Could not acquire lock for '${name}'"; _st_lock_dir=""; return 1; fi
sleep 0.5
done
printf '%s' "$$" > "${_st_lock_dir}/pid" 2>/dev/null || true
fi
# Rotate logs if needed
rotate_logs 2>/dev/null || true
local profile_file
profile_file=$(_profile_path "$name")
if [[ ! -f "$profile_file" ]]; then
log_error "Profile '${name}' not found"
_st_unlock; return 1
fi
if is_tunnel_running "$name"; then
log_warn "Tunnel '${name}' is already running (PID: $(get_tunnel_pid "$name"))"
_st_unlock; return 2
fi
local -A _sp
load_profile "$name" _sp || { _st_unlock; return 1; }
local tunnel_type="${_sp[TUNNEL_TYPE]:-socks5}"
local ssh_host="${_sp[SSH_HOST]}"
local local_port="${_sp[LOCAL_PORT]:-}"
local bind_addr="${_sp[LOCAL_BIND_ADDR]:-127.0.0.1}"
# Force 127.0.0.1 binding when inbound TLS is active (stunnel wraps the port)
if [[ -n "${_sp[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_sp[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then
if [[ "$bind_addr" != "127.0.0.1" ]]; then
log_info "Inbound TLS active — forcing bind to 127.0.0.1 (stunnel handles external access)"
bind_addr="127.0.0.1"
_sp[LOCAL_BIND_ADDR]="127.0.0.1"
fi
fi
# Validate port numbers
if [[ -n "$local_port" ]] && ! validate_port "$local_port"; then
log_error "Invalid LOCAL_PORT '${local_port}' in profile '${name}'"
_st_unlock; return 1
fi
if [[ -n "${_sp[REMOTE_PORT]:-}" ]] && ! validate_port "${_sp[REMOTE_PORT]}"; then
log_error "Invalid REMOTE_PORT '${_sp[REMOTE_PORT]}' in profile '${name}'"
_st_unlock; return 1
fi
if [[ -z "$ssh_host" ]]; then
log_error "SSH host not configured for profile '${name}'"
_st_unlock; return 1
fi
# Validate openssl is available for TLS obfuscation
if [[ "${_sp[OBFS_MODE]:-none}" == "stunnel" ]]; then
if ! command -v openssl &>/dev/null; then
log_error "openssl required for TLS obfuscation but not found"
_st_unlock; return 1
fi
log_debug "TLS obfuscation enabled (port ${_sp[OBFS_PORT]:-443})"
fi
# Validate required fields for forward tunnels
if [[ "$tunnel_type" == "local" ]]; then
if [[ -z "${_sp[REMOTE_HOST]:-}" ]] || [[ -z "${_sp[REMOTE_PORT]:-}" ]]; then
log_error "Local forward requires REMOTE_HOST and REMOTE_PORT"
_st_unlock; return 1
fi
if [[ -z "${_sp[LOCAL_PORT]:-}" ]]; then
log_error "Local forward requires LOCAL_PORT"
_st_unlock; return 1
fi
elif [[ "$tunnel_type" == "remote" ]]; then
if [[ -z "${_sp[REMOTE_PORT]:-}" ]] || [[ -z "${_sp[LOCAL_PORT]:-}" ]]; then
log_error "Remote forward requires REMOTE_PORT and LOCAL_PORT"
_st_unlock; return 1
fi
fi
# Check port collision (non-remote tunnels)
if [[ "$tunnel_type" != "remote" ]] && [[ -n "$local_port" ]]; then
if is_port_in_use "$local_port" "$bind_addr"; then
log_error "Port ${local_port} is already in use"
_st_unlock; return 1
fi
fi
# Build SSH command
local -a ssh_cmd
local _build_output
_build_output=$(build_tunnel_cmd "$name") || { log_error "Failed to build SSH command for '${name}'"; _st_unlock; return 1; }
mapfile -t ssh_cmd <<< "$_build_output"
if [[ ${#ssh_cmd[@]} -eq 0 ]]; then
log_error "Failed to build SSH command for '${name}'"
_st_unlock; return 1
fi
local tunnel_log tunnel_pid_file
tunnel_log=$(_log_file "$name")
tunnel_pid_file=$(_pid_file "$name")
log_info "Starting tunnel '${name}' (${tunnel_type})..."
log_debug "Command: ${ssh_cmd[*]}"
# Password-based auth via sshpass or SSH_ASKPASS
local _st_password="${_sp[SSH_PASSWORD]:-}"
local _st_use_sshpass=false
local _st_use_askpass=false
local _st_saved_display="${DISPLAY:-}"
local _st_had_display=false
if [[ -n "${DISPLAY+x}" ]]; then _st_had_display=true; fi
# Auth cleanup helper — unset env vars, restore DISPLAY, remove askpass files
_st_cleanup_auth() {
if [[ "${_st_use_sshpass:-}" == true ]]; then unset SSHPASS 2>/dev/null || true; fi
if [[ "${_st_use_askpass:-}" == true ]]; then
unset SSH_ASKPASS SSH_ASKPASS_REQUIRE 2>/dev/null || true
if [[ "${_st_had_display:-}" == true ]]; then
export DISPLAY="$_st_saved_display"
else
unset DISPLAY 2>/dev/null || true
fi
rm -f "${PID_DIR}/${name}.askpass" "${PID_DIR}/${name}.pass" 2>/dev/null || true
fi
}
if [[ -n "$_st_password" ]]; then
if [[ -n "${_sp[JUMP_HOSTS]:-}" ]]; then
# Jump host tunnels need multiple password prompts.
# sshpass only handles one, so use SSH_ASKPASS instead.
local _askpass_file="${PID_DIR}/${name}.askpass"
local _passfile="${PID_DIR}/${name}.pass"
printf '%s\n' "$_st_password" > "$_passfile"
chmod 600 "$_passfile"
printf '#!/bin/bash\ncat "%s"\n' "$_passfile" > "$_askpass_file"
chmod 700 "$_askpass_file"
export DISPLAY="${DISPLAY:-:0}"
export SSH_ASKPASS="$_askpass_file"
export SSH_ASKPASS_REQUIRE="force"
_st_use_askpass=true
else
if ! command -v sshpass &>/dev/null; then
log_info "Installing sshpass for password authentication..."
if [[ -n "${PKG_UPDATE:-}" ]]; then ${PKG_UPDATE} &>/dev/null || true; fi
install_package "sshpass" || log_warn "Failed to install sshpass"
fi
if command -v sshpass &>/dev/null; then
_st_use_sshpass=true
export SSHPASS="$_st_password"
else
log_warn "sshpass unavailable — SSH will prompt for password interactively"
fi
fi
fi
local use_autossh="${_sp[AUTOSSH_ENABLED]:-$(config_get AUTOSSH_ENABLED true)}"
# autossh cannot handle -J (ProxyJump) — it mangles the argument parsing.
# Fall back to plain SSH for jump host tunnels.
if [[ -n "${_sp[JUMP_HOSTS]:-}" ]] && [[ "$use_autossh" == "true" ]]; then
log_debug "Jump host tunnel — skipping autossh (incompatible with -J)"
use_autossh="false"
fi
if [[ "$use_autossh" == "true" ]] && command -v autossh &>/dev/null; then
# ── AutoSSH mode ──
local monitor_port="${_sp[AUTOSSH_MONITOR_PORT]:-$(config_get AUTOSSH_MONITOR_PORT 0)}"
export AUTOSSH_PIDFILE="$tunnel_pid_file"
export AUTOSSH_LOGFILE="$tunnel_log"
export AUTOSSH_POLL="$(config_get AUTOSSH_POLL 30)"
export AUTOSSH_GATETIME="$(config_get AUTOSSH_GATETIME 30)"
export AUTOSSH_FIRST_POLL="$(config_get AUTOSSH_FIRST_POLL 30)"
export AUTOSSH_LOGLEVEL="$(config_get AUTOSSH_LOG_LEVEL 1)"
ssh_cmd[0]="autossh"
local -a autossh_cmd=("${ssh_cmd[0]}" "-M" "$monitor_port" "${ssh_cmd[@]:1}")
if [[ "$_st_use_sshpass" == true ]]; then
local -a _sshpass_autossh=(sshpass -e "${autossh_cmd[@]}")
"${_sshpass_autossh[@]}" >> "$tunnel_log" 2>&1 &
else
"${autossh_cmd[@]}" >> "$tunnel_log" 2>&1 &
fi
local bg_pid=$!
disown "$bg_pid" 2>/dev/null || true
# Always record autossh parent PID for reliable kill
printf '%s\n' "$bg_pid" > "${tunnel_pid_file}.autossh" 2>/dev/null || true
# Wait for autossh to write its PID (AUTOSSH_PIDFILE)
local _as_wait
for _as_wait in 1 2 3; do
if [[ -f "$tunnel_pid_file" ]]; then break; fi
sleep 1
done
# Fallback: if autossh didn't write PID, use background job PID
if [[ ! -f "$tunnel_pid_file" ]]; then
local _pid_tmp
_pid_tmp=$(mktemp "${tunnel_pid_file}.XXXXXX") || {
log_error "Cannot create PID temp file"
unset AUTOSSH_PIDFILE AUTOSSH_LOGFILE AUTOSSH_POLL AUTOSSH_GATETIME AUTOSSH_FIRST_POLL AUTOSSH_LOGLEVEL
_st_cleanup_auth; _st_unlock; return 1
}
printf '%s\n' "$bg_pid" > "$_pid_tmp" && mv -f "$_pid_tmp" "$tunnel_pid_file" || {
rm -f "$_pid_tmp" 2>/dev/null
log_error "Failed to write PID file for '${name}'"
unset AUTOSSH_PIDFILE AUTOSSH_LOGFILE AUTOSSH_POLL AUTOSSH_GATETIME AUTOSSH_FIRST_POLL AUTOSSH_LOGLEVEL
_st_cleanup_auth; _st_unlock; return 1
}
fi
unset AUTOSSH_PIDFILE AUTOSSH_LOGFILE AUTOSSH_POLL
unset AUTOSSH_GATETIME AUTOSSH_FIRST_POLL AUTOSSH_LOGLEVEL
else
# ── Plain SSH mode ──
if [[ "$_st_use_sshpass" == true ]] || [[ "$_st_use_askpass" == true ]]; then
# sshpass/askpass and ssh -f are incompatible (-f breaks pty).
# Background the whole command ourselves instead.
if [[ "$_st_use_sshpass" == true ]]; then
sshpass -e "${ssh_cmd[@]}" >> "$tunnel_log" 2>&1 &
else
# </dev/null ensures SSH has no terminal on stdin
"${ssh_cmd[@]}" </dev/null >> "$tunnel_log" 2>&1 &
fi
local bg_pid=$!
disown "$bg_pid" 2>/dev/null || true
# Wait for SSH to authenticate and establish the tunnel
sleep 3
if ! kill -0 "$bg_pid" 2>/dev/null; then
log_error "SSH connection failed for '${name}'"
_st_cleanup_auth; _st_unlock; return 1
fi
else
# Use -f so SSH authenticates in foreground (password prompt works)
# then forks to background after successful auth
local _ssh_rc=0
"${ssh_cmd[@]}" -f >> "$tunnel_log" 2>&1 </dev/tty || _ssh_rc=$?
if (( _ssh_rc != 0 )); then
log_error "SSH connection failed for '${name}'"
_st_unlock; return 1
fi
# Jump tunnels with multi-hop take longer to bind the listening port
local _max_tries=3
if [[ -n "${_sp[JUMP_HOSTS]:-}" ]]; then
_max_tries=8
sleep 2
else
sleep 1
fi
local bg_pid="" _try
for (( _try=1; _try<=_max_tries; _try++ )); do
# Try lsof on the local port first (works for socks5 + local forward)
if [[ -n "$local_port" ]] && [[ -z "$bg_pid" ]]; then
bg_pid=$(lsof -ti:"${local_port}" -sTCP:LISTEN 2>/dev/null | head -1) || true
fi
# Fallback: search for the SSH process by command line
if [[ -z "$bg_pid" ]]; then
bg_pid=$(pgrep -n -f "ssh.*-[DJ].*${ssh_host}" 2>/dev/null) || true
fi
if [[ -z "$bg_pid" ]]; then
bg_pid=$(pgrep -n -f "ssh.*${ssh_host}" 2>/dev/null) || true
fi
[[ -n "$bg_pid" ]] && break
sleep 1
done
if [[ -z "$bg_pid" ]]; then
log_error "Could not find SSH process for '${name}'"
_st_cleanup_auth; _st_unlock; return 1
fi
fi
local _pid_tmp
_pid_tmp=$(mktemp "${tunnel_pid_file}.XXXXXX") || { log_error "Cannot create PID temp file"; _st_cleanup_auth; _st_unlock; return 1; }
printf '%s\n' "$bg_pid" > "$_pid_tmp" && mv -f "$_pid_tmp" "$tunnel_pid_file" || {
rm -f "$_pid_tmp" 2>/dev/null
log_error "Failed to write PID file for '${name}'"
_st_cleanup_auth; _st_unlock; return 1
}
fi
# Clean up auth env vars + restore DISPLAY (keep askpass files for autossh reconnection)
if [[ "$_st_use_sshpass" == true ]]; then unset SSHPASS 2>/dev/null || true; fi
if [[ "$_st_use_askpass" == true ]]; then
unset SSH_ASKPASS SSH_ASKPASS_REQUIRE 2>/dev/null || true
if [[ "$_st_had_display" == true ]]; then
export DISPLAY="$_st_saved_display"
else
unset DISPLAY 2>/dev/null || true
fi
fi
sleep 1
if is_tunnel_running "$name"; then
local pid
pid=$(get_tunnel_pid "$name")
# Write startup timestamp for reliable uptime tracking
date +%s > "${PID_DIR}/${name}.started" 2>/dev/null || true
log_success "Tunnel '${name}' started (PID: ${pid})"
log_file "info" "Tunnel '${name}' started (PID: ${pid}, type: ${tunnel_type})" || true
_notify_tunnel_start "$name" "$tunnel_type" "$pid" || true
# Enable security features if configured
if [[ "${_sp[DNS_LEAK_PROTECTION]:-}" == "true" ]]; then
log_info "Enabling DNS leak protection..."
if ! enable_dns_leak_protection; then
log_warn "DNS leak protection FAILED — tunnel running WITHOUT DNS protection"
fi
fi
if [[ "${_sp[KILL_SWITCH]:-}" == "true" ]]; then
log_info "Enabling kill switch..."
if ! enable_kill_switch "$name"; then
log_warn "Kill switch FAILED — tunnel running WITHOUT kill switch"
fi
fi
# Start local stunnel for inbound TLS+PSK if configured
if [[ -n "${_sp[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_sp[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then
log_info "Starting inbound TLS wrapper (stunnel + PSK)..."
if _obfs_start_local_stunnel "$name" _sp; then
_obfs_show_client_config "$name" _sp
else
log_warn "Inbound TLS wrapper failed — tunnel running WITHOUT inbound protection"
fi
fi
_st_unlock; return 0
else
log_error "Tunnel '${name}' failed to start"
log_info "Check logs: ${tunnel_log}"
_notify_tunnel_fail "$name" || true
_st_cleanup_auth
rm -f "$tunnel_pid_file" 2>/dev/null || true
_st_unlock; return 1
fi
}
stop_tunnel() {
local name="$1"
# Per-profile lock to prevent concurrent start/stop races
local _stp_lock_fd="" _stp_lock_dir=""
_stp_unlock() {
if [[ -n "${_stp_lock_fd:-}" ]]; then exec {_stp_lock_fd}>&- 2>/dev/null || true; fi
if [[ -n "${_stp_lock_dir:-}" ]]; then
rm -f "${_stp_lock_dir}/pid" 2>/dev/null || true
rmdir "${_stp_lock_dir}" 2>/dev/null || true
_stp_lock_dir=""
fi
rm -f "${PID_DIR}/${name}.lock" 2>/dev/null || true
}
if command -v flock &>/dev/null; then
exec {_stp_lock_fd}>"${PID_DIR}/${name}.lock" 2>/dev/null || { log_error "Could not open lock file for '${name}'"; return 1; }
flock -w 10 "$_stp_lock_fd" 2>/dev/null || { log_error "Could not acquire lock for '${name}'"; _stp_unlock; return 1; }
else
_stp_lock_dir="${PID_DIR}/${name}.lck"
local _stp_try=0
while ! mkdir "$_stp_lock_dir" 2>/dev/null; do
local _stp_stale_pid=""
_stp_stale_pid=$(cat "${_stp_lock_dir}/pid" 2>/dev/null) || true
if [[ -n "$_stp_stale_pid" ]] && ! kill -0 "$_stp_stale_pid" 2>/dev/null; then
rm -f "${_stp_lock_dir}/pid" 2>/dev/null || true
rmdir "$_stp_lock_dir" 2>/dev/null || true
continue
fi
if (( ++_stp_try >= 20 )); then log_error "Could not acquire lock for '${name}'"; return 1; fi
sleep 0.5
done
printf '%s' "$$" > "${_stp_lock_dir}/pid" 2>/dev/null || true
fi
if ! is_tunnel_running "$name"; then
log_warn "Tunnel '${name}' is not running"
# Still clean up stale PID and security features
_clean_stale_pid "$name"
local -A _stp_stale
if load_profile "$name" _stp_stale 2>/dev/null; then
if [[ "${_stp_stale[KILL_SWITCH]:-}" == "true" ]]; then
disable_kill_switch "$name" || log_warn "Kill switch disable failed for stale tunnel"
fi
if [[ "${_stp_stale[DNS_LEAK_PROTECTION]:-}" == "true" ]]; then
disable_dns_leak_protection || log_warn "DNS leak protection disable failed for stale tunnel"
fi
fi
_stp_unlock; return 0
fi
local tunnel_pid tunnel_pid_file
tunnel_pid=$(get_tunnel_pid "$name")
tunnel_pid_file=$(_pid_file "$name")
log_info "Stopping tunnel '${name}' (PID: ${tunnel_pid})..."
# Load profile for security cleanup
local -A _stp
load_profile "$name" _stp 2>/dev/null || true
if [[ "${_stp[KILL_SWITCH]:-}" == "true" ]]; then
log_info "Disabling kill switch..."
disable_kill_switch "$name" || log_warn "Kill switch disable failed"
fi
if [[ "${_stp[DNS_LEAK_PROTECTION]:-}" == "true" ]]; then
log_info "Disabling DNS leak protection..."
disable_dns_leak_protection || log_warn "DNS leak protection disable failed"
fi
# Stop local stunnel (inbound TLS+PSK) if running
_obfs_stop_local_stunnel "$name" || true
# Kill autossh parent first (if present) — it handles SSH child cleanup
local _autossh_parent_file="${tunnel_pid_file}.autossh"
if [[ -f "$_autossh_parent_file" ]]; then
local _as_parent_pid=""
read -r _as_parent_pid < "$_autossh_parent_file" 2>/dev/null || true
if [[ -n "$_as_parent_pid" ]] && kill -0 "$_as_parent_pid" 2>/dev/null; then
kill "$_as_parent_pid" 2>/dev/null || true
fi
rm -f "$_autossh_parent_file" 2>/dev/null || true
fi
# Graceful SIGTERM → wait → SIGKILL
kill "$tunnel_pid" 2>/dev/null || true
local waited=0
while (( waited < 5 )) && kill -0 "$tunnel_pid" 2>/dev/null; do
sleep 1; ((++waited))
done
if kill -0 "$tunnel_pid" 2>/dev/null; then
log_warn "Force killing tunnel '${name}'..."
kill -9 "$tunnel_pid" 2>/dev/null || true
sleep 1
fi
# Clean up SSH control sockets (stale sockets from %C hash naming)
# Use timeout to prevent hanging if remote host is unreachable
find "${SSH_CONTROL_DIR}" -maxdepth 1 -type s ! -name '.' -exec \
sh -c 'timeout 3 ssh -O check -o "ControlPath=$1" dummy 2>/dev/null || rm -f "$1"' _ {} \; 2>/dev/null || true
if ! kill -0 "$tunnel_pid" 2>/dev/null; then
rm -f "$tunnel_pid_file" "${tunnel_pid_file}.autossh" "${PID_DIR}/${name}.stunnel" \
"${PID_DIR}/${name}.started" "${PID_DIR}/${name}.askpass" "${PID_DIR}/${name}.pass" 2>/dev/null || true
unset '_SSH_PID_CACHE[$name]' 2>/dev/null || true
log_success "Tunnel '${name}' stopped"
log_file "info" "Tunnel '${name}' stopped" || true
_notify_tunnel_stop "$name" || true
_stp_unlock; return 0
else
log_error "Failed to stop tunnel '${name}'"
_stp_unlock; return 1
fi
}
restart_tunnel() {
local name="$1"
# Hold a restart lock across the entire stop+start sequence
# to prevent another process from starting during the gap
local _rt_lock_fd="" _rt_lock_dir=""
_rt_unlock() {
if [[ -n "${_rt_lock_fd:-}" ]]; then exec {_rt_lock_fd}>&- 2>/dev/null || true; fi
if [[ -n "${_rt_lock_dir:-}" ]]; then
rm -f "${_rt_lock_dir}/pid" 2>/dev/null || true
rmdir "${_rt_lock_dir}" 2>/dev/null || true
_rt_lock_dir=""
fi
rm -f "${PID_DIR}/${name}.restart.lock" 2>/dev/null || true
}
if command -v flock &>/dev/null; then
exec {_rt_lock_fd}>"${PID_DIR}/${name}.restart.lock" 2>/dev/null || { log_error "Could not open restart lock file for '${name}'"; return 1; }
flock -w 10 "$_rt_lock_fd" 2>/dev/null || { log_error "Could not acquire restart lock for '${name}'"; _rt_unlock; return 1; }
else
_rt_lock_dir="${PID_DIR}/${name}.restart.lck"
local _rt_try=0
while ! mkdir "$_rt_lock_dir" 2>/dev/null; do
local _rt_stale_pid=""
_rt_stale_pid=$(cat "${_rt_lock_dir}/pid" 2>/dev/null) || true
if [[ -n "$_rt_stale_pid" ]] && ! kill -0 "$_rt_stale_pid" 2>/dev/null; then
rm -f "${_rt_lock_dir}/pid" 2>/dev/null || true
rmdir "$_rt_lock_dir" 2>/dev/null || true
continue
fi
if (( ++_rt_try >= 20 )); then log_error "Could not acquire restart lock for '${name}'"; return 1; fi
sleep 0.5
done
printf '%s' "$$" > "${_rt_lock_dir}/pid" 2>/dev/null || true
fi
log_info "Restarting tunnel '${name}'..."
_record_reconnect "$name" "manual_restart"
stop_tunnel "$name" || true
sleep 1
start_tunnel "$name" || { log_error "Failed to restart tunnel '${name}'"; _rt_unlock; return 1; }
_rt_unlock
}
start_all_tunnels() {
local started=0 failed=0 name
while IFS= read -r name; do
[[ -z "$name" ]] && continue
local autostart
autostart=$(get_profile_field "$name" "AUTOSTART") || true
if [[ "$autostart" == "true" ]]; then
local _st_rc=0
start_tunnel "$name" || _st_rc=$?
if (( _st_rc == 0 )); then
((++started))
elif (( _st_rc == 2 )); then
: # already running — skip counting
else
((++failed))
fi
fi
done < <(list_profiles)
log_info "Started ${started} tunnel(s), ${failed} failed"
if (( failed > 0 )); then return 1; fi
return 0
}
stop_all_tunnels() {
local stopped=0 failed=0 name
while IFS= read -r name; do
[[ -z "$name" ]] && continue
if is_tunnel_running "$name"; then
if stop_tunnel "$name"; then
((++stopped))
else
((++failed))
fi
fi
done < <(list_profiles)
if (( failed > 0 )); then
log_info "Stopped ${stopped} tunnel(s), ${failed} failed"
return 1
else
log_info "Stopped ${stopped} tunnel(s)"
fi
return 0
}
# ── Traffic & uptime helpers ──
get_tunnel_uptime() {
local name="$1"
local pid
pid=$(get_tunnel_pid "$name")
[[ -z "$pid" ]] && { echo 0; return 0; }
kill -0 "$pid" 2>/dev/null || { echo 0; return 0; }
local _now
printf -v _now '%(%s)T' -1
# Primary: startup timestamp file (most reliable, survives across invocations)
local _started_file="${PID_DIR}/${name}.started"
if [[ -f "$_started_file" ]]; then
local _st_epoch=""
read -r _st_epoch < "$_started_file" 2>/dev/null || true
if [[ "$_st_epoch" =~ ^[0-9]+$ ]]; then
echo $(( _now - _st_epoch ))
return 0
fi
fi
# Fallback 1: /proc/PID/stat (Linux only, pure bash — no awk forks)
if [[ -f "/proc/${pid}/stat" ]]; then
local _stat_line=""
read -r _stat_line < "/proc/${pid}/stat" 2>/dev/null || true
# Field 22 is starttime (in clock ticks). Fields are space-delimited
# but field 2 (comm) can contain spaces in parens. Strip it first.
_stat_line="${_stat_line#*(}" # remove up to first (
_stat_line="${_stat_line#*) }" # remove up to ") "
# Now field 1=state, ... field 20=starttime (22 - 2 comm fields)
local -a _sf
read -ra _sf <<< "$_stat_line"
local start_ticks="${_sf[19]:-}"
local clk_tck
clk_tck=$(getconf CLK_TCK 2>/dev/null || echo 100)
[[ "$clk_tck" =~ ^[0-9]+$ ]] || clk_tck=100
local boot_time=""
local _bkey _bval
while read -r _bkey _bval; do
[[ "$_bkey" == "btime" ]] && { boot_time="$_bval"; break; }
done < /proc/stat 2>/dev/null
if [[ "$start_ticks" =~ ^[0-9]+$ ]] && [[ "$boot_time" =~ ^[0-9]+$ ]]; then
local start_sec=$(( boot_time + start_ticks / clk_tck ))
echo $(( _now - start_sec ))
return 0
fi
fi
# Fallback 2: PID file mtime
local pf="${PID_DIR}/${name}.pid"
if [[ -f "$pf" ]]; then
local ft
ft=$(stat -c %Y "$pf" 2>/dev/null || stat -f %m "$pf" 2>/dev/null) || true
if [[ -n "$ft" ]]; then
echo $(( _now - ft )); return 0
fi
fi
echo 0; return 0
}
# Cache: tunnel name → resolved SSH child PID (avoids re-walking tree every frame)
declare -gA _SSH_PID_CACHE=()
get_tunnel_traffic() {
local _name="$1"
local pid
pid=$(get_tunnel_pid "$_name")
if [[ -z "$pid" ]] || [[ ! -d "/proc/${pid}" ]]; then
unset '_SSH_PID_CACHE[$_name]' 2>/dev/null || true
echo "0 0"; return 0
fi
# Check cached SSH child PID first
local _target="${_SSH_PID_CACHE[$_name]:-}"
if [[ -n "$_target" ]] && [[ -d "/proc/${_target}" ]]; then
# Cache hit — verify it's still alive
:
else
# Walk process tree to find actual ssh process (autossh/sshpass are wrappers)
# Uses /proc/PID/task/*/children (no fork) instead of pgrep -P
_target="$pid"
local _try _comm=""
for _try in 1 2 3; do
_comm=""
read -r _comm < "/proc/${_target}/comm" 2>/dev/null || true
[[ "$_comm" == "ssh" ]] && break
# Read first child PID from /proc without forking pgrep
local _next=""
if [[ -f "/proc/${_target}/task/${_target}/children" ]]; then
read -r _next _ < "/proc/${_target}/task/${_target}/children" 2>/dev/null || true
fi
[[ -z "$_next" ]] && break
_target="$_next"
done
_SSH_PID_CACHE["$_name"]="$_target"
fi
if [[ ! -f "/proc/${_target}/io" ]]; then
echo "0 0"; return 0
fi
# Read rchar/wchar with a single while-read loop (no awk forks)
local _key _val rchar=0 wchar=0
while read -r _key _val; do
case "$_key" in
rchar:) rchar="$_val" ;;
wchar:) wchar="$_val"; break ;; # wchar comes after rchar, stop early
esac
done < "/proc/${_target}/io" 2>/dev/null
echo "${rchar} ${wchar}"
}
# Count ESTABLISHED connections on a port from ss data (pure bash, no grep fork)
# Usage: _count_port_conns PORT [ss_data_var]
_count_port_conns() {
local _cpc_port="$1" _cpc_data="${2:-${_DASH_SS_CACHE:-}}"
if [[ -z "$_cpc_data" ]]; then
_cpc_data=$(ss -tn 2>/dev/null) || true
fi
local _cpc_count=0 _cpc_line
while IFS= read -r _cpc_line; do
[[ "$_cpc_line" == *ESTAB* ]] || continue
[[ "$_cpc_line" == *":${_cpc_port} "* ]] || [[ "$_cpc_line" == *":${_cpc_port} "* ]] || continue
(( ++_cpc_count )) || true
done <<< "$_cpc_data"
echo "$_cpc_count"
}
get_tunnel_connections() {
local name="$1"
local -A _cc
load_profile "$name" _cc 2>/dev/null || { echo 0; return 0; }
local port="${_cc[LOCAL_PORT]:-}"
[[ -z "$port" ]] && { echo 0; return 0; }
[[ "$port" =~ ^[0-9]+$ ]] || { echo 0; return 0; }
# When inbound TLS is active, count only the stunnel port
local _olp="${_cc[OBFS_LOCAL_PORT]:-0}"
if [[ "$_olp" =~ ^[0-9]+$ ]] && (( _olp > 0 )); then
_count_port_conns "$_olp"
else
_count_port_conns "$port"
fi
}
# ============================================================================
# SECURITY FEATURES (Phase 4)
# ============================================================================
readonly _RESOLV_CONF="/etc/resolv.conf"
readonly _RESOLV_BACKUP="${BACKUP_DIR}/resolv.conf.bak"
readonly _IPTABLES_BACKUP_DIR="${BACKUP_DIR}/iptables"
readonly _TF_CHAIN="TUNNELFORGE"
# ── DNS Leak Protection ──
# Forces DNS through specified servers by rewriting /etc/resolv.conf
enable_dns_leak_protection() {
if [[ $EUID -ne 0 ]]; then
log_error "DNS leak protection requires root privileges"
return 1
fi
mkdir -p "$BACKUP_DIR" 2>/dev/null || true
# Backup current resolv.conf if not already backed up
if [[ ! -f "$_RESOLV_BACKUP" ]] && [[ -f "$_RESOLV_CONF" ]]; then
if ! cp "$_RESOLV_CONF" "$_RESOLV_BACKUP" 2>/dev/null; then
log_error "Failed to backup resolv.conf"
return 1
fi
log_debug "Backed up resolv.conf"
fi
# Remove immutable flag if set from previous run
if command -v chattr &>/dev/null; then
chattr -i "$_RESOLV_CONF" 2>/dev/null || true
fi
# Write new resolv.conf with secure DNS
local dns1 dns2 _dns_val
dns1=$(config_get DNS_SERVER_1 "1.1.1.1")
dns2=$(config_get DNS_SERVER_2 "1.0.0.1")
# Validate DNS server values are valid IPv4 or IPv6 addresses
local _dns_ip_re='^([0-9]{1,3}\.){3}[0-9]{1,3}$'
local _dns_ip6_re='^[0-9a-fA-F]*:[0-9a-fA-F:]*$'
for _dns_val in "$dns1" "$dns2"; do
if ! [[ "$_dns_val" =~ $_dns_ip_re ]] && ! [[ "$_dns_val" =~ $_dns_ip6_re ]]; then
log_error "Invalid DNS server address: ${_dns_val}"
return 1
fi
done
# Abort if resolv.conf is a symlink (systemd-resolved, etc.)
if [[ -L "$_RESOLV_CONF" ]]; then
log_error "resolv.conf is a symlink ($(readlink "$_RESOLV_CONF" 2>/dev/null || true)) — cannot safely rewrite; disable systemd-resolved first"
return 1
fi
# Atomic write via temp file + mv
local _resolv_tmp
_resolv_tmp=$(mktemp "${_RESOLV_CONF}.tf_tmp.XXXXXX") || { log_error "Failed to create temp file"; return 1; }
{
printf "# TunnelForge DNS Leak Protection — do not edit\n"
printf "# Original backed up to: %s\n" "$_RESOLV_BACKUP"
printf "nameserver %s\n" "$dns1"
printf "nameserver %s\n" "$dns2"
printf "options edns0\n"
} > "$_resolv_tmp" 2>/dev/null || {
log_error "Failed to write resolv.conf temp file"
rm -f "$_resolv_tmp" 2>/dev/null
return 1
}
if ! mv -f "$_resolv_tmp" "$_RESOLV_CONF" 2>/dev/null; then
log_error "Failed to install resolv.conf (mv failed)"
rm -f "$_resolv_tmp" 2>/dev/null
return 1
fi
# Make immutable to prevent overwriting by system services
if command -v chattr &>/dev/null; then
if ! chattr +i "$_RESOLV_CONF" 2>/dev/null; then
log_warn "Could not make resolv.conf immutable (chattr +i failed)"
fi
fi
log_success "DNS leak protection enabled (${dns1}, ${dns2})"
return 0
}
disable_dns_leak_protection() {
if [[ $EUID -ne 0 ]]; then
log_error "DNS leak protection requires root privileges"
return 1
fi
# Remove immutable flag
if command -v chattr &>/dev/null; then
chattr -i "$_RESOLV_CONF" 2>/dev/null || true
fi
# Restore backup (atomic: copy to temp then mv)
if [[ -f "$_RESOLV_BACKUP" ]]; then
local _restore_tmp
_restore_tmp=$(mktemp "${_RESOLV_CONF}.tf_restore.XXXXXX") || { log_error "Failed to create temp file"; return 1; }
if ! cp "$_RESOLV_BACKUP" "$_restore_tmp" 2>/dev/null; then
log_error "Failed to copy resolv.conf backup to temp file"
rm -f "$_restore_tmp" 2>/dev/null
return 1
fi
if ! mv -f "$_restore_tmp" "$_RESOLV_CONF" 2>/dev/null; then
log_error "Failed to restore resolv.conf (mv failed)"
rm -f "$_restore_tmp" 2>/dev/null
return 1
fi
rm -f "$_RESOLV_BACKUP" 2>/dev/null
log_success "DNS leak protection disabled (resolv.conf restored)"
else
log_warn "No resolv.conf backup found; writing sane defaults"
local _defaults_tmp
_defaults_tmp=$(mktemp "${_RESOLV_CONF}.tf_defaults.XXXXXX") || { log_error "Failed to create temp file"; return 1; }
if ! { printf "nameserver 8.8.8.8\n"; printf "nameserver 8.8.4.4\n"; } > "$_defaults_tmp" 2>/dev/null; then
log_error "Failed to write sane defaults to temp file"
rm -f "$_defaults_tmp" 2>/dev/null
return 1
fi
if ! mv -f "$_defaults_tmp" "$_RESOLV_CONF" 2>/dev/null; then
log_error "Failed to apply sane defaults (mv failed)"
rm -f "$_defaults_tmp" 2>/dev/null
return 1
fi
fi
return 0
}
is_dns_leak_protected() {
if [[ -f "$_RESOLV_CONF" ]] && grep -qF "TunnelForge DNS" "$_RESOLV_CONF" 2>/dev/null; then
return 0
fi
return 1
}
# ── Kill Switch (iptables firewall) ──
# Blocks all non-tunnel traffic to prevent data leaks if tunnel drops
enable_kill_switch() {
local name="$1"
if [[ $EUID -ne 0 ]]; then
log_error "Kill switch requires root privileges"
return 1
fi
if ! command -v iptables &>/dev/null; then
log_error "iptables is required for kill switch"
return 1
fi
local _has_ip6tables=false
if command -v ip6tables &>/dev/null; then _has_ip6tables=true; fi
local -A _ks_prof
load_profile "$name" _ks_prof 2>/dev/null || {
log_error "Cannot load profile '${name}' for kill switch"
return 1
}
local ssh_host="${_ks_prof[SSH_HOST]:-}"
local ssh_port="${_ks_prof[SSH_PORT]:-22}"
if [[ -z "$ssh_host" ]]; then
log_error "No SSH host configured for kill switch"
return 1
fi
# Resolve hostname to IPv4 and IPv6 (with fallback chain for portability)
local ssh_ip ssh_ip6=""
ssh_ip=$(getent ahostsv4 "$ssh_host" 2>/dev/null | awk '{print $1; exit}') || true
if [[ -z "$ssh_ip" ]]; then
ssh_ip=$(dig +short A "$ssh_host" 2>/dev/null | head -1) || true
fi
if [[ -z "$ssh_ip" ]]; then
ssh_ip=$(host "$ssh_host" 2>/dev/null | awk '/has address/{print $NF; exit}') || true
fi
if [[ -z "$ssh_ip" ]]; then
ssh_ip=$(nslookup "$ssh_host" 2>/dev/null \
| awk '/^Address/ && !/127\.0\.0/ && NR>2 {print $NF; exit}') || true
fi
if [[ -z "$ssh_ip" ]]; then
ssh_ip="$ssh_host" # Assume already an IP
fi
if ! validate_ip "$ssh_ip" && ! validate_ip6 "$ssh_ip"; then
log_error "Could not resolve SSH host '${ssh_host}' to a valid IP — kill switch aborted"
return 1
fi
if [[ "$_has_ip6tables" == true ]]; then
ssh_ip6=$(getent ahostsv6 "$ssh_host" 2>/dev/null | awk '{print $1; exit}') || true
fi
mkdir -p "$_IPTABLES_BACKUP_DIR" 2>/dev/null || true
# Create chain if it doesn't exist
iptables -N "$_TF_CHAIN" 2>/dev/null || true
if [[ "$_has_ip6tables" == true ]]; then
ip6tables -N "$_TF_CHAIN" 2>/dev/null || true
fi
# Check if chain already has rules (multi-tunnel support)
if iptables -S "$_TF_CHAIN" 2>/dev/null | grep -q -- "-j DROP"; then
# Chain active — remove old DROP, add this tunnel's SSH host, re-add DROP
iptables -D "$_TF_CHAIN" -j DROP 2>/dev/null || true
iptables -A "$_TF_CHAIN" -d "$ssh_ip" -p tcp --dport "$ssh_port" -m comment --comment "tf:${name}" -j ACCEPT 2>/dev/null || true
iptables -A "$_TF_CHAIN" -j DROP 2>/dev/null || true
if [[ "$_has_ip6tables" == true ]]; then
ip6tables -D "$_TF_CHAIN" -j DROP 2>/dev/null || true
if [[ -n "$ssh_ip6" ]]; then
ip6tables -A "$_TF_CHAIN" -d "$ssh_ip6" -p tcp --dport "$ssh_port" -m comment --comment "tf:${name}" -j ACCEPT 2>/dev/null || true
fi
ip6tables -A "$_TF_CHAIN" -j DROP 2>/dev/null || true
fi
else
# Fresh chain — build with all standard rules
iptables -F "$_TF_CHAIN" 2>/dev/null || true
# Allow loopback
iptables -A "$_TF_CHAIN" -o lo -j ACCEPT 2>/dev/null || true
# Allow established/related
iptables -A "$_TF_CHAIN" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true
# Allow SSH to tunnel server
iptables -A "$_TF_CHAIN" -d "$ssh_ip" -p tcp --dport "$ssh_port" -m comment --comment "tf:${name}" -j ACCEPT 2>/dev/null || true
# Allow local DNS
iptables -A "$_TF_CHAIN" -d 127.0.0.0/8 -p udp --dport 53 -j ACCEPT 2>/dev/null || true
iptables -A "$_TF_CHAIN" -d 127.0.0.0/8 -p tcp --dport 53 -j ACCEPT 2>/dev/null || true
# Allow configured DNS servers (for DNS leak protection compatibility)
local _dns1 _dns2
_dns1=$(config_get DNS_SERVER_1 "1.1.1.1")
_dns2=$(config_get DNS_SERVER_2 "1.0.0.1")
if validate_ip "$_dns1" || validate_ip6 "$_dns1"; then
iptables -A "$_TF_CHAIN" -d "$_dns1" -p udp --dport 53 -j ACCEPT 2>/dev/null || true
iptables -A "$_TF_CHAIN" -d "$_dns1" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true
fi
if validate_ip "$_dns2" || validate_ip6 "$_dns2"; then
iptables -A "$_TF_CHAIN" -d "$_dns2" -p udp --dport 53 -j ACCEPT 2>/dev/null || true
iptables -A "$_TF_CHAIN" -d "$_dns2" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true
fi
# Allow DHCP
iptables -A "$_TF_CHAIN" -p udp --dport 67:68 -j ACCEPT 2>/dev/null || true
# Drop everything else
iptables -A "$_TF_CHAIN" -j DROP 2>/dev/null || true
# IPv6 mirror
if [[ "$_has_ip6tables" == true ]]; then
ip6tables -F "$_TF_CHAIN" 2>/dev/null || true
ip6tables -A "$_TF_CHAIN" -o lo -j ACCEPT 2>/dev/null || true
ip6tables -A "$_TF_CHAIN" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true
if [[ -n "$ssh_ip6" ]]; then
ip6tables -A "$_TF_CHAIN" -d "$ssh_ip6" -p tcp --dport "$ssh_port" -m comment --comment "tf:${name}" -j ACCEPT 2>/dev/null || true
fi
ip6tables -A "$_TF_CHAIN" -d ::1 -p udp --dport 53 -j ACCEPT 2>/dev/null || true
ip6tables -A "$_TF_CHAIN" -d ::1 -p tcp --dport 53 -j ACCEPT 2>/dev/null || true
# Allow configured DNS servers if they are IPv6
local _dns_v
for _dns_v in "$_dns1" "$_dns2"; do
if [[ "$_dns_v" =~ : ]]; then
ip6tables -A "$_TF_CHAIN" -d "$_dns_v" -p udp --dport 53 -j ACCEPT 2>/dev/null || true
ip6tables -A "$_TF_CHAIN" -d "$_dns_v" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true
fi
done
ip6tables -A "$_TF_CHAIN" -p udp --dport 546:547 -j ACCEPT 2>/dev/null || true
ip6tables -A "$_TF_CHAIN" -p ipv6-icmp -j ACCEPT 2>/dev/null || true
ip6tables -A "$_TF_CHAIN" -j DROP 2>/dev/null || true
fi
fi
# Insert jump to chain (avoid duplicates)
if ! iptables -C OUTPUT -j "$_TF_CHAIN" 2>/dev/null; then
iptables -I OUTPUT 1 -j "$_TF_CHAIN" 2>/dev/null || true
fi
if ! iptables -C FORWARD -j "$_TF_CHAIN" 2>/dev/null; then
iptables -I FORWARD 1 -j "$_TF_CHAIN" 2>/dev/null || true
fi
if [[ "$_has_ip6tables" == true ]]; then
if ! ip6tables -C OUTPUT -j "$_TF_CHAIN" 2>/dev/null; then
ip6tables -I OUTPUT 1 -j "$_TF_CHAIN" 2>/dev/null || true
fi
if ! ip6tables -C FORWARD -j "$_TF_CHAIN" 2>/dev/null; then
ip6tables -I FORWARD 1 -j "$_TF_CHAIN" 2>/dev/null || true
fi
fi
log_success "Kill switch enabled for '${name}' (allow ${ssh_ip}:${ssh_port})"
return 0
}
disable_kill_switch() {
local name="$1"
if [[ $EUID -ne 0 ]]; then
log_error "Kill switch requires root privileges"
return 1
fi
if ! command -v iptables &>/dev/null; then
return 0
fi
local _has_ip6tables=false
if command -v ip6tables &>/dev/null; then _has_ip6tables=true; fi
# Remove only this tunnel's SSH ACCEPT rule (multi-tunnel safe)
# Load profile to get SSH host/port for exact rule match
local -A _dks_prof
load_profile "$name" _dks_prof 2>/dev/null || true
local _dks_host="${_dks_prof[SSH_HOST]:-}"
local _dks_port="${_dks_prof[SSH_PORT]:-22}"
local _dks_ip
_dks_ip=$(getent ahostsv4 "$_dks_host" 2>/dev/null | awk '{print $1; exit}') || true
if [[ -z "$_dks_ip" ]]; then
_dks_ip=$(dig +short A "$_dks_host" 2>/dev/null | head -1) || true
fi
if [[ -z "$_dks_ip" ]]; then
_dks_ip=$(host "$_dks_host" 2>/dev/null | awk '/has address/{print $NF; exit}') || true
fi
: "${_dks_ip:=$_dks_host}"
if [[ -n "$_dks_ip" ]]; then
iptables -D "$_TF_CHAIN" -d "$_dks_ip" -p tcp --dport "$_dks_port" -m comment --comment "tf:${name}" -j ACCEPT 2>/dev/null || true
fi
# IPv6: remove tunnel rule
if [[ "$_has_ip6tables" == true ]]; then
local _dks_ip6
_dks_ip6=$(getent ahostsv6 "$_dks_host" 2>/dev/null | awk '{print $1; exit}') || true
if [[ -n "$_dks_ip6" ]]; then
ip6tables -D "$_TF_CHAIN" -d "$_dks_ip6" -p tcp --dport "$_dks_port" -m comment --comment "tf:${name}" -j ACCEPT 2>/dev/null || true
fi
fi
# Fallback: find exact rule by comment using -S output and delete it
local _fb_rule
_fb_rule=$(iptables -S "$_TF_CHAIN" 2>/dev/null | grep -F "tf:${name}" | head -1) || true
if [[ -n "$_fb_rule" ]]; then
_fb_rule="${_fb_rule/-A $_TF_CHAIN/-D $_TF_CHAIN}"
_fb_rule="${_fb_rule//\"/}"
local -a _fb_arr
read -ra _fb_arr <<< "$_fb_rule"
iptables "${_fb_arr[@]}" 2>/dev/null || true
fi
# IPv6 fallback
if [[ "$_has_ip6tables" == true ]]; then
local _fb6_rule
_fb6_rule=$(ip6tables -S "$_TF_CHAIN" 2>/dev/null | grep -F "tf:${name}" | head -1) || true
if [[ -n "$_fb6_rule" ]]; then
_fb6_rule="${_fb6_rule/-A $_TF_CHAIN/-D $_TF_CHAIN}"
_fb6_rule="${_fb6_rule//\"/}"
local -a _fb6_arr
read -ra _fb6_arr <<< "$_fb6_rule"
ip6tables "${_fb6_arr[@]}" 2>/dev/null || true
fi
fi
# Check if any other tunnel rules remain
local _remaining
_remaining=$(iptables -S "$_TF_CHAIN" 2>/dev/null | grep -c 'tf:' || true)
: "${_remaining:=0}"
if (( _remaining == 0 )); then
# Last tunnel — tear down the entire chain
iptables -D OUTPUT -j "$_TF_CHAIN" 2>/dev/null || true
iptables -D FORWARD -j "$_TF_CHAIN" 2>/dev/null || true
iptables -F "$_TF_CHAIN" 2>/dev/null || true
iptables -X "$_TF_CHAIN" 2>/dev/null || true
if [[ "$_has_ip6tables" == true ]]; then
ip6tables -D OUTPUT -j "$_TF_CHAIN" 2>/dev/null || true
ip6tables -D FORWARD -j "$_TF_CHAIN" 2>/dev/null || true
ip6tables -F "$_TF_CHAIN" 2>/dev/null || true
ip6tables -X "$_TF_CHAIN" 2>/dev/null || true
fi
fi
# Clean up stale pristine backup files (no longer used — chain flush+delete is sufficient)
if (( _remaining == 0 )); then
rm -f "${_IPTABLES_BACKUP_DIR}/pristine.rules" 2>/dev/null
rm -f "${_IPTABLES_BACKUP_DIR}/pristine6.rules" 2>/dev/null
fi
log_success "Kill switch disabled for '${name}'"
return 0
}
is_kill_switch_active() {
command -v iptables &>/dev/null || return 1
if iptables -C OUTPUT -j "$_TF_CHAIN" 2>/dev/null; then
return 0
fi
return 1
}
# ── SSH Key Management ──
generate_ssh_key() {
local key_type="${1:-ed25519}"
local key_path="${2:-${HOME}/.ssh/id_${key_type}}"
local comment="${3:-tunnelforge@$(hostname 2>/dev/null || echo localhost)}"
if [[ -f "$key_path" ]]; then
log_warn "Key already exists: ${key_path}"
return 0
fi
local key_dir
key_dir=$(dirname "$key_path")
mkdir -p "$key_dir" 2>/dev/null || true
chmod 700 "$key_dir" 2>/dev/null || true
log_info "Generating ${key_type} SSH key..."
if ssh-keygen -t "$key_type" -f "$key_path" -C "$comment" -N "" 2>/dev/null; then
chmod 600 "$key_path" 2>/dev/null || true
chmod 644 "${key_path}.pub" 2>/dev/null || true
log_success "SSH key generated: ${key_path}"
log_info "Public key:"
printf " %s\n" "$(cat "${key_path}.pub" 2>/dev/null)"
return 0
fi
log_error "Failed to generate SSH key"
return 1
}
deploy_ssh_key() {
local name="$1"
local -A _dk_prof
load_profile "$name" _dk_prof 2>/dev/null || {
log_error "Cannot load profile '${name}'"
return 1
}
local ssh_host="${_dk_prof[SSH_HOST]:-}"
local ssh_port="${_dk_prof[SSH_PORT]:-22}"
local ssh_user="${_dk_prof[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}"
local key="${_dk_prof[IDENTITY_KEY]:-}"
if [[ -z "$ssh_host" ]]; then
log_error "No SSH host in profile '${name}'"
return 1
fi
# Find public key
local pub_key=""
if [[ -n "$key" ]] && [[ -f "${key}.pub" ]]; then
pub_key="${key}.pub"
elif [[ -f "${HOME}/.ssh/id_ed25519.pub" ]]; then
pub_key="${HOME}/.ssh/id_ed25519.pub"
elif [[ -f "${HOME}/.ssh/id_rsa.pub" ]]; then
pub_key="${HOME}/.ssh/id_rsa.pub"
else
log_error "No public key found to deploy"
return 1
fi
log_info "Deploying key to ${ssh_user}@${ssh_host}:${ssh_port}..."
local _dk_pass="${_dk_prof[SSH_PASSWORD]:-}"
local -a _dk_sshpass_prefix=()
if [[ -n "$_dk_pass" ]] && command -v sshpass &>/dev/null; then
_dk_sshpass_prefix=(env "SSHPASS=${_dk_pass}" sshpass -e)
fi
if command -v ssh-copy-id &>/dev/null; then
local priv_key="${pub_key%.pub}"
local -a _dk_opts=(-i "$priv_key" -p "$ssh_port" -o "StrictHostKeyChecking=accept-new")
if [[ -n "$key" ]] && [[ -f "$key" ]]; then _dk_opts+=(-o "IdentityFile=${key}"); fi
if "${_dk_sshpass_prefix[@]}" ssh-copy-id "${_dk_opts[@]}" "${ssh_user}@${ssh_host}" 2>/dev/null; then
log_success "Key deployed to ${ssh_user}@${ssh_host}"
return 0
fi
fi
# Manual fallback (always attempted if ssh-copy-id missing or failed)
local -a _dk_ssh_opts=(-p "$ssh_port" -o "StrictHostKeyChecking=accept-new")
if [[ -n "$key" ]] && [[ -f "$key" ]]; then _dk_ssh_opts+=(-o "IdentityFile=${key}"); fi
if "${_dk_sshpass_prefix[@]}" ssh "${_dk_ssh_opts[@]}" "${ssh_user}@${ssh_host}" \
'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys' \
< "$pub_key" 2>/dev/null; then
log_success "Key deployed to ${ssh_user}@${ssh_host}"
return 0
fi
log_error "Failed to deploy key"
return 1
}
check_key_permissions() {
local key_path="$1"
local issues=0
if [[ ! -f "$key_path" ]]; then
log_warn "Key not found: ${key_path}"
return 1
fi
local perms
perms=$(stat -c "%a" "$key_path" 2>/dev/null || stat -f "%Lp" "$key_path" 2>/dev/null) || true
if [[ -n "$perms" ]]; then
case "$perms" in
600|400) ;;
*) log_warn "Insecure permissions on ${key_path}: ${perms} (should be 600)"
((++issues)) ;;
esac
fi
local owner
owner=$(stat -c "%U" "$key_path" 2>/dev/null || stat -f "%Su" "$key_path" 2>/dev/null) || true
if [[ -n "$owner" ]] && [[ "$owner" != "$(whoami 2>/dev/null || echo root)" ]]; then
log_warn "Key ${key_path} owned by ${owner}"
((++issues))
fi
if (( issues == 0 )); then
log_debug "Key permissions OK: ${key_path}"
return 0
fi
return 1
}
# ── Verify SSH Host Fingerprint ──
verify_host_fingerprint() {
local host="$1" port="${2:-22}"
if [[ -z "$host" ]]; then
log_error "Usage: verify_host_fingerprint <host> [port]"
return 1
fi
# Validate hostname — reject option injection and shell metacharacters
if [[ "$host" == -* ]] || [[ "$host" =~ [^a-zA-Z0-9._:@%\[\]-] ]]; then
log_error "Invalid hostname: ${host}"
return 1
fi
printf "\n${BOLD}SSH Host Fingerprints for %s:%s${RESET}\n\n" "$host" "$port"
if ! command -v ssh-keyscan &>/dev/null; then
log_error "ssh-keyscan is required"
return 1
fi
local keys
keys=$(ssh-keyscan -p "$port" -- "$host" 2>/dev/null) || true
if [[ -z "$keys" ]]; then
log_error "Could not retrieve host keys from ${host}:${port}"
return 1
fi
while IFS= read -r _fline || [[ -n "$_fline" ]]; do
[[ -z "$_fline" ]] && continue
[[ "$_fline" == \#* ]] && continue
local _fp
_fp=$(echo "$_fline" | ssh-keygen -lf - 2>/dev/null) || true
if [[ -n "$_fp" ]]; then
printf " ${CYAN}${RESET} %s\n" "$_fp"
fi
done <<< "$keys"
printf "\n${DIM}Known hosts status:${RESET}\n"
if [[ -f "${HOME}/.ssh/known_hosts" ]]; then
local _kh_lookup="$host"
if [[ "$port" != "22" ]]; then _kh_lookup="[${host}]:${port}"; fi
if ssh-keygen -F "$_kh_lookup" -f "${HOME}/.ssh/known_hosts" &>/dev/null; then
printf " ${GREEN}${RESET} Host is in known_hosts\n"
else
printf " ${YELLOW}${RESET} Host is NOT in known_hosts\n"
fi
else
printf " ${YELLOW}${RESET} No known_hosts file found\n"
fi
printf "\n"
return 0
}
# ── Security Audit ──
security_audit() {
local score=100 issues=0 warnings=0
printf "\n${BOLD_CYAN}═══ TunnelForge Security Audit ═══${RESET}\n\n"
# 1. SSH key permissions
printf "${BOLD}[1/6] SSH Key Permissions${RESET}\n"
local _found_keys=false _key_f _key_penalty=0
for _key_f in "${HOME}/.ssh/"*; do
[[ -f "$_key_f" ]] || continue
[[ "$_key_f" == *.pub ]] && continue
[[ "$_key_f" == */known_hosts* ]] && continue
[[ "$_key_f" == */authorized_keys* ]] && continue
[[ "$_key_f" == */config ]] && continue
# Only check files that look like private keys (contain BEGIN marker)
head -1 "$_key_f" 2>/dev/null | grep -q "PRIVATE KEY" || continue
_found_keys=true
local _kp
_kp=$(stat -c "%a" "$_key_f" 2>/dev/null || stat -f "%Lp" "$_key_f" 2>/dev/null) || true
if [[ "$_kp" == "600" ]] || [[ "$_kp" == "400" ]]; then
printf " ${GREEN}${RESET} %s (%s) OK\n" "$(basename "$_key_f")" "$_kp"
else
printf " ${RED}${RESET} %s (%s) — should be 600\n" "$(basename "$_key_f")" "${_kp:-?}"
((++issues))
if (( _key_penalty < 20 )); then
score=$(( score - 10 ))
(( _key_penalty += 10 ))
fi
fi
done
if [[ "$_found_keys" != true ]]; then
printf " ${YELLOW}${RESET} No SSH keys found in ~/.ssh/\n"
((++warnings))
fi
# 2. SSH directory permissions
printf "\n${BOLD}[2/6] SSH Directory${RESET}\n"
if [[ -d "${HOME}/.ssh" ]]; then
local _ssh_perms
_ssh_perms=$(stat -c "%a" "${HOME}/.ssh" 2>/dev/null || stat -f "%Lp" "${HOME}/.ssh" 2>/dev/null) || true
if [[ "$_ssh_perms" == "700" ]]; then
printf " ${GREEN}${RESET} ~/.ssh permissions: %s OK\n" "$_ssh_perms"
else
printf " ${RED}${RESET} ~/.ssh permissions: %s — should be 700\n" "${_ssh_perms:-?}"
((++issues)); score=$(( score - 5 ))
fi
else
printf " ${YELLOW}${RESET} ~/.ssh directory does not exist\n"
((++warnings))
fi
# 3. DNS leak protection status
printf "\n${BOLD}[3/6] DNS Leak Protection${RESET}\n"
if is_dns_leak_protected; then
printf " ${GREEN}${RESET} DNS leak protection is ACTIVE\n"
else
printf " ${DIM}${RESET} DNS leak protection is not active\n"
((++warnings))
fi
# 4. Kill switch status
printf "\n${BOLD}[4/6] Kill Switch${RESET}\n"
if [[ $EUID -ne 0 ]]; then
printf " ${YELLOW}${RESET} Skipped — requires root to inspect iptables\n"
((++warnings))
elif is_kill_switch_active; then
printf " ${GREEN}${RESET} Kill switch is ACTIVE (IPv4)\n"
if command -v ip6tables &>/dev/null && ip6tables -C OUTPUT -j "$_TF_CHAIN" 2>/dev/null; then
printf " ${GREEN}${RESET} Kill switch is ACTIVE (IPv6)\n"
else
printf " ${YELLOW}${RESET} IPv6 kill switch not active\n"
((++warnings))
fi
else
printf " ${DIM}${RESET} Kill switch is not active\n"
((++warnings))
fi
# 5. Tunnel PID integrity
printf "\n${BOLD}[5/6] Tunnel Integrity${RESET}\n"
local _audit_profiles
_audit_profiles=$(list_profiles)
if [[ -n "$_audit_profiles" ]]; then
while IFS= read -r _aname; do
[[ -z "$_aname" ]] && continue
local _apid
_apid=$(get_tunnel_pid "$_aname" 2>/dev/null)
if [[ -n "$_apid" ]]; then
if kill -0 "$_apid" 2>/dev/null; then
printf " ${GREEN}${RESET} %s (PID %s) — running\n" "$_aname" "$_apid"
else
printf " ${RED}${RESET} %s (PID %s) — stale PID file\n" "$_aname" "$_apid"
((++issues)); score=$(( score - 5 ))
fi
else
printf " ${DIM}${RESET} %s — not running\n" "$_aname"
fi
done <<< "$_audit_profiles"
else
printf " ${DIM}${RESET} No profiles configured\n"
fi
# 6. Listening ports check
printf "\n${BOLD}[6/6] Listening Ports${RESET}\n"
if command -v ss &>/dev/null; then
local _port_count
_port_count=$(ss -tln 2>/dev/null | tail -n +2 | wc -l) || true
printf " ${DIM}${RESET} %s TCP ports listening\n" "${_port_count:-0}"
elif command -v netstat &>/dev/null; then
local _port_count
_port_count=$(netstat -tln 2>/dev/null | tail -n +3 | wc -l) || true
printf " ${DIM}${RESET} %s TCP ports listening\n" "${_port_count:-0}"
else
printf " ${YELLOW}${RESET} Cannot check ports (ss/netstat not found)\n"
fi
# Summary
if (( score < 0 )); then score=0; fi
printf "\n${BOLD}──────────────────────────────────${RESET}\n"
local _sc_color="${GREEN}"
if (( score < 70 )); then _sc_color="${RED}"
elif (( score < 90 )); then _sc_color="${YELLOW}"; fi
printf "${BOLD}Security Score: ${_sc_color}%d/100${RESET}\n" "$score"
printf "${DIM}Issues: %d | Warnings: %d${RESET}\n\n" "$issues" "$warnings"
return 0
}
# ============================================================================
# SERVER SETUP & SYSTEMD (Phase 5)
# ============================================================================
readonly _SYSTEMD_DIR="/etc/systemd/system"
readonly _SSHD_CONFIG="/etc/ssh/sshd_config"
readonly _SSHD_BACKUP="${BACKUP_DIR}/sshd_config.bak"
# ── Server Hardening ──
# Hardens a remote server's SSH config for receiving tunnel connections
_server_harden_sshd() {
printf "\n${BOLD}[1/4] Hardening SSH daemon${RESET}\n"
if [[ ! -f "$_SSHD_CONFIG" ]]; then
log_error "sshd_config not found at ${_SSHD_CONFIG}"
return 1
fi
# Backup original (canonical backup kept for first-run restore)
if [[ ! -f "$_SSHD_BACKUP" ]]; then
cp "$_SSHD_CONFIG" "$_SSHD_BACKUP" 2>/dev/null || {
log_error "Failed to backup sshd_config"
return 1
}
log_success "Backed up sshd_config (canonical)"
fi
# Always create a timestamped backup before each modification
local _ts_backup="${BACKUP_DIR}/sshd_config.$(date -u '+%Y%m%d%H%M%S').bak"
cp "$_SSHD_CONFIG" "$_ts_backup" 2>/dev/null || true
# Check if any user has authorized_keys before disabling password auth
local _pw_auth="no"
local _has_keys=false
local _huser
for _huser in /root /home/*; do
if [[ -f "${_huser}/.ssh/authorized_keys" ]] && [[ -s "${_huser}/.ssh/authorized_keys" ]]; then
_has_keys=true
break
fi
done
if [[ "$_has_keys" != true ]]; then
_pw_auth="yes"
log_warn "No authorized_keys found — keeping PasswordAuthentication=yes to prevent lockout"
fi
# Apply hardening settings
local -A _harden=(
[PermitRootLogin]="prohibit-password"
[PasswordAuthentication]="$_pw_auth"
[PubkeyAuthentication]="yes"
[X11Forwarding]="no"
[AllowTcpForwarding]="yes"
[GatewayPorts]="clientspecified"
[MaxAuthTries]="3"
[LoginGraceTime]="30"
[ClientAliveInterval]="60"
[ClientAliveCountMax]="3"
[PermitEmptyPasswords]="no"
)
local _hk _hv _changed=false
for _hk in "${!_harden[@]}"; do
_hv="${_harden[$_hk]}"
if grep -qE "^[[:space:]]*${_hk}[[:space:]]" "$_SSHD_CONFIG" 2>/dev/null; then
# Setting exists — update it
local _cur
_cur=$(grep -E "^[[:space:]]*${_hk}[[:space:]]" "$_SSHD_CONFIG" 2>/dev/null | awk '{print $2}' | head -1) || true
if [[ "$_cur" != "$_hv" ]]; then
# First-match only to preserve Match block overrides
local _ln
_ln=$(grep -n "^[[:space:]]*${_hk}[[:space:]]" "$_SSHD_CONFIG" 2>/dev/null | head -1 | cut -d: -f1) || true
if [[ -n "$_ln" ]]; then
local _sed_tmp
_sed_tmp=$(mktemp "${_SSHD_CONFIG}.tf_sed.XXXXXX") || continue
sed "${_ln}s/.*/${_hk} ${_hv}/" "$_SSHD_CONFIG" > "$_sed_tmp" 2>/dev/null && mv -f "$_sed_tmp" "$_SSHD_CONFIG" 2>/dev/null || true
rm -f "$_sed_tmp" 2>/dev/null || true
fi
printf " ${CYAN}~${RESET} %s: %s → %s\n" "$_hk" "${_cur:-?}" "$_hv"
_changed=true
else
printf " ${GREEN}${RESET} %s: %s (already set)\n" "$_hk" "$_hv"
fi
elif grep -qE "^[[:space:]]*#[[:space:]]*${_hk}[[:space:]]" "$_SSHD_CONFIG" 2>/dev/null; then
# Commented out — uncomment and set (first match only)
local _cln
_cln=$(grep -n "^[[:space:]]*#[[:space:]]*${_hk}[[:space:]]" "$_SSHD_CONFIG" 2>/dev/null | head -1 | cut -d: -f1) || true
if [[ -n "$_cln" ]]; then
local _sed_tmp
_sed_tmp=$(mktemp "${_SSHD_CONFIG}.tf_sed.XXXXXX") || continue
sed "${_cln}s/.*/${_hk} ${_hv}/" "$_SSHD_CONFIG" > "$_sed_tmp" 2>/dev/null && mv -f "$_sed_tmp" "$_SSHD_CONFIG" 2>/dev/null || true
rm -f "$_sed_tmp" 2>/dev/null || true
fi
printf " ${CYAN}+${RESET} %s: %s (uncommented)\n" "$_hk" "$_hv"
_changed=true
else
# Not present — insert before first Match block (or append if none)
local _match_ln
_match_ln=$(grep -n "^[[:space:]]*Match[[:space:]]" "$_SSHD_CONFIG" 2>/dev/null | head -1 | cut -d: -f1) || true
if [[ -n "$_match_ln" ]]; then
local _sed_tmp
_sed_tmp=$(mktemp "${_SSHD_CONFIG}.tf_sed.XXXXXX") || continue
{ head -n "$((_match_ln - 1))" "$_SSHD_CONFIG"; printf "%s %s\n" "$_hk" "$_hv"; tail -n "+${_match_ln}" "$_SSHD_CONFIG"; } > "$_sed_tmp" 2>/dev/null && mv -f "$_sed_tmp" "$_SSHD_CONFIG" 2>/dev/null || true
rm -f "$_sed_tmp" 2>/dev/null || true
else
if [[ -s "$_SSHD_CONFIG" ]] && [[ -n "$(tail -c1 "$_SSHD_CONFIG" 2>/dev/null)" ]]; then
echo "" >> "$_SSHD_CONFIG" 2>/dev/null || true
fi
printf "%s %s\n" "$_hk" "$_hv" >> "$_SSHD_CONFIG" 2>/dev/null || true
fi
printf " ${CYAN}+${RESET} %s: %s (added)\n" "$_hk" "$_hv"
_changed=true
fi
done
if [[ "$_changed" == true ]]; then
# Test config before reload
if sshd -t 2>/dev/null; then
log_success "sshd_config syntax OK"
local _reload_ok=false
if [[ "$INIT_SYSTEM" == "systemd" ]]; then
if systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null; then
_reload_ok=true
fi
else
if service sshd reload 2>/dev/null || service ssh reload 2>/dev/null; then
_reload_ok=true
fi
fi
if [[ "$_reload_ok" == true ]]; then
log_success "SSH daemon reloaded"
else
log_warn "Could not reload sshd (reload manually)"
fi
else
log_error "sshd_config syntax error — restoring backup"
if [[ -f "$_ts_backup" ]] && cp "$_ts_backup" "$_SSHD_CONFIG" 2>/dev/null; then
log_info "Restored from timestamped backup"
elif [[ -f "$_SSHD_BACKUP" ]] && cp "$_SSHD_BACKUP" "$_SSHD_CONFIG" 2>/dev/null; then
log_warn "Timestamped backup unavailable, restored from canonical backup"
else
log_error "CRITICAL: No backup available — sshd_config may be broken!"
fi
return 1
fi
else
log_info "No changes needed"
fi
return 0
}
_server_setup_firewall() {
printf "\n${BOLD}[2/4] Configuring firewall${RESET}\n"
# Detect SSH port from sshd_config (used by all firewall paths)
local _fw_ssh_port=22
if [[ -f "$_SSHD_CONFIG" ]]; then
local _detected_port
_detected_port=$(grep -E "^[[:space:]]*Port[[:space:]]+" "$_SSHD_CONFIG" 2>/dev/null | awk '{print $2}' | head -1) || true
if [[ -n "$_detected_port" ]] && [[ "$_detected_port" =~ ^[0-9]+$ ]]; then
_fw_ssh_port="$_detected_port"
fi
fi
if command -v ufw &>/dev/null; then
ufw default deny incoming 2>/dev/null || true
ufw allow "$_fw_ssh_port/tcp" 2>/dev/null || true
ufw --force enable 2>/dev/null || true
printf " ${GREEN}${RESET} UFW enabled (default deny + SSH port %s allowed)\n" "$_fw_ssh_port"
log_success "UFW configured with default deny"
elif command -v firewall-cmd &>/dev/null; then
firewall-cmd --permanent --add-port="${_fw_ssh_port}/tcp" 2>/dev/null || true
firewall-cmd --reload 2>/dev/null || true
printf " ${GREEN}${RESET} firewalld: SSH port %s allowed\n" "$_fw_ssh_port"
log_success "firewalld configured"
elif command -v iptables &>/dev/null; then
# IPv4 rules (conntrack instead of deprecated state module)
if ! iptables -C INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null; then
iptables -I INPUT 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true
fi
if ! iptables -C INPUT -i lo -j ACCEPT 2>/dev/null; then
iptables -I INPUT 2 -i lo -j ACCEPT 2>/dev/null || true
fi
if ! iptables -C INPUT -p tcp --dport "$_fw_ssh_port" -j ACCEPT 2>/dev/null; then
iptables -A INPUT -p tcp --dport "$_fw_ssh_port" -j ACCEPT 2>/dev/null || true
fi
iptables -P INPUT DROP 2>/dev/null || true
iptables -P FORWARD DROP 2>/dev/null || true
printf " ${GREEN}${RESET} iptables: SSH (port %s) allowed, default deny\n" "$_fw_ssh_port"
# IPv6 mirror rules
if command -v ip6tables &>/dev/null; then
if ! ip6tables -C INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null; then
ip6tables -I INPUT 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true
fi
if ! ip6tables -C INPUT -i lo -j ACCEPT 2>/dev/null; then
ip6tables -I INPUT 2 -i lo -j ACCEPT 2>/dev/null || true
fi
if ! ip6tables -C INPUT -p tcp --dport "$_fw_ssh_port" -j ACCEPT 2>/dev/null; then
ip6tables -A INPUT -p tcp --dport "$_fw_ssh_port" -j ACCEPT 2>/dev/null || true
fi
ip6tables -P INPUT DROP 2>/dev/null || true
ip6tables -P FORWARD DROP 2>/dev/null || true
printf " ${GREEN}${RESET} ip6tables: SSH (port %s) allowed, default deny\n" "$_fw_ssh_port"
fi
# Persist iptables rules
if [[ -d "/etc/iptables" ]]; then
iptables-save > /etc/iptables/rules.v4 2>/dev/null || true
if command -v ip6tables-save &>/dev/null; then
ip6tables-save > /etc/iptables/rules.v6 2>/dev/null || true
fi
printf " ${GREEN}${RESET} iptables rules persisted to /etc/iptables/\n"
elif command -v netfilter-persistent &>/dev/null; then
netfilter-persistent save 2>/dev/null || true
printf " ${GREEN}${RESET} iptables rules persisted via netfilter-persistent\n"
else
log_warn "Install iptables-persistent to survive reboots"
fi
else
printf " ${YELLOW}${RESET} No firewall tool found (ufw/firewalld/iptables)\n"
fi
return 0
}
_server_setup_fail2ban() {
printf "\n${BOLD}[3/4] Setting up fail2ban${RESET}\n"
if ! command -v fail2ban-client &>/dev/null; then
log_info "Installing fail2ban..."
if [[ -n "${PKG_INSTALL:-}" ]]; then
${PKG_INSTALL} fail2ban 2>/dev/null || {
log_warn "Could not install fail2ban"
return 0
}
else
printf " ${YELLOW}${RESET} Cannot install fail2ban (unknown package manager)\n"
return 0
fi
fi
# Detect SSH port
local _f2b_ssh_port=22
if [[ -f "$_SSHD_CONFIG" ]]; then
local _f2b_dp
_f2b_dp=$(grep -E "^[[:space:]]*Port[[:space:]]+" "$_SSHD_CONFIG" 2>/dev/null | awk '{print $2}' | head -1) || true
if [[ -n "$_f2b_dp" ]] && [[ "$_f2b_dp" =~ ^[0-9]+$ ]]; then
_f2b_ssh_port="$_f2b_dp"
fi
fi
local jail_dir="/etc/fail2ban/jail.d"
mkdir -p "$jail_dir" 2>/dev/null || true
local jail_file="${jail_dir}/tunnelforge-sshd.conf"
if [[ ! -f "$jail_file" ]]; then
{
printf "[sshd]\n"
printf "enabled = true\n"
printf "port = %s\n" "$_f2b_ssh_port"
printf "filter = sshd\n"
printf "maxretry = 5\n"
printf "findtime = 600\n"
printf "bantime = 3600\n"
local _f2b_backend="auto"
if [[ "$INIT_SYSTEM" == "systemd" ]]; then _f2b_backend="systemd"; fi
printf "backend = %s\n" "$_f2b_backend"
} > "$jail_file" 2>/dev/null || true
printf " ${GREEN}${RESET} Created fail2ban SSH jail\n"
else
printf " ${GREEN}${RESET} fail2ban SSH jail already exists\n"
fi
if [[ "$INIT_SYSTEM" == "systemd" ]]; then
systemctl enable fail2ban 2>/dev/null || true
systemctl restart fail2ban 2>/dev/null || true
fi
log_success "fail2ban configured"
return 0
}
_server_setup_sysctl() {
printf "\n${BOLD}[4/4] Kernel hardening${RESET}\n"
local sysctl_file="/etc/sysctl.d/99-tunnelforge.conf"
if [[ ! -f "$sysctl_file" ]]; then
{
printf "# TunnelForge kernel hardening\n"
printf "net.ipv4.tcp_syncookies = 1\n"
printf "net.ipv4.conf.all.rp_filter = 1\n"
printf "net.ipv4.conf.default.rp_filter = 1\n"
printf "net.ipv4.icmp_echo_ignore_broadcasts = 1\n"
printf "net.ipv4.conf.all.accept_redirects = 0\n"
printf "net.ipv4.conf.default.accept_redirects = 0\n"
printf "net.ipv4.conf.all.send_redirects = 0\n"
printf "net.ipv4.conf.default.send_redirects = 0\n"
printf "net.ipv4.ip_forward = 1\n"
} > "$sysctl_file" 2>/dev/null || true
sysctl -p "$sysctl_file" 2>/dev/null || true
printf " ${GREEN}${RESET} Kernel parameters hardened\n"
log_success "sysctl hardening applied"
else
printf " ${GREEN}${RESET} Kernel hardening already applied\n"
fi
return 0
}
server_setup() {
local _profile_name="${1:-}"
# If a profile name is given, harden the REMOTE server via SSH
if [[ -n "$_profile_name" ]]; then
_server_setup_remote "$_profile_name"
return $?
fi
# Otherwise, harden THIS (local) server
if [[ $EUID -ne 0 ]]; then
log_error "Server setup requires root privileges"
return 1
fi
printf "\n${BOLD_CYAN}═══ TunnelForge Server Setup ═══${RESET}\n"
printf "${DIM}Hardening this server for receiving SSH tunnel connections${RESET}\n"
printf "\nThis will:\n"
printf " 1. Harden SSH daemon configuration\n"
printf " 2. Configure firewall rules\n"
printf " 3. Set up fail2ban intrusion prevention\n"
printf " 4. Apply kernel network hardening\n\n"
if ! confirm_action "Proceed with server hardening?"; then
log_info "Server setup cancelled"
return 0
fi
_server_harden_sshd || true
_server_setup_firewall || true
_server_setup_fail2ban || true
_server_setup_sysctl || true
printf "\n${BOLD_GREEN}═══ Server Setup Complete ═══${RESET}\n"
printf "${DIM}Your server is now hardened for SSH tunnel connections.${RESET}\n"
printf "${DIM}Run 'tunnelforge audit' to verify security posture.${RESET}\n\n"
return 0
}
# ── Remote Server Setup ──
# SSHes into a profile's target server and enables essential tunnel settings
_server_setup_remote() {
local name="$1"
local -A _rss_prof
if ! load_profile "$name" _rss_prof; then
log_error "Profile '${name}' not found"
return 1
fi
local host="${_rss_prof[SSH_HOST]:-}"
local user="${_rss_prof[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}"
if [[ -z "$host" ]]; then
log_error "Profile '${name}' has no SSH_HOST"
return 1
fi
printf "\n${BOLD_CYAN}═══ Remote Server Setup ═══${RESET}\n"
printf "${DIM}Target: %s@%s${RESET}\n\n" "$user" "$host"
printf "This will enable on the remote server:\n"
printf " - AllowTcpForwarding yes ${DIM}(required for -D/-L/-R)${RESET}\n"
printf " - GatewayPorts clientspecified ${DIM}(for -R public bind)${RESET}\n"
printf " - PermitTunnel yes ${DIM}(for TUN/TAP forwarding)${RESET}\n\n"
if ! confirm_action "SSH into ${host} and apply settings?"; then
log_info "Remote setup cancelled"
return 0
fi
_obfs_remote_ssh _rss_prof
local _rss_rc=0
"${_OBFS_SSH_CMD[@]}" "bash -s" <<'REMOTE_SSHD_SCRIPT' || _rss_rc=$?
set -e
# Use sudo if not root
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
if command -v sudo >/dev/null 2>&1; then
if sudo -n true 2>/dev/null; then
SUDO="sudo"
else
echo "ERROR: Not root and sudo requires a password"
echo " Either SSH as root, or add NOPASSWD for this user"
exit 1
fi
else
echo "ERROR: Not running as root and sudo not available"
exit 1
fi
fi
SSHD_CONFIG="/etc/ssh/sshd_config"
if [ ! -f "$SSHD_CONFIG" ]; then
echo "ERROR: sshd_config not found at $SSHD_CONFIG"
exit 1
fi
# Backup before changes
$SUDO cp "$SSHD_CONFIG" "${SSHD_CONFIG}.bak.$(date +%s)" 2>/dev/null || true
CHANGED=false
apply_setting() {
local key="$1" val="$2"
if grep -qE "^[[:space:]]*${key}[[:space:]]" "$SSHD_CONFIG" 2>/dev/null; then
CUR=$(grep -E "^[[:space:]]*${key}[[:space:]]" "$SSHD_CONFIG" | awk '{print $2}' | head -1)
if [ "$CUR" != "$val" ]; then
LN=$(grep -n "^[[:space:]]*${key}[[:space:]]" "$SSHD_CONFIG" | head -1 | cut -d: -f1)
$SUDO sed -i "${LN}s/.*/${key} ${val}/" "$SSHD_CONFIG"
echo " ~ ${key}: ${CUR} -> ${val}"
CHANGED=true
else
echo " OK ${key}: ${val} (already set)"
fi
elif grep -qE "^[[:space:]]*#[[:space:]]*${key}" "$SSHD_CONFIG" 2>/dev/null; then
LN=$(grep -n "^[[:space:]]*#[[:space:]]*${key}" "$SSHD_CONFIG" | head -1 | cut -d: -f1)
$SUDO sed -i "${LN}s/.*/${key} ${val}/" "$SSHD_CONFIG"
echo " + ${key}: ${val} (uncommented)"
CHANGED=true
else
echo "${key} ${val}" | $SUDO tee -a "$SSHD_CONFIG" >/dev/null
echo " + ${key}: ${val} (added)"
CHANGED=true
fi
}
echo "Checking sshd settings..."
apply_setting "AllowTcpForwarding" "yes"
apply_setting "GatewayPorts" "clientspecified"
apply_setting "PermitTunnel" "yes"
if [ "$CHANGED" = true ]; then
if $SUDO sshd -t 2>/dev/null; then
echo "sshd_config syntax OK"
if command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl reload sshd 2>/dev/null || $SUDO systemctl reload ssh 2>/dev/null || true
else
$SUDO service sshd reload 2>/dev/null || $SUDO service ssh reload 2>/dev/null || true
fi
echo "SUCCESS: SSH daemon reloaded with new settings"
else
echo "ERROR: sshd_config syntax error — restoring backup"
LATEST_BAK=$(ls -t "${SSHD_CONFIG}.bak."* 2>/dev/null | head -1)
if [ -n "$LATEST_BAK" ]; then
$SUDO cp "$LATEST_BAK" "$SSHD_CONFIG" 2>/dev/null || true
echo "Restored from backup"
fi
exit 1
fi
else
echo "All settings already correct — no changes needed"
fi
REMOTE_SSHD_SCRIPT
unset SSHPASS 2>/dev/null || true
if (( _rss_rc == 0 )); then
printf "\n"
log_success "Remote server configured for tunnel forwarding"
else
printf "\n"
log_error "Remote setup failed (exit code: ${_rss_rc})"
fi
return "$_rss_rc"
}
# ── TLS Obfuscation (stunnel) Setup ──
# Remotely installs and configures stunnel on a profile's server
# Build SSH command for remote execution on profile's server.
# Populates global _OBFS_SSH_CMD array. Caller must unset SSHPASS.
_obfs_remote_ssh() {
local -n _ors_prof="$1"
local host="${_ors_prof[SSH_HOST]:-}"
local port="${_ors_prof[SSH_PORT]:-22}"
local user="${_ors_prof[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}"
local key="${_ors_prof[IDENTITY_KEY]:-}"
local password="${_ors_prof[SSH_PASSWORD]:-}"
_OBFS_SSH_CMD=()
local -a ssh_opts=(-p "$port" -o "ConnectTimeout=15" -o "StrictHostKeyChecking=accept-new")
if [[ -n "$key" ]] && [[ -f "$key" ]]; then ssh_opts+=(-i "$key"); fi
if [[ -n "$password" ]]; then
if command -v sshpass &>/dev/null; then
export SSHPASS="$password"
_OBFS_SSH_CMD=(sshpass -e ssh "${ssh_opts[@]}" "${user}@${host}")
else
ssh_opts+=(-o "BatchMode=no")
_OBFS_SSH_CMD=(ssh "${ssh_opts[@]}" "${user}@${host}")
fi
else
_OBFS_SSH_CMD=(ssh "${ssh_opts[@]}" "${user}@${host}")
fi
return 0
}
# Core remote setup script — shared by CLI and wizard.
# Args: obfs_port ssh_port
# Requires _OBFS_SSH_CMD to be populated by _obfs_remote_ssh().
_obfs_run_remote_setup() {
local obfs_port="$1" ssh_port="$2"
# Validate ports are numeric before interpolation into remote script
if ! [[ "$obfs_port" =~ ^[0-9]+$ ]] || ! [[ "$ssh_port" =~ ^[0-9]+$ ]]; then
log_error "Port values must be numeric"; return 1
fi
local _setup_rc=0
"${_OBFS_SSH_CMD[@]}" "bash -s" <<OBFS_SCRIPT || _setup_rc=$?
set -e
OBFS_PORT="${obfs_port}"
SSH_PORT="${ssh_port}"
# Use sudo if not root
SUDO=""
if [ "\$(id -u)" -ne 0 ]; then
if command -v sudo >/dev/null 2>&1; then
# Verify sudo works without password (non-interactive session has no TTY)
if sudo -n true 2>/dev/null; then
SUDO="sudo"
else
echo "ERROR: Not root and sudo requires a password"
echo " Either SSH as root, or add NOPASSWD for this user in /etc/sudoers"
exit 1
fi
else
echo "ERROR: Not running as root and sudo not available"
exit 1
fi
fi
# Detect package manager
if command -v apt-get >/dev/null 2>&1; then
PKG_INSTALL="\$SUDO apt-get install -y -qq"
PKG_UPDATE="\$SUDO apt-get update -qq"
elif command -v dnf >/dev/null 2>&1; then
PKG_INSTALL="\$SUDO dnf install -y -q"
PKG_UPDATE="true"
elif command -v yum >/dev/null 2>&1; then
PKG_INSTALL="\$SUDO yum install -y -q"
PKG_UPDATE="true"
elif command -v apk >/dev/null 2>&1; then
PKG_INSTALL="\$SUDO apk add --quiet"
PKG_UPDATE="\$SUDO apk update --quiet"
else
echo "ERROR: No supported package manager (apt/dnf/yum/apk)"
exit 1
fi
# Check if port is already in use (not by our stunnel)
if ss -tln 2>/dev/null | grep -qE ":\${OBFS_PORT}[[:space:]]"; then
LISTENER=\$(ss -tlnp 2>/dev/null | grep ":\${OBFS_PORT} " | head -1)
if echo "\$LISTENER" | grep -q stunnel; then
echo "INFO: stunnel already listening on port \${OBFS_PORT} — updating config"
else
echo "ERROR: Port \${OBFS_PORT} already in use by another service:"
echo " \$LISTENER"
echo "Choose a different OBFS_PORT or stop the conflicting service."
exit 2
fi
fi
# Install stunnel (skip if already present)
if command -v stunnel >/dev/null 2>&1 || command -v stunnel4 >/dev/null 2>&1; then
echo "stunnel already installed"
else
echo "Installing stunnel..."
\$PKG_UPDATE 2>/dev/null || true
# Try stunnel4 first (Debian/Ubuntu), then stunnel (RHEL/Alpine)
\$PKG_INSTALL stunnel4 >/dev/null 2>&1 || \$PKG_INSTALL stunnel >/dev/null 2>&1 || true
# Verify it actually installed
if ! command -v stunnel >/dev/null 2>&1 && ! command -v stunnel4 >/dev/null 2>&1; then
echo "ERROR: Failed to install stunnel"
echo " Try manually: ssh into the server and install stunnel"
exit 1
fi
echo "stunnel installed"
fi
# Ensure openssl is available
command -v openssl >/dev/null 2>&1 || \$PKG_INSTALL openssl >/dev/null 2>&1 || true
# Generate self-signed cert if missing
CERT_DIR="/etc/stunnel"
CERT_FILE="\${CERT_DIR}/tunnelforge.pem"
\$SUDO mkdir -p "\$CERT_DIR"
if [ ! -f "\$CERT_FILE" ]; then
echo "Generating self-signed TLS certificate..."
\$SUDO openssl req -new -x509 -days 3650 -nodes \
-out "\$CERT_FILE" -keyout "\$CERT_FILE" \
-subj "/CN=tunnelforge/O=TunnelForge/C=US" 2>/dev/null || {
echo "ERROR: Failed to generate certificate"
exit 1
}
\$SUDO chmod 600 "\$CERT_FILE"
echo "Certificate generated: \$CERT_FILE"
else
echo "Certificate exists: \$CERT_FILE"
fi
# Write stunnel config
CONF_FILE="\${CERT_DIR}/tunnelforge-ssh.conf"
\$SUDO tee "\$CONF_FILE" >/dev/null <<STUNNEL_CONF
; TunnelForge SSH obfuscation — wraps SSH in TLS
pid = /var/run/stunnel-tunnelforge.pid
[ssh-tls]
accept = 0.0.0.0:\${OBFS_PORT}
connect = 127.0.0.1:\${SSH_PORT}
cert = \${CERT_FILE}
STUNNEL_CONF
echo "Config written: \$CONF_FILE"
# Enable and start stunnel
if command -v systemctl >/dev/null 2>&1; then
SVC=""
for _sn in stunnel4 stunnel; do
if systemctl list-unit-files "\${_sn}.service" 2>/dev/null | grep -q "\${_sn}"; then
SVC="\$_sn"
break
fi
done
if [ -n "\$SVC" ]; then
\$SUDO systemctl enable "\$SVC" 2>/dev/null || true
\$SUDO systemctl restart "\$SVC" 2>/dev/null || true
echo "Stunnel service restarted (\${SVC})"
else
\$SUDO stunnel "\$CONF_FILE" 2>/dev/null || { echo "ERROR: stunnel failed to start"; exit 1; }
echo "Stunnel started directly"
fi
else
\$SUDO stunnel "\$CONF_FILE" 2>/dev/null || { echo "ERROR: stunnel failed to start"; exit 1; }
echo "Stunnel started"
fi
# Open firewall port
if command -v ufw >/dev/null 2>&1; then
\$SUDO ufw allow "\${OBFS_PORT}/tcp" 2>/dev/null || true
echo "UFW: port \${OBFS_PORT} opened"
elif command -v firewall-cmd >/dev/null 2>&1; then
\$SUDO firewall-cmd --permanent --add-port="\${OBFS_PORT}/tcp" 2>/dev/null || true
\$SUDO firewall-cmd --reload 2>/dev/null || true
echo "firewalld: port \${OBFS_PORT} opened"
fi
# Verify
sleep 1
if ss -tln 2>/dev/null | grep -qE ":\${OBFS_PORT}[[:space:]]"; then
echo "SUCCESS: stunnel listening on port \${OBFS_PORT}"
else
echo "WARNING: stunnel may not be listening yet — check manually"
fi
OBFS_SCRIPT
unset SSHPASS 2>/dev/null || true
return "$_setup_rc"
}
# CLI entry: tunnelforge obfs-setup <profile>
_obfs_setup_stunnel() {
local name="$1"
local -A _os_prof=()
load_profile "$name" _os_prof || { log_error "Cannot load profile '${name}'"; return 1; }
local host="${_os_prof[SSH_HOST]:-}"
local ssh_port="${_os_prof[SSH_PORT]:-22}"
local obfs_port="${_os_prof[OBFS_PORT]:-443}"
local user="${_os_prof[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}"
if [[ -z "$host" ]]; then
log_error "No SSH host in profile '${name}'"
return 1
fi
if [[ "${_os_prof[OBFS_MODE]:-none}" == "none" ]]; then
log_warn "Profile '${name}' does not have obfuscation enabled"
printf "${DIM} Enable it first: edit profile and set OBFS_MODE=stunnel${RESET}\n"
printf "${DIM} Or use the wizard: tunnelforge edit ${name}${RESET}\n\n"
if ! confirm_action "Set up stunnel anyway?"; then
return 0
fi
# Auto-enable if they proceed
_os_prof[OBFS_MODE]="stunnel"
_os_prof[OBFS_PORT]="$obfs_port"
save_profile "$name" _os_prof 2>/dev/null || true
log_info "Obfuscation enabled for profile '${name}'"
fi
printf "\n${BOLD_CYAN}═══ TLS Obfuscation Setup ═══${RESET}\n"
printf "${DIM}Configuring stunnel on %s@%s to accept TLS on port %s${RESET}\n\n" "$user" "$host" "$obfs_port"
printf "This will:\n"
printf " 1. Install stunnel + openssl on the remote server\n"
printf " 2. Generate a self-signed TLS certificate\n"
printf " 3. Configure stunnel: port %s (TLS) → port %s (SSH)\n" "$obfs_port" "$ssh_port"
printf " 4. Enable stunnel service and open firewall port\n\n"
if ! confirm_action "Proceed with stunnel setup on ${host}?"; then
log_info "Setup cancelled"
return 0
fi
_obfs_remote_ssh _os_prof || { log_error "Cannot build SSH command"; return 1; }
log_info "Connecting to ${user}@${host}..."
local _rc=0
_obfs_run_remote_setup "$obfs_port" "$ssh_port" || _rc=$?
if (( _rc == 0 )); then
log_success "Stunnel configured on ${host}:${obfs_port}"
printf "\n${DIM} SSH traffic will be wrapped in TLS — DPI sees HTTPS on port ${obfs_port}.${RESET}\n"
printf "${DIM} Start your tunnel: tunnelforge start ${name}${RESET}\n\n"
elif (( _rc == 2 )); then
log_error "Port ${obfs_port} is in use on ${host} — choose a different OBFS_PORT"
return 1
else
log_error "Stunnel setup failed on ${host} (exit code: ${_rc})"
return 1
fi
return 0
}
# Wizard entry: setup stunnel using profile nameref (before profile is saved)
_obfs_setup_stunnel_direct() {
local -n _osd_prof="$1"
local host="${_osd_prof[SSH_HOST]:-}"
local ssh_port="${_osd_prof[SSH_PORT]:-22}"
local obfs_port="${_osd_prof[OBFS_PORT]:-443}"
local user="${_osd_prof[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}"
if [[ -z "$host" ]]; then
log_error "No SSH host configured"
return 1
fi
printf "\n${BOLD_CYAN}═══ TLS Obfuscation Setup ═══${RESET}\n" >/dev/tty
printf "${DIM}Configuring stunnel on %s@%s (port %s → %s)${RESET}\n\n" "$user" "$host" "$obfs_port" "$ssh_port" >/dev/tty
_obfs_remote_ssh _osd_prof || { log_error "Cannot build SSH command"; return 1; }
log_info "Connecting to ${user}@${host}..."
local _rc=0
_obfs_run_remote_setup "$obfs_port" "$ssh_port" || _rc=$?
unset SSHPASS 2>/dev/null || true
if (( _rc == 0 )); then
log_success "Stunnel configured on ${host}:${obfs_port}"
elif (( _rc == 2 )); then
log_error "Port ${obfs_port} is in use on ${host}"
else
log_error "Stunnel setup failed (exit code: ${_rc})"
fi
return "$_rc"
}
# ── Local Stunnel (Inbound TLS + PSK) ──
# Wraps the SOCKS5/local listener with TLS+PSK so clients connect securely.
# Architecture: client ──TLS+PSK──→ stunnel ──→ 127.0.0.1:LOCAL_PORT
# Generate a random 32-byte hex PSK
_obfs_generate_psk() {
local _psk=""
if command -v openssl &>/dev/null; then
_psk=$(openssl rand -hex 32 2>/dev/null) || true
fi
if [[ -z "$_psk" ]] && [[ -r /dev/urandom ]]; then
_psk=$(head -c 32 /dev/urandom 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || true
fi
if [[ -z "$_psk" ]]; then
# Last resort: bash $RANDOM (weaker but functional)
local _i
for _i in 1 2 3 4 5 6 7 8; do
printf '%08x' "$RANDOM$RANDOM" 2>/dev/null
done
return 0
fi
printf '%s' "$_psk"
return 0
}
# Write local stunnel config and PSK secrets file.
# Args: name local_port obfs_local_port psk
_obfs_write_local_conf() {
local _name="$1" _lport="$2" _olport="$3" _psk="$4"
local _conf_dir="${CONFIG_DIR}/stunnel"
local _conf_file="${_conf_dir}/${_name}-local.conf"
local _psk_file="${_conf_dir}/${_name}-local.psk"
local _pid_f="${PID_DIR}/${_name}.stunnel"
local _log_f="${LOG_DIR}/${_name}-stunnel.log"
mkdir -p "$_conf_dir" 2>/dev/null || true
# Write PSK secrets file (identity:key format)
printf 'tunnelforge:%s\n' "$_psk" > "$_psk_file" 2>/dev/null || {
log_error "Cannot write PSK file: $_psk_file"
return 1
}
if ! chmod 600 "$_psk_file" 2>/dev/null; then
log_error "Failed to secure PSK file permissions: $_psk_file"
rm -f "$_psk_file" 2>/dev/null || true
return 1
fi
# Write stunnel config — global options MUST come before [section]
printf '; TunnelForge inbound TLS+PSK wrapper\n' > "$_conf_file"
printf 'pid = %s\n' "$_pid_f" >> "$_conf_file"
printf 'output = %s\n' "$_log_f" >> "$_conf_file"
printf 'foreground = no\n\n' >> "$_conf_file"
printf '[tunnelforge-inbound]\n' >> "$_conf_file"
printf 'accept = 0.0.0.0:%s\n' "$_olport" >> "$_conf_file"
printf 'connect = 127.0.0.1:%s\n' "$_lport" >> "$_conf_file"
printf 'PSKsecrets = %s\n' "$_psk_file" >> "$_conf_file"
printf 'ciphers = PSK\n' >> "$_conf_file"
chmod 600 "$_conf_file" 2>/dev/null || true
return 0
}
# Start local stunnel for inbound TLS+PSK.
# Args: name
# Reads profile to get LOCAL_PORT, OBFS_LOCAL_PORT, OBFS_PSK.
_obfs_start_local_stunnel() {
local _name="$1"
local -n _osl_prof="$2"
local _lport="${_osl_prof[LOCAL_PORT]:-}"
local _olport="${_osl_prof[OBFS_LOCAL_PORT]:-}"
local _psk="${_osl_prof[OBFS_PSK]:-}"
if [[ -z "$_olport" ]] || [[ "$_olport" == "0" ]]; then return 0; fi
if [[ -z "$_lport" ]]; then
log_warn "No LOCAL_PORT for local stunnel — skipping inbound TLS"
return 0
fi
if [[ -z "$_psk" ]]; then
log_warn "No PSK for local stunnel — skipping inbound TLS"
return 0
fi
if ! command -v stunnel &>/dev/null && ! command -v stunnel4 &>/dev/null; then
log_info "Installing stunnel for inbound TLS..."
if [[ -n "${PKG_UPDATE:-}" ]]; then ${PKG_UPDATE} &>/dev/null || true; fi
install_package "stunnel4" 2>/dev/null || install_package "stunnel" 2>/dev/null || true
if ! command -v stunnel &>/dev/null && ! command -v stunnel4 &>/dev/null; then
log_error "Failed to install stunnel — inbound TLS unavailable"
return 1
fi
log_success "stunnel installed"
fi
# Write config + PSK file (includes global options at top)
_obfs_write_local_conf "$_name" "$_lport" "$_olport" "$_psk" || return 1
local _conf_file="${CONFIG_DIR}/stunnel/${_name}-local.conf"
local _pid_f="${PID_DIR}/${_name}.stunnel"
local _log_f="${LOG_DIR}/${_name}-stunnel.log"
# Check if OBFS_LOCAL_PORT is already in use
if is_port_in_use "$_olport" "0.0.0.0"; then
log_error "Inbound TLS port ${_olport} already in use"
return 1
fi
# Launch stunnel
local _stunnel_bin="stunnel"
if ! command -v stunnel &>/dev/null; then _stunnel_bin="stunnel4"; fi
"$_stunnel_bin" "$_conf_file" >> "$_log_f" 2>&1 || {
log_error "Local stunnel failed to start (check ${_log_f})"
return 1
}
# Wait for stunnel to actually listen on the port (not just PID file)
local _sw
for _sw in 1 2 3 4 5; do
if is_port_in_use "$_olport" "0.0.0.0"; then break; fi
sleep 1
done
if is_port_in_use "$_olport" "0.0.0.0"; then
local _spid=""
_spid=$(cat "$_pid_f" 2>/dev/null) || true
log_success "Inbound TLS active on 0.0.0.0:${_olport} → 127.0.0.1:${_lport} (PID: ${_spid:-?})"
else
log_error "stunnel failed to listen on port ${_olport} (check ${_log_f})"
return 1
fi
return 0
}
# Stop local stunnel for a profile.
# Args: name
_obfs_stop_local_stunnel() {
local _name="$1"
local _pid_f="${PID_DIR}/${_name}.stunnel"
local _conf_dir="${CONFIG_DIR}/stunnel"
if [[ ! -f "$_pid_f" ]]; then return 0; fi
local _spid=""
_spid=$(cat "$_pid_f" 2>/dev/null) || true
if [[ -n "$_spid" ]] && kill -0 "$_spid" 2>/dev/null; then
log_info "Stopping local stunnel (PID: ${_spid})..."
kill "$_spid" 2>/dev/null || true
local _sw=0
while (( _sw < 3 )) && kill -0 "$_spid" 2>/dev/null; do
sleep 1; (( ++_sw ))
done
if kill -0 "$_spid" 2>/dev/null; then
kill -9 "$_spid" 2>/dev/null || true
fi
fi
# Clean up files
rm -f "$_pid_f" \
"${_conf_dir}/${_name}-local.conf" \
"${_conf_dir}/${_name}-local.psk" 2>/dev/null || true
return 0
}
# Display client stunnel config for the user to copy.
# Args: name prof_ref
_obfs_show_client_config() {
local _name="$1"
local -n _occ_prof="$2"
local _olport="${_occ_prof[OBFS_LOCAL_PORT]:-}"
local _psk="${_occ_prof[OBFS_PSK]:-}"
local _lport="${_occ_prof[LOCAL_PORT]:-}"
local _host=""
if [[ -z "$_olport" ]] || [[ "$_olport" == "0" ]]; then return 0; fi
# Determine the server's reachable IP/hostname
_host="${_occ_prof[SSH_HOST]:-localhost}"
# If this machine has a public IP, try to detect it
local _pub_ip=""
_pub_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | cut -d' ' -f2) || true
if [[ -n "$_pub_ip" ]]; then _host="$_pub_ip"; fi
printf "\n${BOLD_CYAN}═══ Client Connection Info ═══${RESET}\n"
printf "${DIM}Users connect to this server via TLS+PSK on port ${_olport}${RESET}\n\n"
printf "${BOLD}Server address:${RESET} %s:%s\n" "$_host" "$_olport"
printf "${BOLD}PSK identity:${RESET} tunnelforge\n"
printf "${BOLD}PSK secret:${RESET} %s\n" "$_psk"
printf "${BOLD}SOCKS5 port:${RESET} %s (tunneled through TLS)\n\n" "$_lport"
printf "${BOLD}── Client stunnel.conf ──${RESET}\n"
printf "${DIM}Save this on the user's PC and run: stunnel stunnel.conf${RESET}\n\n"
printf "[tunnelforge]\n"
printf "client = yes\n"
printf "accept = 127.0.0.1:%s\n" "$_lport"
printf "connect = %s:%s\n" "$_host" "$_olport"
printf "PSKsecrets = psk.txt\n"
printf "ciphers = PSK\n\n"
printf "${BOLD}── psk.txt ──${RESET}\n"
printf "tunnelforge:%s\n\n" "$_psk"
printf "${DIM}After setup, configure browser/apps to use SOCKS5 proxy:${RESET}\n"
printf "${DIM} 127.0.0.1:%s (on the user's PC)${RESET}\n\n" "$_lport"
return 0
}
# Show all profiles with inbound TLS+PSK configured — admin quick-reference
# for sharing client connection details.
_menu_client_configs() {
_menu_header "Client Configs"
printf " ${DIM}Profiles with inbound TLS+PSK protection${RESET}\n\n" >/dev/tty
local _mcc_profiles _mcc_found=0
_mcc_profiles=$(list_profiles) || true
if [[ -z "$_mcc_profiles" ]]; then
printf " ${YELLOW}No profiles found.${RESET}\n" >/dev/tty
return 0
fi
# Detect this machine's IP once
local _mcc_pub_ip=""
_mcc_pub_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | cut -d' ' -f2) || true
while IFS= read -r _mcc_name; do
[[ -z "$_mcc_name" ]] && continue
local -A _mcc_p=()
load_profile "$_mcc_name" _mcc_p || continue
local _mcc_olport="${_mcc_p[OBFS_LOCAL_PORT]:-}"
[[ -z "$_mcc_olport" ]] || [[ "$_mcc_olport" == "0" ]] && { unset _mcc_p; continue; }
local _mcc_psk="${_mcc_p[OBFS_PSK]:-}"
local _mcc_lport="${_mcc_p[LOCAL_PORT]:-1080}"
local _mcc_host="${_mcc_pub_ip:-${_mcc_p[SSH_HOST]:-localhost}}"
local _mcc_running=""
if is_tunnel_running "$_mcc_name"; then
_mcc_running="${GREEN}ALIVE${RESET}"
else
_mcc_running="${RED}STOPPED${RESET}"
fi
(( ++_mcc_found ))
printf " ${BOLD_CYAN}┌─── %s [%b]${RESET}\n" "$_mcc_name" "$_mcc_running" >/dev/tty
printf " ${CYAN}${RESET} ${BOLD}Server address:${RESET} %s\n" "$_mcc_host" >/dev/tty
printf " ${CYAN}${RESET} ${BOLD}Port:${RESET} %s\n" "$_mcc_olport" >/dev/tty
printf " ${CYAN}${RESET} ${BOLD}Local SOCKS5 port:${RESET} %s ${DIM}(default for client)${RESET}\n" "$_mcc_lport" >/dev/tty
printf " ${CYAN}${RESET} ${BOLD}PSK secret key:${RESET} %s\n" "$_mcc_psk" >/dev/tty
printf " ${CYAN}└───${RESET}\n\n" >/dev/tty
unset _mcc_p
done <<< "$_mcc_profiles"
if (( _mcc_found == 0 )); then
printf " ${YELLOW}No profiles have inbound TLS+PSK configured.${RESET}\n" >/dev/tty
printf " ${DIM}Create a tunnel with inbound protection enabled to see configs here.${RESET}\n" >/dev/tty
else
printf " ${DIM}─────────────────────────────────────────────${RESET}\n" >/dev/tty
printf " ${DIM}Give clients the Server, Port, and PSK above.${RESET}\n" >/dev/tty
printf " ${DIM}They can use tunnelforge-client.bat (Windows) or the Linux script.${RESET}\n" >/dev/tty
printf " ${DIM}CLI: tunnelforge client-config <name> │ tunnelforge client-script <name>${RESET}\n" >/dev/tty
fi
printf "\n" >/dev/tty
return 0
}
# Generate a self-contained client setup script.
# Users run this on their PC and it installs stunnel + connects automatically.
# Args: name prof_ref [output_file]
_obfs_generate_client_script() {
local _name="$1"
local -n _ogs_prof="$2"
local _out="${3:-}"
local _olport="${_ogs_prof[OBFS_LOCAL_PORT]:-}"
local _psk="${_ogs_prof[OBFS_PSK]:-}"
local _lport="${_ogs_prof[LOCAL_PORT]:-}"
if [[ -z "$_olport" ]] || [[ "$_olport" == "0" ]]; then
log_error "No inbound TLS configured on profile '$_name'"
return 1
fi
if [[ -z "$_psk" ]]; then
log_error "No PSK configured on profile '$_name'"
return 1
fi
# Determine server IP
local _host=""
_host="${_ogs_prof[SSH_HOST]:-localhost}"
local _pub_ip=""
_pub_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | cut -d' ' -f2) || true
if [[ -n "$_pub_ip" ]]; then _host="$_pub_ip"; fi
# Validate interpolated values to prevent injection in generated script
if ! [[ "$_psk" =~ ^[a-fA-F0-9]+$ ]]; then
log_error "PSK contains invalid characters (expected hex)"
return 1
fi
if ! [[ "$_host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
log_error "Host contains invalid characters"
return 1
fi
if ! [[ "$_olport" =~ ^[0-9]+$ ]] || ! [[ "$_lport" =~ ^[0-9]+$ ]]; then
log_error "Port values must be numeric"
return 1
fi
# Default output file
if [[ -z "$_out" ]]; then
_out="${CONFIG_DIR}/tunnelforge-connect.sh"
fi
cat > "$_out" << CLIENTSCRIPT
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════
# TunnelForge Client Connect Script
# Generated: $(date '+%Y-%m-%d %H:%M:%S')
# Server: ${_host}:${_olport}
# ═══════════════════════════════════════════════════════
set -e
SERVER="${_host}"
PORT="${_olport}"
LOCAL_PORT="${_lport}"
PSK_IDENTITY="tunnelforge"
PSK_SECRET="${_psk}"
CONF_DIR="\${HOME}/.tunnelforge-client"
CONF_FILE="\${CONF_DIR}/stunnel.conf"
PSK_FILE="\${CONF_DIR}/psk.txt"
PID_FILE="\${CONF_DIR}/stunnel.pid"
LOG_FILE="\${CONF_DIR}/stunnel.log"
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'
BOLD='\033[1m'; DIM='\033[2m'; RESET='\033[0m'
info() { printf "\${GREEN}[+]\${RESET} %s\n" "\$1"; }
error() { printf "\${RED}[!]\${RESET} %s\n" "\$1"; }
dim() { printf "\${DIM}%s\${RESET}\n" "\$1"; }
# ── Stop ──
do_stop() {
if [[ -f "\$PID_FILE" ]]; then
local pid=""
pid=\$(cat "\$PID_FILE" 2>/dev/null) || true
if [[ -n "\$pid" ]] && kill -0 "\$pid" 2>/dev/null; then
kill "\$pid" 2>/dev/null || true
info "Disconnected (PID: \$pid)"
fi
rm -f "\$PID_FILE" 2>/dev/null || true
else
error "Not connected"
fi
exit 0
}
# ── Status ──
do_status() {
if [[ -f "\$PID_FILE" ]]; then
local pid=""
pid=\$(cat "\$PID_FILE" 2>/dev/null) || true
if [[ -n "\$pid" ]] && kill -0 "\$pid" 2>/dev/null; then
info "Connected (PID: \$pid)"
dim "SOCKS5 proxy: 127.0.0.1:\${LOCAL_PORT}"
exit 0
fi
fi
error "Not connected"
exit 1
}
case "\${1:-}" in
stop) do_stop ;;
status) do_status ;;
esac
printf "\n\${BOLD}\${CYAN}═══ TunnelForge Client ═══\${RESET}\n"
printf "\${DIM}Connecting to \${SERVER}:\${PORT} via TLS+PSK\${RESET}\n\n"
# ── Check/install stunnel ──
if ! command -v stunnel >/dev/null 2>&1 && ! command -v stunnel4 >/dev/null 2>&1; then
info "Installing stunnel..."
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq stunnel4
elif command -v dnf >/dev/null 2>&1; then
sudo dnf install -y -q stunnel
elif command -v yum >/dev/null 2>&1; then
sudo yum install -y -q stunnel
elif command -v pacman >/dev/null 2>&1; then
sudo pacman -S --noconfirm stunnel
elif command -v brew >/dev/null 2>&1; then
brew install stunnel
else
error "Cannot install stunnel automatically"
error "Install it manually: https://www.stunnel.org/downloads.html"
exit 1
fi
fi
if ! command -v stunnel >/dev/null 2>&1 && ! command -v stunnel4 >/dev/null 2>&1; then
error "stunnel installation failed"
exit 1
fi
info "stunnel found"
# ── Already running? ──
if [[ -f "\$PID_FILE" ]]; then
old_pid=\$(cat "\$PID_FILE" 2>/dev/null) || true
if [[ -n "\$old_pid" ]] && kill -0 "\$old_pid" 2>/dev/null; then
info "Already connected (PID: \$old_pid)"
dim "SOCKS5 proxy: 127.0.0.1:\${LOCAL_PORT}"
dim "Run '\$0 stop' to disconnect"
exit 0
fi
rm -f "\$PID_FILE" 2>/dev/null || true
fi
# ── Write config ──
mkdir -p "\$CONF_DIR" 2>/dev/null
chmod 700 "\$CONF_DIR" 2>/dev/null || true
printf '%s:%s\n' "\$PSK_IDENTITY" "\$PSK_SECRET" > "\$PSK_FILE"
chmod 600 "\$PSK_FILE"
cat > "\$CONF_FILE" << STCONF
; TunnelForge client config
pid = \${PID_FILE}
output = \${LOG_FILE}
foreground = no
[tunnelforge]
client = yes
accept = 127.0.0.1:\${LOCAL_PORT}
connect = \${SERVER}:\${PORT}
PSKsecrets = \${PSK_FILE}
ciphers = PSK
STCONF
# ── Connect ──
STUNNEL_BIN="stunnel"
if ! command -v stunnel >/dev/null 2>&1; then STUNNEL_BIN="stunnel4"; fi
"\$STUNNEL_BIN" "\$CONF_FILE" 2>/dev/null || {
error "Failed to connect (check \$LOG_FILE)"
exit 1
}
sleep 1
if [[ -f "\$PID_FILE" ]]; then
pid=\$(cat "\$PID_FILE" 2>/dev/null) || true
if [[ -n "\$pid" ]] && kill -0 "\$pid" 2>/dev/null; then
printf "\n"
info "Connected! (PID: \$pid)"
printf "\n"
printf " \${BOLD}SOCKS5 proxy:\${RESET} 127.0.0.1:\${LOCAL_PORT}\n"
printf "\n"
dim " Browser setup: Settings → Proxy → Manual"
dim " SOCKS Host: 127.0.0.1 Port: \${LOCAL_PORT}"
dim " Select SOCKS v5, enable Proxy DNS"
printf "\n"
dim " Commands:"
dim " \$0 status — check connection"
dim " \$0 stop — disconnect"
printf "\n"
exit 0
fi
fi
error "Connection failed (check \$LOG_FILE)"
exit 1
CLIENTSCRIPT
chmod +x "$_out" 2>/dev/null || true
log_success "Linux/Mac script: $_out"
printf " ${BOLD}./tunnelforge-connect.sh${RESET} # Connect\n"
printf " ${BOLD}./tunnelforge-connect.sh stop${RESET} # Disconnect\n"
printf " ${BOLD}./tunnelforge-connect.sh status${RESET} # Check status\n\n"
return 0
}
# Generate a Windows PowerShell client connect script.
# Args: name prof_ref [output_file]
_obfs_generate_client_script_win() {
local _name="$1"
local -n _ogw_prof="$2"
local _out="${3:-}"
local _olport="${_ogw_prof[OBFS_LOCAL_PORT]:-}"
local _psk="${_ogw_prof[OBFS_PSK]:-}"
local _lport="${_ogw_prof[LOCAL_PORT]:-}"
if [[ -z "$_olport" ]] || [[ "$_olport" == "0" ]]; then
log_error "No inbound TLS configured on profile '$_name'"
return 1
fi
if [[ -z "$_psk" ]]; then
log_error "No PSK configured on profile '$_name'"
return 1
fi
local _host=""
_host="${_ogw_prof[SSH_HOST]:-localhost}"
local _pub_ip=""
_pub_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | cut -d' ' -f2) || true
if [[ -n "$_pub_ip" ]]; then _host="$_pub_ip"; fi
# Validate interpolated values to prevent injection in generated script
if ! [[ "$_psk" =~ ^[a-fA-F0-9]+$ ]]; then
log_error "PSK contains invalid characters (expected hex)"
return 1
fi
if ! [[ "$_host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
log_error "Host contains invalid characters"
return 1
fi
if ! [[ "$_olport" =~ ^[0-9]+$ ]] || ! [[ "$_lport" =~ ^[0-9]+$ ]]; then
log_error "Port values must be numeric"
return 1
fi
if [[ -z "$_out" ]]; then
_out="${CONFIG_DIR}/tunnelforge-connect.ps1"
fi
cat > "$_out" << 'WINSCRIPT_TOP'
# ═══════════════════════════════════════════════════════
# TunnelForge Client Connect Script (Windows)
WINSCRIPT_TOP
cat >> "$_out" << WINSCRIPT_VARS
# Generated: $(date '+%Y-%m-%d %H:%M:%S')
# Server: ${_host}:${_olport}
# ═══════════════════════════════════════════════════════
\$Server = "${_host}"
\$Port = "${_olport}"
\$LocalPort = "${_lport}"
\$PskIdentity = "tunnelforge"
\$PskSecret = "${_psk}"
WINSCRIPT_VARS
cat >> "$_out" << 'WINSCRIPT_BODY'
$ConfDir = "$env:USERPROFILE\.tunnelforge-client"
$StunnelDir = "$ConfDir\stunnel"
$ConfFile = "$ConfDir\stunnel.conf"
$PskFile = "$ConfDir\psk.txt"
$PidFile = "$ConfDir\stunnel.pid"
$LogFile = "$ConfDir\stunnel.log"
$StunnelExe = "$StunnelDir\stunnel.exe"
$StunnelZip = "$ConfDir\stunnel.zip"
$StunnelUrl = "https://www.stunnel.org/downloads/stunnel-5.72-win64-installer.exe"
function Write-Info($msg) { Write-Host "[+] $msg" -ForegroundColor Green }
function Write-Err($msg) { Write-Host "[!] $msg" -ForegroundColor Red }
function Write-Dim($msg) { Write-Host " $msg" -ForegroundColor DarkGray }
# ── Stop ──
if ($args[0] -eq "stop") {
if (Test-Path $PidFile) {
$pid = Get-Content $PidFile -ErrorAction SilentlyContinue
if ($pid) {
try { Stop-Process -Id $pid -Force -ErrorAction Stop; Write-Info "Disconnected (PID: $pid)" }
catch { Write-Err "Process $pid not found" }
}
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
} else { Write-Err "Not connected" }
exit
}
# ── Status ──
if ($args[0] -eq "status") {
if (Test-Path $PidFile) {
$pid = Get-Content $PidFile -ErrorAction SilentlyContinue
if ($pid) {
try {
Get-Process -Id $pid -ErrorAction Stop | Out-Null
Write-Info "Connected (PID: $pid)"
Write-Dim "SOCKS5 proxy: 127.0.0.1:$LocalPort"
exit 0
} catch {}
}
}
Write-Err "Not connected"
exit 1
}
Write-Host ""
Write-Host "=== TunnelForge Client ===" -ForegroundColor Cyan
Write-Host "Connecting to ${Server}:${Port} via TLS+PSK" -ForegroundColor DarkGray
Write-Host ""
# ── Create config dir ──
if (-not (Test-Path $ConfDir)) { New-Item -ItemType Directory -Path $ConfDir -Force | Out-Null }
# ── Find or install stunnel ──
$stunnel = $null
# Check common locations
$searchPaths = @(
"$StunnelExe",
"C:\Program Files (x86)\stunnel\bin\stunnel.exe",
"C:\Program Files\stunnel\bin\stunnel.exe",
"$env:ProgramFiles\stunnel\bin\stunnel.exe",
"${env:ProgramFiles(x86)}\stunnel\bin\stunnel.exe"
)
foreach ($p in $searchPaths) {
if (Test-Path $p) { $stunnel = $p; break }
}
# Check PATH
if (-not $stunnel) {
$inPath = Get-Command stunnel -ErrorAction SilentlyContinue
if ($inPath) { $stunnel = $inPath.Source }
}
if (-not $stunnel) {
Write-Info "stunnel not found. Please install it:"
Write-Host ""
Write-Host " Option 1: Download from https://www.stunnel.org/downloads.html" -ForegroundColor Yellow
Write-Host " Install the Win64 version" -ForegroundColor DarkGray
Write-Host ""
Write-Host " Option 2: Using winget:" -ForegroundColor Yellow
Write-Host " winget install stunnel" -ForegroundColor White
Write-Host ""
Write-Host " Option 3: Using chocolatey:" -ForegroundColor Yellow
Write-Host " choco install stunnel" -ForegroundColor White
Write-Host ""
Write-Host "After installing, run this script again." -ForegroundColor DarkGray
exit 1
}
Write-Info "stunnel found: $stunnel"
# ── Check if already running ──
if (Test-Path $PidFile) {
$oldPid = Get-Content $PidFile -ErrorAction SilentlyContinue
if ($oldPid) {
try {
Get-Process -Id $oldPid -ErrorAction Stop | Out-Null
Write-Info "Already connected (PID: $oldPid)"
Write-Dim "SOCKS5 proxy: 127.0.0.1:$LocalPort"
Write-Dim "Run '$($MyInvocation.MyCommand.Name) stop' to disconnect"
exit 0
} catch {}
}
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
}
# ── Write config files ──
Set-Content -Path $PskFile -Value "${PskIdentity}:${PskSecret}" -Force
Set-Content -Path $ConfFile -Value @"
; TunnelForge client config
pid = $PidFile
output = $LogFile
foreground = no
[tunnelforge]
client = yes
accept = 127.0.0.1:$LocalPort
connect = ${Server}:${Port}
PSKsecrets = $PskFile
ciphers = PSK
"@ -Force
# ── Connect ──
Write-Info "Connecting..."
$proc = Start-Process -FilePath $stunnel -ArgumentList "`"$ConfFile`"" -PassThru -NoNewWindow -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
# Check if stunnel created a PID file or is running
if (Test-Path $PidFile) {
$newPid = Get-Content $PidFile -ErrorAction SilentlyContinue
if ($newPid) {
try {
Get-Process -Id $newPid -ErrorAction Stop | Out-Null
Write-Host ""
Write-Info "Connected! (PID: $newPid)"
Write-Host ""
Write-Host " SOCKS5 proxy: 127.0.0.1:$LocalPort" -ForegroundColor White
Write-Host ""
Write-Dim "Browser setup: Settings > Proxy > Manual"
Write-Dim " SOCKS Host: 127.0.0.1 Port: $LocalPort"
Write-Dim " Select SOCKS v5, enable Proxy DNS"
Write-Host ""
Write-Dim "Commands:"
Write-Dim " .\$($MyInvocation.MyCommand.Name) status - check connection"
Write-Dim " .\$($MyInvocation.MyCommand.Name) stop - disconnect"
Write-Host ""
exit 0
} catch {}
}
}
# Fallback: check if process is running
if ($proc -and -not $proc.HasExited) {
Write-Host ""
Write-Info "Connected! (PID: $($proc.Id))"
Set-Content -Path $PidFile -Value $proc.Id
Write-Host ""
Write-Host " SOCKS5 proxy: 127.0.0.1:$LocalPort" -ForegroundColor White
Write-Host ""
exit 0
}
Write-Err "Connection failed (check $LogFile)"
exit 1
WINSCRIPT_BODY
log_success "Windows script: $_out"
printf " ${BOLD}powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1${RESET} # Connect\n"
printf " ${BOLD}powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 stop${RESET} # Disconnect\n"
printf " ${BOLD}powershell -ExecutionPolicy Bypass -File tunnelforge-connect.ps1 status${RESET} # Check\n\n"
printf "${DIM}Send both files to users — .sh for Linux/Mac, .ps1 for Windows.${RESET}\n\n"
return 0
}
# ── Systemd Service Management ──
# Generates and manages systemd unit files for tunnel profiles
_service_unit_path() {
printf "%s/tunnelforge-%s.service" "$_SYSTEMD_DIR" "$1"
}
generate_service() {
local name="$1"
if [[ $EUID -ne 0 ]]; then
log_error "Service management requires root privileges"
return 1
fi
if [[ "$INIT_SYSTEM" != "systemd" ]]; then
log_error "Systemd is required (detected: ${INIT_SYSTEM})"
return 1
fi
local -A _svc_prof
load_profile "$name" _svc_prof 2>/dev/null || {
log_error "Cannot load profile '${name}'"
return 1
}
local tunnel_type="${_svc_prof[TUNNEL_TYPE]:-socks5}"
local ssh_host="${_svc_prof[SSH_HOST]:-}"
local ssh_port="${_svc_prof[SSH_PORT]:-22}"
local ssh_user="${_svc_prof[SSH_USER]:-$(config_get SSH_DEFAULT_USER root)}"
if [[ -z "$ssh_host" ]]; then
log_error "No SSH host in profile '${name}'"
return 1
fi
local unit_file
unit_file=$(_service_unit_path "$name")
# Escape % for systemd specifier safety in all user-derived fields
local safe_name="${name//%/%%}"
local exec_cmd="${INSTALL_DIR}/tunnelforge.sh start ${safe_name}"
# Build description
local desc="TunnelForge ${tunnel_type} tunnel '${name}' (${ssh_user}@${ssh_host}:${ssh_port})"
desc="${desc//%/%%}"
{
printf "[Unit]\n"
printf "Description=%s\n" "$desc"
printf "Documentation=man:ssh(1)\n"
printf "After=network-online.target\n"
printf "Wants=network-online.target\n"
printf "StartLimitIntervalSec=60\n"
printf "StartLimitBurst=3\n"
printf "\n"
printf "[Service]\n"
printf "Type=oneshot\n"
printf "RemainAfterExit=yes\n"
printf "ExecStart=%s\n" "$exec_cmd"
printf "ExecStop=%s stop %s\n" "${INSTALL_DIR}/tunnelforge.sh" "$safe_name"
printf "TimeoutStartSec=30\n"
printf "TimeoutStopSec=15\n"
printf "\n"
printf "# Security sandboxing\n"
local _needs_net_admin=false _needs_resolv=false
if [[ "${_svc_prof[KILL_SWITCH]:-}" == "true" ]]; then _needs_net_admin=true; fi
if [[ "${_svc_prof[DNS_LEAK_PROTECTION]:-}" == "true" ]]; then _needs_resolv=true; fi
printf "ProtectSystem=strict\n"
printf "ProtectHome=tmpfs\n"
local _svc_home
_svc_home=$(getent passwd root 2>/dev/null | cut -d: -f6) || true
: "${_svc_home:=/root}"
printf "BindReadOnlyPaths=%s/.ssh\n" "$_svc_home"
printf "ReadWritePaths=%s\n" "$INSTALL_DIR"
if [[ "$_needs_resolv" == true ]]; then
printf "ReadWritePaths=/etc\n"
fi
printf "PrivateTmp=true\n"
# Build capabilities dynamically based on features
local _caps=""
if [[ "$_needs_net_admin" == true ]]; then _caps="CAP_NET_ADMIN CAP_NET_RAW"; fi
if [[ "$_needs_resolv" == true ]]; then
if [[ -n "$_caps" ]]; then _caps="${_caps} CAP_LINUX_IMMUTABLE"; else _caps="CAP_LINUX_IMMUTABLE"; fi
fi
if [[ -n "$_caps" ]]; then
printf "AmbientCapabilities=%s\n" "$_caps"
printf "CapabilityBoundingSet=%s\n" "$_caps"
else
printf "NoNewPrivileges=true\n"
fi
printf "\n"
printf "[Install]\n"
printf "WantedBy=multi-user.target\n"
} > "$unit_file" 2>/dev/null || {
log_error "Failed to write service file: ${unit_file}"
return 1
}
chmod 644 "$unit_file" 2>/dev/null || true
systemctl daemon-reload 2>/dev/null || true
log_success "Service file created: ${unit_file}"
printf "\n${DIM}Manage with:${RESET}\n"
printf " systemctl enable tunnelforge-%s ${DIM}# Start on boot${RESET}\n" "$name"
printf " systemctl start tunnelforge-%s ${DIM}# Start now${RESET}\n" "$name"
printf " systemctl status tunnelforge-%s ${DIM}# Check status${RESET}\n" "$name"
printf " systemctl stop tunnelforge-%s ${DIM}# Stop${RESET}\n" "$name"
printf " systemctl disable tunnelforge-%s ${DIM}# Remove from boot${RESET}\n\n" "$name"
return 0
}
enable_service() {
local name="$1"
if [[ $EUID -ne 0 ]]; then
log_error "Service management requires root privileges"
return 1
fi
local unit_file
unit_file=$(_service_unit_path "$name")
if [[ ! -f "$unit_file" ]]; then
log_info "No service file found, generating..."
generate_service "$name" || return 1
fi
systemctl enable "tunnelforge-${name}" 2>/dev/null || {
log_error "Failed to enable service"
return 1
}
systemctl start "tunnelforge-${name}" 2>/dev/null || {
log_error "Failed to start service"
return 1
}
log_success "Service tunnelforge-${name} enabled and started"
return 0
}
disable_service() {
local name="$1"
if [[ $EUID -ne 0 ]]; then
log_error "Service management requires root privileges"
return 1
fi
systemctl stop "tunnelforge-${name}" 2>/dev/null || true
systemctl disable "tunnelforge-${name}" 2>/dev/null || true
log_success "Service tunnelforge-${name} stopped and disabled"
return 0
}
remove_service() {
local name="$1"
if [[ $EUID -ne 0 ]]; then
log_error "Service management requires root privileges"
return 1
fi
disable_service "$name" || true
local unit_file
unit_file=$(_service_unit_path "$name")
if [[ -f "$unit_file" ]]; then
rm -f "$unit_file" 2>/dev/null || true
systemctl daemon-reload 2>/dev/null || true
log_success "Removed service file: ${unit_file}"
else
log_info "No service file to remove"
fi
return 0
}
service_status() {
local name="$1"
local unit_name="tunnelforge-${name}"
local unit_file
unit_file=$(_service_unit_path "$name")
printf "\n${BOLD}Service: %s${RESET}\n" "$unit_name"
if [[ ! -f "$unit_file" ]]; then
printf " ${DIM}${RESET} No service file\n\n"
return 0
fi
local _svc_active _svc_enabled
_svc_active=$(systemctl is-active "$unit_name" 2>/dev/null) || true
_svc_enabled=$(systemctl is-enabled "$unit_name" 2>/dev/null) || true
if [[ "$_svc_active" == "active" ]]; then
printf " ${GREEN}${RESET} Status: active (running)\n"
elif [[ "$_svc_active" == "activating" ]]; then
printf " ${YELLOW}${RESET} Status: activating\n"
else
printf " ${DIM}${RESET} Status: %s\n" "${_svc_active:-unknown}"
fi
if [[ "$_svc_enabled" == "enabled" ]]; then
printf " ${GREEN}${RESET} Boot: enabled\n"
else
printf " ${DIM}${RESET} Boot: %s\n" "${_svc_enabled:-disabled}"
fi
# Show recent log entries
if command -v journalctl &>/dev/null; then
printf "\n${DIM}Recent logs (last 5 lines):${RESET}\n"
journalctl -u "$unit_name" --no-pager -n 5 2>/dev/null || true
fi
printf "\n"
return 0
}
# ── Service interactive menu ──
_menu_service() {
local name="$1"
while true; do
clear >/dev/tty 2>/dev/null || true
printf "\n${BOLD_CYAN}═══ Service Manager: %s ═══${RESET}\n\n" "$name" >/dev/tty
printf " ${CYAN}1${RESET}) Generate service file\n" >/dev/tty
printf " ${CYAN}2${RESET}) Enable + start service\n" >/dev/tty
printf " ${CYAN}3${RESET}) Disable + stop service\n" >/dev/tty
printf " ${CYAN}4${RESET}) Show service status\n" >/dev/tty
printf " ${CYAN}5${RESET}) Remove service file\n" >/dev/tty
printf " ${YELLOW}q${RESET}) Back\n\n" >/dev/tty
local _sv_choice
printf " ${BOLD}Select${RESET}: " >/dev/tty
read -rsn1 _sv_choice </dev/tty || true
_drain_esc _sv_choice
printf "\n" >/dev/tty
case "$_sv_choice" in
1) generate_service "$name" || true; _press_any_key ;;
2) enable_service "$name" || true; _press_any_key ;;
3) disable_service "$name" || true; _press_any_key ;;
4) service_status "$name" || true; _press_any_key ;;
5)
if confirm_action "Remove service for '${name}'?"; then
remove_service "$name" || true
fi
_press_any_key ;;
q|Q) return 0 ;;
*) true ;;
esac
done
}
# ── Backup & Restore ──
backup_tunnelforge() {
local timestamp
timestamp=$(date '+%Y%m%d_%H%M%S')
local backup_name="tunnelforge_backup_${timestamp}.tar.gz"
local backup_path="${BACKUP_DIR}/${backup_name}"
mkdir -p "$BACKUP_DIR" 2>/dev/null || true
log_info "Creating backup: ${backup_name}..."
# Build list of paths to include
local -a _bk_paths=()
if [[ -d "$CONFIG_DIR" ]]; then _bk_paths+=("$CONFIG_DIR"); fi
if [[ -d "$PROFILES_DIR" ]]; then _bk_paths+=("$PROFILES_DIR"); fi
if [[ -d "$DATA_DIR" ]]; then _bk_paths+=("$DATA_DIR"); fi
# Include security-related backups (sshd_config, iptables rules)
if [[ -d "$BACKUP_DIR" ]]; then
_bk_paths+=("$BACKUP_DIR")
fi
if [[ ${#_bk_paths[@]} -eq 0 ]]; then
log_error "Nothing to backup"
return 1
fi
if tar czf "$backup_path" --exclude='*.tar.gz' "${_bk_paths[@]}" 2>/dev/null; then
chmod 600 "$backup_path" 2>/dev/null || true
local _bk_size
_bk_size=$(stat -c %s "$backup_path" 2>/dev/null || stat -f %z "$backup_path" 2>/dev/null) || true
_bk_size=$(format_bytes "${_bk_size:-0}")
log_success "Backup created: ${backup_path} (${_bk_size})"
# Rotate old backups (keep last 5)
local _bk_count
_bk_count=$(find "$BACKUP_DIR" -name "tunnelforge_backup_*.tar.gz" -type f 2>/dev/null | wc -l) || true
: "${_bk_count:=0}"
if (( _bk_count > 5 )); then
find "$BACKUP_DIR" -name "tunnelforge_backup_*.tar.gz" -type f 2>/dev/null | \
sort | head -n $(( _bk_count - 5 )) | while IFS= read -r _old_bk; do
rm -f "$_old_bk" 2>/dev/null
done || true
log_debug "Rotated old backups"
fi
else
rm -f "$backup_path" 2>/dev/null || true
log_error "Failed to create backup"
return 1
fi
return 0
}
restore_tunnelforge() {
if [[ $EUID -ne 0 ]]; then
log_error "Restore requires root privileges"
return 1
fi
local backup_file="${1:-}"
# If no file specified, find the latest
if [[ -z "$backup_file" ]]; then
backup_file=$(find "$BACKUP_DIR" -name "tunnelforge_backup_*.tar.gz" -type f 2>/dev/null | \
sort -r | head -1) || true
if [[ -z "$backup_file" ]]; then
log_error "No backup files found in ${BACKUP_DIR}"
return 1
fi
log_info "Found backup: $(basename "$backup_file")"
fi
if [[ ! -f "$backup_file" ]]; then
log_error "Backup file not found: ${backup_file}"
return 1
fi
printf "\n${BOLD}Backup contents:${RESET}\n"
if ! tar tzf "$backup_file" >/dev/null 2>&1; then
log_error "Cannot read backup archive"
return 1
fi
tar tzf "$backup_file" 2>/dev/null | head -20 || true
printf "${DIM} ... (truncated)${RESET}\n\n"
if ! confirm_action "Restore from this backup? (current config will be overwritten)"; then
log_info "Restore cancelled"
return 0
fi
log_info "Restoring from backup..."
# Pre-scan archive for path traversal and symlink attacks
local _bad_paths _symlinks _tar_listing
_tar_listing=$(tar tzf "$backup_file" 2>/dev/null || true)
_bad_paths=$(printf '%s\n' "$_tar_listing" | grep -E '(^|/)\.\.(/|$)|^/' || true)
if [[ -n "$_bad_paths" ]]; then
log_error "Backup archive contains unsafe paths (potential path traversal)"
log_error "Suspicious entries: $(printf '%s' "$_bad_paths" | head -5)"
return 1
fi
# Check for symlinks in the archive (tar tvf shows 'l' type)
_symlinks=$(tar tvzf "$backup_file" 2>/dev/null | grep -E '^l' || true)
if [[ -n "$_symlinks" ]]; then
log_error "Backup archive contains symlinks (potential symlink attack)"
log_error "Suspicious entries: $(printf '%s' "$_symlinks" | head -5)"
return 1
fi
# Feature-detect --no-unsafe-links support
local -a _tar_safe_opts=()
if tar --help 2>&1 | grep -q -- '--no-unsafe-links' 2>/dev/null; then
_tar_safe_opts=(--no-unsafe-links)
fi
if tar xzf "$backup_file" -C / --no-same-owner --no-same-permissions "${_tar_safe_opts[@]}" 2>/dev/null; then
log_success "Backup restored successfully"
log_info "Reapplying directory permissions..."
init_directories 2>/dev/null || true
# Secure config and profile files
find "${CONFIG_DIR}" -type f -exec chmod 600 {} \; 2>/dev/null || true
find "${PROFILES_DIR}" -type f -exec chmod 600 {} \; 2>/dev/null || true
log_info "Reloading settings..."
load_settings || true
else
log_error "Failed to restore backup"
return 1
fi
return 0
}
# ── Backup interactive menu ──
_menu_backup_restore() {
while true; do
clear >/dev/tty 2>/dev/null || true
printf "\n${BOLD_CYAN}═══ Backup & Restore ═══${RESET}\n\n" >/dev/tty
printf " ${CYAN}1${RESET}) Create backup now\n" >/dev/tty
printf " ${CYAN}2${RESET}) Restore from latest backup\n" >/dev/tty
printf " ${CYAN}3${RESET}) List available backups\n" >/dev/tty
printf " ${YELLOW}q${RESET}) Back\n\n" >/dev/tty
local _br_choice
printf " ${BOLD}Select${RESET}: " >/dev/tty
read -rsn1 _br_choice </dev/tty || true
_drain_esc _br_choice
printf "\n" >/dev/tty
case "$_br_choice" in
1) backup_tunnelforge || true; _press_any_key ;;
2) restore_tunnelforge || true; _press_any_key ;;
3)
printf "\n${BOLD}Available Backups:${RESET}\n"
local _br_found=false
local _br_f
while IFS= read -r _br_f; do
[[ -z "$_br_f" ]] && continue
_br_found=true
local _br_sz
_br_sz=$(stat -c %s "$_br_f" 2>/dev/null || stat -f %z "$_br_f" 2>/dev/null) || true
_br_sz=$(format_bytes "${_br_sz:-0}")
printf " ${CYAN}${RESET} %s ${DIM}(%s)${RESET}\n" "$(basename "$_br_f")" "$_br_sz"
done < <(find "$BACKUP_DIR" -name "tunnelforge_backup_*.tar.gz" -type f 2>/dev/null | sort -r || true)
if [[ "$_br_found" != true ]]; then
printf " ${DIM}No backups found${RESET}\n"
fi
printf "\n"
_press_any_key ;;
q|Q) return 0 ;;
*) true ;;
esac
done
}
# ── Uninstall ──
uninstall_tunnelforge() {
if [[ $EUID -ne 0 ]]; then
log_error "Uninstall requires root privileges"
return 1
fi
printf "\n${BOLD_RED}═══ TunnelForge Uninstall ═══${RESET}\n\n"
printf "This will remove:\n"
printf " - All tunnel profiles and configuration\n"
printf " - Systemd service files\n"
printf " - Installation directory (%s)\n" "$INSTALL_DIR"
printf " - CLI symlink (%s)\n" "$BIN_LINK"
printf "\n${YELLOW}This will NOT remove:${RESET}\n"
printf " - Your SSH keys (~/.ssh/)\n"
printf " - Final backup (saved to ~/)\n\n"
if ! confirm_action "Are you absolutely sure you want to uninstall?"; then
log_info "Uninstall cancelled"
return 0
fi
# Offer backup BEFORE any destructive operations
if confirm_action "Create a backup before uninstalling?"; then
backup_tunnelforge || true
local _ui_bk
_ui_bk=$(find "$BACKUP_DIR" -name "tunnelforge_backup_*.tar.gz" -type f 2>/dev/null | sort -r | head -1) || true
if [[ -n "$_ui_bk" ]]; then
local _ui_home_bk="${HOME}/tunnelforge_final_backup.tar.gz"
if cp "$_ui_bk" "$_ui_home_bk" 2>/dev/null; then
log_info "Backup saved to: ${_ui_home_bk}"
else
log_error "Failed to copy backup to ${_ui_home_bk}"
if ! confirm_action "Continue uninstall WITHOUT backup?"; then
return 0
fi
fi
fi
fi
# Stop all running tunnels
log_info "Stopping all tunnels..."
stop_all_tunnels 2>/dev/null || true
# Remove systemd services
log_info "Removing systemd services..."
local _ui_svc
while IFS= read -r _ui_svc; do
[[ -z "$_ui_svc" ]] && continue
local _ui_name
_ui_name=$(basename "$_ui_svc" .service)
_ui_name="${_ui_name#tunnelforge-}"
systemctl stop "tunnelforge-${_ui_name}" 2>/dev/null || true
systemctl disable "tunnelforge-${_ui_name}" 2>/dev/null || true
rm -f "$_ui_svc" 2>/dev/null || true
done < <(find "$_SYSTEMD_DIR" -name "tunnelforge-*.service" -type f 2>/dev/null || true)
systemctl daemon-reload 2>/dev/null || true
# Disable security features if active
if is_dns_leak_protected; then
log_info "Disabling DNS leak protection..."
disable_dns_leak_protection 2>/dev/null || true
fi
if is_kill_switch_active; then
log_info "Disabling kill switch..."
local _fw_cmd
for _fw_cmd in iptables ip6tables; do
if command -v "$_fw_cmd" &>/dev/null; then
"$_fw_cmd" -D OUTPUT -j "$_TF_CHAIN" 2>/dev/null || true
"$_fw_cmd" -D FORWARD -j "$_TF_CHAIN" 2>/dev/null || true
"$_fw_cmd" -F "$_TF_CHAIN" 2>/dev/null || true
"$_fw_cmd" -X "$_TF_CHAIN" 2>/dev/null || true
fi
done
fi
# Remove symlink
rm -f "$BIN_LINK" 2>/dev/null || true
log_success "Removed CLI symlink"
# Remove sysctl config
rm -f /etc/sysctl.d/99-tunnelforge.conf 2>/dev/null || true
sysctl --system >/dev/null 2>&1 || true
# Remove fail2ban jail and reload
rm -f /etc/fail2ban/jail.d/tunnelforge-sshd.conf 2>/dev/null || true
systemctl reload fail2ban 2>/dev/null || systemctl restart fail2ban 2>/dev/null || true
# Restore original sshd_config if we have a backup
if [[ -f "$_SSHD_BACKUP" ]]; then
log_info "Restoring original sshd_config..."
if cp "$_SSHD_BACKUP" "$_SSHD_CONFIG" 2>/dev/null; then
systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null || true
log_success "Restored original sshd_config"
else
log_warn "Could not restore sshd_config"
fi
fi
# Remove data dirs first, install dir last (contains running script)
rm -rf "${PID_DIR}" 2>/dev/null || true
rm -rf "${LOG_DIR}" 2>/dev/null || true
rm -rf "${DATA_DIR}" 2>/dev/null || true
rm -rf "${SSH_CONTROL_DIR}" 2>/dev/null || true
rm -rf "${PROFILES_DIR}" 2>/dev/null || true
rm -rf "${CONFIG_DIR}" 2>/dev/null || true
# Print farewell BEFORE deleting the script itself
printf "\n${BOLD_GREEN}TunnelForge has been uninstalled.${RESET}\n" >/dev/tty 2>/dev/null || true
printf "${DIM}Thank you for using TunnelForge!${RESET}\n\n" >/dev/tty 2>/dev/null || true
rm -rf "${INSTALL_DIR}" 2>/dev/null || true
return 0
}
# ── Self-Update ──────────────────────────────────────────────────────────────
update_tunnelforge() {
if [[ $EUID -ne 0 ]]; then
log_error "Update requires root privileges"
return 1
fi
printf "\n${BOLD_CYAN}═══ TunnelForge Update ═══${RESET}\n\n" >/dev/tty
local _script_path="${INSTALL_DIR}/tunnelforge.sh"
if [[ ! -f "$_script_path" ]]; then
log_error "TunnelForge not installed at ${_script_path}"
return 1
fi
# Download latest script to temp file
printf " Checking for updates..." >/dev/tty
local _tmp_file=""
_tmp_file=$(mktemp /tmp/tunnelforge-update.XXXXXX) || { log_error "Failed to create temp file"; return 1; }
if ! curl -sf --connect-timeout 10 --max-time 60 \
"${GITEA_RAW}/tunnelforge.sh" \
-o "$_tmp_file" 2>/dev/null; then
printf "\r \r" >/dev/tty
log_error "Could not reach update server (check your internet connection)"
rm -f "$_tmp_file" 2>/dev/null || true
return 1
fi
printf "\r \r" >/dev/tty
# Compare SHA256 of installed vs remote
local _local_sha="" _remote_sha=""
_local_sha=$(sha256sum "$_script_path" 2>/dev/null | cut -d' ' -f1) || true
_remote_sha=$(sha256sum "$_tmp_file" 2>/dev/null | cut -d' ' -f1) || true
if [[ -z "$_remote_sha" ]] || [[ -z "$_local_sha" ]]; then
log_error "Failed to compute file checksums"
rm -f "$_tmp_file" 2>/dev/null || true
return 1
fi
if [[ "$_local_sha" == "$_remote_sha" ]]; then
printf " ${GREEN}Already up to date${RESET} ${DIM}(v%s)${RESET}\n\n" "$VERSION" >/dev/tty
rm -f "$_tmp_file" 2>/dev/null || true
return 0
fi
# Update available — show info and ask
local _remote_ver=""
_remote_ver=$(grep -oE 'readonly VERSION="[^"]+"' "$_tmp_file" 2>/dev/null \
| head -1 | grep -oE '"[^"]+"' | tr -d '"') || true
printf " ${YELLOW}Update available${RESET}\n" >/dev/tty
if [[ -n "$_remote_ver" ]] && [[ "$_remote_ver" != "$VERSION" ]]; then
printf " Installed : ${DIM}v%s${RESET}\n" "$VERSION" >/dev/tty
printf " Latest : ${BOLD}v%s${RESET}\n\n" "$_remote_ver" >/dev/tty
else
printf " ${DIM}New changes available (v%s)${RESET}\n\n" "$VERSION" >/dev/tty
fi
if ! confirm_action "Install update?"; then
printf "\n ${DIM}Update skipped.${RESET}\n\n" >/dev/tty
rm -f "$_tmp_file" 2>/dev/null || true
return 0
fi
# Validate downloaded script
if ! bash -n "$_tmp_file" 2>/dev/null; then
log_error "Downloaded file failed syntax check — aborting"
rm -f "$_tmp_file" 2>/dev/null || true
return 1
fi
# Backup current script
if [[ -f "$_script_path" ]]; then
cp "$_script_path" "${_script_path}.bak" 2>/dev/null || true
fi
# Replace script
mv "$_tmp_file" "$_script_path" || { log_error "Failed to install update"; rm -f "$_tmp_file" 2>/dev/null || true; return 1; }
chmod +x "$_script_path" 2>/dev/null || true
if [[ -n "$_remote_ver" ]] && [[ "$_remote_ver" != "$VERSION" ]]; then
printf "\n ${BOLD_GREEN}Updated successfully${RESET} ${DIM}(v%s → v%s)${RESET}\n" \
"$VERSION" "$_remote_ver" >/dev/tty
else
printf "\n ${BOLD_GREEN}Updated successfully${RESET}\n" >/dev/tty
fi
printf " ${DIM}Running tunnels are not affected.${RESET}\n" >/dev/tty
printf " ${DIM}Previous version backed up to %s.bak${RESET}\n\n" "$_script_path" >/dev/tty
return 0
}
# ============================================================================
# TELEGRAM NOTIFICATIONS (Phase 6)
# ============================================================================
readonly _TG_API="https://api.telegram.org"
_telegram_enabled() {
[[ "$(config_get TELEGRAM_ENABLED false)" == "true" ]] || return 1
[[ -n "$(config_get TELEGRAM_BOT_TOKEN)" ]] || return 1
[[ -n "$(config_get TELEGRAM_CHAT_ID)" ]] || return 1
return 0
}
# Find a running SOCKS5 proxy port for Telegram API calls
# (Telegram may be blocked on the local network)
_tg_find_proxy() {
local _pn _pt _pp
for _pn in $(list_profiles 2>/dev/null); do
if is_tunnel_running "$_pn" 2>/dev/null; then
_pt=$(get_profile_field "$_pn" "TUNNEL_TYPE" 2>/dev/null) || true
if [[ "$_pt" == "socks5" ]]; then
_pp=$(get_profile_field "$_pn" "LOCAL_PORT" 2>/dev/null) || true
if [[ -n "$_pp" ]]; then
printf '%s' "$_pp"
return 0
fi
fi
fi
done
return 1
}
# Build curl proxy args if a SOCKS5 tunnel is available
# Sets _TG_PROXY_ARGS array for caller
_tg_proxy_args() {
_TG_PROXY_ARGS=()
local _proxy_port
_proxy_port=$(_tg_find_proxy 2>/dev/null) || true
if [[ -n "$_proxy_port" ]]; then
_TG_PROXY_ARGS=(--socks5-hostname "127.0.0.1:${_proxy_port}")
fi
return 0
}
# Send a message via Telegram Bot API
# Usage: _telegram_send "message text" [parse_mode]
_telegram_send() {
local message="$1"
local parse_mode="${2:-}"
local token chat_id
token=$(config_get TELEGRAM_BOT_TOKEN "")
chat_id=$(config_get TELEGRAM_CHAT_ID "")
[[ -n "$token" ]] && [[ -n "$chat_id" ]] || return 1
local _tg_url="${_TG_API}/bot${token}/sendMessage"
log_debug "Telegram send to chat ${chat_id}"
local -a _TG_PROXY_ARGS=()
_tg_proxy_args || true
local -a curl_args=(
-s --max-time 15
"${_TG_PROXY_ARGS[@]}"
-X POST
--data-urlencode "chat_id=${chat_id}"
--data-urlencode "text=${message}"
--data-urlencode "disable_web_page_preview=true"
)
if [[ -n "$parse_mode" ]]; then
curl_args+=(--data-urlencode "parse_mode=${parse_mode}")
fi
# Write URL to temp file to hide bot token from process listing and /proc/fd
local _tg_cfg
_tg_cfg=$(mktemp "${TMP_DIR}/tg_cfg.XXXXXX") || return 1
printf 'url = "%s"\n' "$_tg_url" > "$_tg_cfg" 2>/dev/null || return 1
chmod 600 "$_tg_cfg" 2>/dev/null || true
local _tg_rc=0
curl --config "$_tg_cfg" "${curl_args[@]}" >/dev/null 2>&1 || _tg_rc=$?
rm -f "$_tg_cfg" 2>/dev/null || true
return "$_tg_rc"
}
# Send a notification (checks if enabled + alerts flag)
_telegram_notify() {
local message="$1"
if _telegram_enabled && [[ "$(config_get TELEGRAM_ALERTS true)" == "true" ]]; then
_telegram_send "$message" &
_TG_BG_PIDS+=($!)
fi
# Reap any completed background sends
local -a _tg_alive=()
local _tg_p
for _tg_p in "${_TG_BG_PIDS[@]}"; do
if kill -0 "$_tg_p" 2>/dev/null; then
_tg_alive+=("$_tg_p")
else
wait "$_tg_p" 2>/dev/null || true
fi
done
_TG_BG_PIDS=("${_tg_alive[@]}")
return 0
}
# Test Telegram connectivity
telegram_test() {
if ! _telegram_enabled; then
log_error "Telegram not configured (set bot token and chat ID first)"
return 1
fi
local hostname _t_ip _t_running=0 _t_stopped=0 _t_total=0
hostname=$(hostname 2>/dev/null || echo "unknown")
_t_ip=$(hostname -I 2>/dev/null | awk '{print $1}') || true
: "${_t_ip:=unknown}"
# Count tunnel status
local _t_name
while IFS= read -r _t_name; do
[[ -z "$_t_name" ]] && continue
(( ++_t_total ))
if is_tunnel_running "$_t_name" 2>/dev/null; then
(( ++_t_running ))
else
(( ++_t_stopped ))
fi
done < <(list_profiles 2>/dev/null)
local _t_alerts _t_reports
_t_alerts=$(config_get TELEGRAM_ALERTS true)
_t_reports=$(config_get TELEGRAM_PERIODIC_STATUS false)
# Build tunnel list
local _t_list=""
local _tl_name
while IFS= read -r _tl_name; do
[[ -z "$_tl_name" ]] && continue
if is_tunnel_running "$_tl_name" 2>/dev/null; then
_t_list="${_t_list}${_tl_name} [ALIVE]
"
else
_t_list="${_t_list}${_tl_name} [STOPPED]
"
fi
done < <(list_profiles 2>/dev/null)
[[ -z "$_t_list" ]] && _t_list=" (none configured)
"
local test_msg
test_msg="$(printf '✅ TunnelForge Connected
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🖥 Server Info
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Host: %s
IP: %s
Version: %s
Time: %s
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 Tunnels (%d running / %d stopped)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
%s
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔔 Alerts
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Alerts: %s
Status Reports: %s
You will be notified on:
tunnel start/stop/fail/reconnect
periodic status reports
security audit alerts
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🤖 Bot Commands
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/tf_help - Show this help
/tf_status - Tunnel status
/tf_list - List all tunnels
/tf_ip - Show server IP
/tf_config - Get client config (PSK)
/tf_uptime - Server uptime
/tf_report - Full status report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⌨️ Server CLI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
tunnelforge start/stop/restart <name>
tunnelforge list
tunnelforge dashboard
tunnelforge menu
tunnelforge telegram share <name>
tunnelforge telegram report' \
"$hostname" "$_t_ip" "${VERSION}" "$(date '+%Y-%m-%d %H:%M:%S')" \
"$_t_running" "$_t_stopped" "$_t_list" "$_t_alerts" "$_t_reports")"
log_info "Sending test message..."
local _tt_token
_tt_token=$(config_get TELEGRAM_BOT_TOKEN "")
local _tt_url="${_TG_API}/bot${_tt_token}/sendMessage"
local -a _TG_PROXY_ARGS=()
_tg_proxy_args || true
# Write URL to temp file to hide bot token from /proc (matches _telegram_send pattern)
local _tt_cfg
_tt_cfg=$(mktemp "${TMP_DIR}/tg_test.XXXXXX") || { log_error "Cannot create temp file"; return 1; }
printf 'url = "%s"\n' "$_tt_url" > "$_tt_cfg" 2>/dev/null || { rm -f "$_tt_cfg" 2>/dev/null; return 1; }
chmod 600 "$_tt_cfg" 2>/dev/null || true
local response
response=$(curl --config "$_tt_cfg" \
-s --max-time 15 "${_TG_PROXY_ARGS[@]}" -X POST \
--data-urlencode "chat_id=$(config_get TELEGRAM_CHAT_ID)" \
--data-urlencode "text=${test_msg}" 2>/dev/null) || true
rm -f "$_tt_cfg" 2>/dev/null
if printf '%s' "$response" | grep -qF '"ok":true' 2>/dev/null; then
log_success "Telegram test message sent successfully"
return 0
else
local err_desc
err_desc=$(printf '%s' "$response" | grep -oE '"description":"[^"]*"' 2>/dev/null | head -1) || true
log_error "Telegram test failed: ${err_desc:-no response}"
return 1
fi
}
# Show Telegram status
telegram_status() {
printf "\n${BOLD}Telegram Notification Status${RESET}\n\n"
printf " Enabled : ${BOLD}%s${RESET}\n" "$(config_get TELEGRAM_ENABLED false)"
printf " Bot Token : ${BOLD}%s${RESET}\n" \
"$(if [[ -n "$(config_get TELEGRAM_BOT_TOKEN)" ]]; then echo '••••••••(set)'; else echo '(not set)'; fi)"
printf " Chat ID : ${BOLD}%s${RESET}\n" \
"$(local _cid; _cid=$(config_get TELEGRAM_CHAT_ID); if [[ -n "$_cid" ]]; then echo "****${_cid: -4}"; else echo '(not set)'; fi)"
printf " Alerts : ${BOLD}%s${RESET}\n" "$(config_get TELEGRAM_ALERTS true)"
printf " Status Reports: ${BOLD}%s${RESET}\n" "$(config_get TELEGRAM_PERIODIC_STATUS false)"
printf " Report Interval: ${BOLD}%s${RESET}s\n" "$(config_get TELEGRAM_STATUS_INTERVAL 3600)"
printf "\n"
return 0
}
# Telegram update offset file — prevents reprocessing same messages
_tg_offset_file() { printf '%s' "${CONFIG_DIR}/tg_offset"; }
_tg_get_offset() {
local _f
_f=$(_tg_offset_file)
if [[ -f "$_f" ]]; then
local _val
_val=$(cat "$_f" 2>/dev/null) || true
if [[ "$_val" =~ ^[0-9]+$ ]]; then
printf '%s' "$_val"; return 0
fi
fi
printf '0'
return 0
}
_tg_set_offset() {
local _f
_f=$(_tg_offset_file)
printf '%s' "$1" > "$_f" 2>/dev/null || true
}
# Auto-detect chat ID from recent messages to the bot
# Uses offset tracking to avoid reprocessing old updates
_telegram_get_chat_id() {
local token="$1"
[[ -z "$token" ]] && return 1
local -a _TG_PROXY_ARGS=()
_tg_proxy_args || true
local _offset
_offset=$(_tg_get_offset) || true
local _curl_cfg _url_str
_curl_cfg=$(mktemp "${TMP_DIR}/tg_cid.XXXXXX") || return 1
chmod 600 "$_curl_cfg" 2>/dev/null || true
if [[ "$_offset" -gt 0 ]] 2>/dev/null; then
_url_str=$(printf '%s/bot%s/getUpdates?offset=%s' "$_TG_API" "$token" "$_offset")
else
_url_str=$(printf '%s/bot%s/getUpdates' "$_TG_API" "$token")
fi
printf 'url = "%s"\n' "$_url_str" > "$_curl_cfg"
local response
response=$(curl -s --max-time 15 "${_TG_PROXY_ARGS[@]}" --max-filesize 1048576 -K "$_curl_cfg" 2>/dev/null) || true
rm -f "$_curl_cfg" 2>/dev/null || true
[[ -z "$response" ]] && return 1
printf '%s' "$response" | grep -qF '"ok":true' || return 1
local chat_id="" max_update_id=""
if command -v python3 &>/dev/null; then
local _py_out
_py_out=$(python3 -c "
import json,sys
try:
d=json.loads(sys.stdin.read())
results=d.get('result',[])
max_uid=0
cid=''
for u in results:
uid=u.get('update_id',0)
if uid>max_uid: max_uid=uid
for u in reversed(results):
if 'message' in u:
cid=str(u['message']['chat']['id']); break
elif 'my_chat_member' in u:
cid=str(u['my_chat_member']['chat']['id']); break
print(cid+'|'+str(max_uid))
except: print('|0')
" <<< "$response" 2>/dev/null) || true
chat_id="${_py_out%%|*}"
max_update_id="${_py_out##*|}"
fi
# Fallback: grep extraction
if [[ -z "$chat_id" ]]; then
chat_id=$(printf '%s' "$response" | grep -oE '"chat"[[:space:]]*:[[:space:]]*\{[[:space:]]*"id"[[:space:]]*:[[:space:]]*-?[0-9]+' \
| grep -oE -- '-?[0-9]+$' | tail -1 2>/dev/null) || true
# Extract max update_id via grep
if [[ -z "$max_update_id" ]] || [[ "$max_update_id" == "0" ]]; then
max_update_id=$(printf '%s' "$response" | grep -oE '"update_id"[[:space:]]*:[[:space:]]*[0-9]+' \
| grep -oE '[0-9]+$' | sort -n | tail -1 2>/dev/null) || true
fi
fi
# Confirm processed updates by advancing offset
if [[ -n "$max_update_id" ]] && [[ "$max_update_id" =~ ^[0-9]+$ ]] && (( max_update_id > 0 )); then
_tg_set_offset "$(( max_update_id + 1 ))"
fi
if [[ -n "$chat_id" ]] && [[ "$chat_id" =~ ^-?[0-9]+$ ]]; then
_TG_DETECTED_CHAT_ID="$chat_id"
return 0
fi
return 1
}
# Telegram interactive setup wizard
telegram_setup() {
local _saved_token _saved_chatid _saved_enabled
_saved_token=$(config_get TELEGRAM_BOT_TOKEN "")
_saved_chatid=$(config_get TELEGRAM_CHAT_ID "")
_saved_enabled=$(config_get TELEGRAM_ENABLED "false")
# Restore on Ctrl+C
trap 'config_set TELEGRAM_BOT_TOKEN "$_saved_token"; config_set TELEGRAM_CHAT_ID "$_saved_chatid"; config_set TELEGRAM_ENABLED "$_saved_enabled"; trap - INT; printf "\n" >/dev/tty; return 0' INT
clear >/dev/tty 2>/dev/null || true
printf "${BOLD_CYAN}══════════════════════════════════════════════════════════════${RESET}\n" >/dev/tty
printf " ${BOLD}TELEGRAM NOTIFICATIONS SETUP${RESET}\n" >/dev/tty
printf "${BOLD_CYAN}══════════════════════════════════════════════════════════════${RESET}\n\n" >/dev/tty
# ── Step 1: Bot Token ──
printf " ${BOLD}Step 1: Create a Telegram Bot${RESET}\n" >/dev/tty
printf " ${CYAN}─────────────────────────────${RESET}\n" >/dev/tty
printf " 1. Open Telegram and search for ${BOLD}@BotFather${RESET}\n" >/dev/tty
printf " 2. Send ${YELLOW}/newbot${RESET}\n" >/dev/tty
printf " 3. Choose a name (e.g. \"TunnelForge Monitor\")\n" >/dev/tty
printf " 4. Choose a username (e.g. \"my_tunnel_bot\")\n" >/dev/tty
printf " 5. BotFather will give you a token like:\n" >/dev/tty
printf " ${YELLOW}123456789:ABCdefGHIjklMNOpqrsTUVwxyz${RESET}\n\n" >/dev/tty
local _tg_token=""
read -rp " Enter your bot token: " _tg_token </dev/tty >/dev/tty || { trap - INT; return 0; }
_tg_token="${_tg_token## }"; _tg_token="${_tg_token%% }"
printf "\n" >/dev/tty
if [[ -z "$_tg_token" ]]; then
printf " ${RED}No token entered. Setup cancelled.${RESET}\n" >/dev/tty
_press_any_key || true; trap - INT; return 0
fi
# Validate token format
if [[ ! "$_tg_token" =~ ^[0-9]+:[A-Za-z0-9_-]+$ ]]; then
printf " ${RED}Invalid token format. Should be like: 123456789:ABCdefGHI...${RESET}\n" >/dev/tty
_press_any_key || true; trap - INT; return 0
fi
# Verify token with Telegram API (route through SOCKS5 if available)
local -a _TG_PROXY_ARGS=()
_tg_proxy_args || true
printf " Verifying bot token... " >/dev/tty
local _me_cfg _me_resp
_me_cfg=$(mktemp "${TMP_DIR}/tg_me.XXXXXX") || true
if [[ -n "$_me_cfg" ]]; then
chmod 600 "$_me_cfg" 2>/dev/null || true
printf 'url = "%s/bot%s/getMe"\n' "$_TG_API" "$_tg_token" > "$_me_cfg"
_me_resp=$(curl -s --max-time 15 "${_TG_PROXY_ARGS[@]}" -K "$_me_cfg" 2>/dev/null) || true
rm -f "$_me_cfg" 2>/dev/null || true
if printf '%s' "$_me_resp" | grep -qF '"ok":true' 2>/dev/null; then
printf "${GREEN}Valid${RESET}\n" >/dev/tty
else
printf "${RED}Invalid token${RESET}\n" >/dev/tty
printf " ${RED}The Telegram API rejected this token. Check it and try again.${RESET}\n" >/dev/tty
_press_any_key || true; trap - INT; return 0
fi
fi
# ── Step 2: Chat ID (auto-detect) ──
printf "\n ${BOLD}Step 2: Get Your Chat ID${RESET}\n" >/dev/tty
printf " ${CYAN}────────────────────────${RESET}\n" >/dev/tty
printf " 1. Open your new bot in Telegram\n" >/dev/tty
printf " 2. Send it the message: ${YELLOW}/start${RESET}\n\n" >/dev/tty
printf " ${YELLOW}Important:${RESET} You MUST send ${BOLD}/start${RESET} to the bot first!\n\n" >/dev/tty
read -rp " Press Enter after sending /start to your bot... " </dev/tty >/dev/tty || { trap - INT; return 0; }
printf "\n Detecting chat ID... " >/dev/tty
local _TG_DETECTED_CHAT_ID="" _attempts=0
while (( _attempts < 3 )) && [[ -z "$_TG_DETECTED_CHAT_ID" ]]; do
_telegram_get_chat_id "$_tg_token" || true
if [[ -n "$_TG_DETECTED_CHAT_ID" ]]; then break; fi
(( ++_attempts ))
sleep 2
done
local _tg_chat_id=""
if [[ -n "$_TG_DETECTED_CHAT_ID" ]]; then
_tg_chat_id="$_TG_DETECTED_CHAT_ID"
printf "${GREEN}Found: ${_tg_chat_id}${RESET}\n" >/dev/tty
else
printf "${RED}Could not auto-detect${RESET}\n\n" >/dev/tty
printf " ${BOLD}You can enter it manually:${RESET}\n" >/dev/tty
printf " ${CYAN}────────────────────────────${RESET}\n" >/dev/tty
printf " Option 1: Press Enter to retry detection\n" >/dev/tty
printf " Option 2: Find your chat ID via ${BOLD}@userinfobot${RESET} on Telegram\n\n" >/dev/tty
local _manual_chatid=""
read -rp " Enter chat ID (or Enter to retry): " _manual_chatid </dev/tty >/dev/tty || true
if [[ -z "$_manual_chatid" ]]; then
# Retry
printf "\n Retrying detection... " >/dev/tty
_attempts=0
while (( _attempts < 5 )) && [[ -z "$_TG_DETECTED_CHAT_ID" ]]; do
_telegram_get_chat_id "$_tg_token" || true
if [[ -n "$_TG_DETECTED_CHAT_ID" ]]; then break; fi
(( ++_attempts ))
sleep 2
done
if [[ -n "$_TG_DETECTED_CHAT_ID" ]]; then
_tg_chat_id="$_TG_DETECTED_CHAT_ID"
printf "${GREEN}Found: ${_tg_chat_id}${RESET}\n" >/dev/tty
fi
elif [[ "$_manual_chatid" =~ ^-?[0-9]+$ ]]; then
_tg_chat_id="$_manual_chatid"
else
printf " ${RED}Invalid chat ID. Must be a number.${RESET}\n" >/dev/tty
fi
if [[ -z "$_tg_chat_id" ]]; then
printf " ${RED}Could not get chat ID. Setup cancelled.${RESET}\n" >/dev/tty
_press_any_key || true; trap - INT; return 0
fi
fi
# ── Step 3: Save and test ──
config_set "TELEGRAM_BOT_TOKEN" "$_tg_token"
config_set "TELEGRAM_CHAT_ID" "$_tg_chat_id"
config_set "TELEGRAM_ENABLED" "true"
save_settings || true
printf "\n Sending test message... " >/dev/tty
if telegram_test 2>/dev/null; then
printf "${GREEN}Success!${RESET}\n" >/dev/tty
printf "\n ${GREEN}Telegram notifications are now active.${RESET}\n" >/dev/tty
else
printf "${RED}Failed to send.${RESET}\n" >/dev/tty
printf " ${YELLOW}Token/chat ID saved but test failed — check credentials.${RESET}\n" >/dev/tty
fi
_press_any_key || true
trap - INT
return 0
}
# ── Notification message builders ──
_notify_tunnel_start() {
local name="$1" tunnel_type="${2:-tunnel}" pid="${3:-}"
local hostname
hostname=$(hostname 2>/dev/null || echo "unknown")
local _nts_obfs=""
local -A _nts_prof=()
if load_profile "$name" _nts_prof 2>/dev/null; then
if [[ "${_nts_prof[OBFS_MODE]:-none}" != "none" ]]; then
_nts_obfs=$(printf '\nObfuscation: %s (port %s)' "${_nts_prof[OBFS_MODE]}" "${_nts_prof[OBFS_PORT]:-443}")
fi
if [[ -n "${_nts_prof[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_nts_prof[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then
_nts_obfs="${_nts_obfs}$(printf '\nInbound TLS: port %s (PSK)' "${_nts_prof[OBFS_LOCAL_PORT]}")"
fi
fi
_telegram_notify "$(printf '✅ Tunnel Started\n\nName: %s\nType: %s\nHost: %s\nPID: %s%s\nTime: %s' \
"$name" "$tunnel_type" "$hostname" "${pid:-?}" "$_nts_obfs" "$(date '+%H:%M:%S')")"
return 0
}
_notify_tunnel_stop() {
local name="$1"
_telegram_notify "$(printf '⛔ Tunnel Stopped\n\nName: %s\nTime: %s' \
"$name" "$(date '+%H:%M:%S')")"
return 0
}
_notify_tunnel_fail() {
local name="$1"
_telegram_notify "$(printf '❌ Tunnel Failed\n\nName: %s\nTime: %s\nCheck logs for details.' \
"$name" "$(date '+%H:%M:%S')")"
return 0
}
_notify_reconnect() {
local name="$1" reason="${2:-unknown}"
_telegram_notify "$(printf '🔄 Tunnel Reconnect\n\nName: %s\nReason: %s\nTime: %s' \
"$name" "$reason" "$(date '+%H:%M:%S')")"
return 0
}
# Generate a periodic status report (with timestamp dedup to prevent repeats)
telegram_send_status() {
if ! _telegram_enabled; then return 0; fi
if [[ "$(config_get TELEGRAM_PERIODIC_STATUS false)" != "true" ]]; then return 0; fi
# Dedup: check last send timestamp to prevent repeat sends
local _ts_file="${CONFIG_DIR}/tg_last_report"
local _interval
_interval=$(config_get TELEGRAM_STATUS_INTERVAL 3600)
if [[ -f "$_ts_file" ]]; then
local _last_ts _now_ts
_last_ts=$(cat "$_ts_file" 2>/dev/null) || true
_now_ts=$(date +%s 2>/dev/null) || true
if [[ "$_last_ts" =~ ^[0-9]+$ ]] && [[ "$_now_ts" =~ ^[0-9]+$ ]]; then
if (( _now_ts - _last_ts < _interval )); then
return 0 # Too soon, skip
fi
fi
fi
local hostname running=0 stopped=0 total=0
hostname=$(hostname 2>/dev/null || echo "unknown")
local name
while IFS= read -r name; do
[[ -z "$name" ]] && continue
((++total))
if is_tunnel_running "$name"; then
((++running))
else
((++stopped))
fi
done < <(list_profiles)
(( total > 0 )) || return 0
local public_ip
public_ip=$(hostname -I 2>/dev/null | awk '{print $1}') || true
: "${public_ip:=unknown}"
local msg
msg=$(printf '📊 Status Report\n\nHost: %s\nIP: %s\nTunnels: %d running / %d stopped / %d total\nTime: %s' \
"$hostname" "$public_ip" "$running" "$stopped" "$total" "$(date '+%Y-%m-%d %H:%M:%S')")
if _telegram_send "$msg"; then
# Record send timestamp only on success
date +%s > "$_ts_file" 2>/dev/null || true
fi
return 0
}
# ── Telegram Bot Command Handler ──
# Polls getUpdates, processes /tf_* commands, responds via sendMessage.
# Call periodically (e.g., from dashboard loop). Uses offset tracking to avoid repeats.
_tg_cmd_response() {
local _cmd="$1" _chat="$2" _token="$3"
local _resp=""
case "$_cmd" in
/tf_help|/tf_help@*)
_resp="$(printf '🤖 TunnelForge Bot Commands
/tf_help - Show this help
/tf_status - Tunnel status overview
/tf_list - List all tunnels
/tf_ip - Show server IP
/tf_config - Get client configs (PSK)
/tf_uptime - Server uptime
/tf_report - Full status report')"
;;
/tf_status|/tf_status@*)
local _r=0 _s=0 _t=0 _n
while IFS= read -r _n; do
[[ -z "$_n" ]] && continue
(( ++_t ))
if is_tunnel_running "$_n" 2>/dev/null; then (( ++_r )); else (( ++_s )); fi
done < <(list_profiles 2>/dev/null)
_resp="$(printf '📊 Tunnel Status\n\nRunning: %d\nStopped: %d\nTotal: %d\nTime: %s' \
"$_r" "$_s" "$_t" "$(date '+%H:%M:%S')")"
;;
/tf_list|/tf_list@*)
local _lines="" _n
while IFS= read -r _n; do
[[ -z "$_n" ]] && continue
if is_tunnel_running "$_n" 2>/dev/null; then
_lines="${_lines}${_n} [ALIVE]\n"
else
_lines="${_lines}${_n} [STOPPED]\n"
fi
done < <(list_profiles 2>/dev/null)
[[ -z "$_lines" ]] && _lines="(no tunnels configured)\n"
_resp="$(printf '📋 Tunnel List\n\n%b' "$_lines")"
;;
/tf_ip|/tf_ip@*)
local _ip
_ip=$(hostname -I 2>/dev/null | awk '{print $1}') || true
: "${_ip:=unknown}"
_resp="$(printf '🌐 Server IP: %s\nHostname: %s' "$_ip" "$(hostname 2>/dev/null || echo unknown)")"
;;
/tf_config|/tf_config@*)
# Send config for all profiles with inbound TLS
local _cfg_lines="" _n
while IFS= read -r _n; do
[[ -z "$_n" ]] && continue
local -A _cp=()
if load_profile "$_n" _cp 2>/dev/null; then
local _olp="${_cp[OBFS_LOCAL_PORT]:-}"
if [[ -n "$_olp" ]] && [[ "$_olp" != "0" ]]; then
local _h="${_cp[SSH_HOST]:-localhost}"
local _pub
_pub=$(hostname -I 2>/dev/null | awk '{print $1}') || true
[[ -n "$_pub" ]] && _h="$_pub"
local _st="STOPPED"
is_tunnel_running "$_n" 2>/dev/null && _st="ALIVE"
_cfg_lines="${_cfg_lines}$(printf '┌── %s [%s]\n│ Server: %s\n│ Port: %s\n│ SOCKS5: 127.0.0.1:%s\n│ PSK: %s\n└──\n\n' \
"$_n" "$_st" "$_h" "$_olp" "${_cp[LOCAL_PORT]:-1080}" "${_cp[OBFS_PSK]:-N/A}")"
fi
fi
done < <(list_profiles 2>/dev/null)
if [[ -z "$_cfg_lines" ]]; then
_resp="No profiles with inbound TLS configured."
else
_resp="$(printf '🔐 Client Configs\n\n%s' "$_cfg_lines")"
fi
;;
/tf_uptime|/tf_uptime@*)
local _up
_up=$(uptime -p 2>/dev/null || uptime 2>/dev/null || echo "unknown")
_resp="$(printf '⏱ Server Uptime\n\n%s\nTime: %s' "$_up" "$(date '+%Y-%m-%d %H:%M:%S')")"
;;
/tf_report|/tf_report@*)
local _r=0 _s=0 _t=0 _n _tlist=""
while IFS= read -r _n; do
[[ -z "$_n" ]] && continue
(( ++_t ))
if is_tunnel_running "$_n" 2>/dev/null; then
(( ++_r ))
_tlist="${_tlist}${_n}\n"
else
(( ++_s ))
_tlist="${_tlist}${_n}\n"
fi
done < <(list_profiles 2>/dev/null)
local _ip
_ip=$(hostname -I 2>/dev/null | awk '{print $1}') || true
local _up
_up=$(uptime -p 2>/dev/null || echo "N/A")
_resp="$(printf '📊 Full Status Report\n\n🖥 %s (%s)\n⏱ %s\n\n📡 Tunnels: %d running / %d stopped\n%b\n🕐 %s' \
"$(hostname 2>/dev/null || echo unknown)" "${_ip:-unknown}" "$_up" "$_r" "$_s" "$_tlist" "$(date '+%Y-%m-%d %H:%M:%S')")"
;;
/start|/start@*)
_resp="$(printf '🤖 TunnelForge Bot Active\n\nSend /tf_help for available commands.')"
;;
*)
# Unknown command — ignore
return 0
;;
esac
if [[ -n "$_resp" ]]; then
# Send response via _telegram_send (uses proxy automatically)
if _telegram_send "$_resp"; then
log_info "TG bot: replied to '${_cmd}'"
else
log_warn "TG bot: failed to send reply for '${_cmd}'"
fi
fi
return 0
}
# Poll for and process Telegram bot commands (one-shot)
_tg_process_commands() {
if ! _telegram_enabled; then return 0; fi
local _token _chat_id
_token=$(config_get TELEGRAM_BOT_TOKEN "")
_chat_id=$(config_get TELEGRAM_CHAT_ID "")
[[ -n "$_token" ]] && [[ -n "$_chat_id" ]] || return 0
local -a _TG_PROXY_ARGS=()
_tg_proxy_args || true
if [[ ${#_TG_PROXY_ARGS[@]} -eq 0 ]]; then
log_debug "TG poll: no SOCKS5 proxy, trying direct"
fi
local _offset
_offset=$(_tg_get_offset) || true
local _curl_cfg _url_str
_curl_cfg=$(mktemp "${TMP_DIR}/tg_poll.XXXXXX") || return 0
chmod 600 "$_curl_cfg" 2>/dev/null || true
if [[ "$_offset" -gt 0 ]] 2>/dev/null; then
_url_str=$(printf '%s/bot%s/getUpdates?offset=%s&timeout=0' "$_TG_API" "$_token" "$_offset")
else
_url_str=$(printf '%s/bot%s/getUpdates?timeout=0' "$_TG_API" "$_token")
fi
printf 'url = "%s"\n' "$_url_str" > "$_curl_cfg"
local response
response=$(curl -s --max-time 20 "${_TG_PROXY_ARGS[@]}" --max-filesize 1048576 -K "$_curl_cfg" 2>/dev/null) || true
rm -f "$_curl_cfg" 2>/dev/null || true
if [[ -z "$response" ]]; then
log_debug "TG poll: empty response from getUpdates"
return 0
fi
if ! printf '%s' "$response" | grep -qF '"ok":true'; then
log_debug "TG poll: API error: ${response:0:200}"
return 0
fi
# Parse updates with python3 (fast, reliable JSON parsing)
if ! command -v python3 &>/dev/null; then
log_debug "TG poll: python3 not found"
return 0
fi
# Validate chat_id is numeric to prevent injection (negative for group chats)
if ! [[ "$_chat_id" =~ ^-?[0-9]+$ ]]; then
log_warn "TG poll: invalid chat_id, skipping"
return 0
fi
local _updates
_updates=$(TF_CHAT_ID="$_chat_id" python3 -c "
import json,sys,os
try:
cid=os.environ.get('TF_CHAT_ID','')
d=json.loads(sys.stdin.read())
for u in d.get('result',[]):
uid=u.get('update_id',0)
msg=u.get('message',{})
text=msg.get('text','')
chat_id=msg.get('chat',{}).get('id',0)
if text.startswith('/') and str(chat_id)==cid:
cmd=text.split()[0].lower()
print(str(uid)+'|'+cmd)
else:
print(str(uid)+'|')
except: pass
" <<< "$response" 2>/dev/null) || true
local _max_uid=0
local _line _uid _cmd
while IFS= read -r _line; do
[[ -z "$_line" ]] && continue
_uid="${_line%%|*}"
_cmd="${_line#*|}"
if [[ "$_uid" =~ ^[0-9]+$ ]] && (( _uid > _max_uid )); then
_max_uid=$_uid
fi
# Process command if present
if [[ -n "$_cmd" ]]; then
log_info "TG bot: received command '${_cmd}'"
_tg_cmd_response "$_cmd" "$_chat_id" "$_token" || true
fi
done <<< "$_updates"
# Advance offset to skip processed updates
if (( _max_uid > 0 )); then
_tg_set_offset "$(( _max_uid + 1 ))"
log_debug "TG poll: offset advanced to $(( _max_uid + 1 ))"
fi
return 0
}
# Non-blocking wrapper: runs _tg_process_commands in background
# Uses lock file to prevent concurrent polls
_tg_process_commands_bg() {
local _lock="${TMP_DIR}/tg_cmd.lock"
# Skip if previous poll still running
if [[ -f "$_lock" ]]; then
local _lpid
_lpid=$(cat "$_lock" 2>/dev/null) || true
if [[ -n "$_lpid" ]] && kill -0 "$_lpid" 2>/dev/null; then
return 0
fi
rm -f "$_lock" 2>/dev/null || true
fi
(
printf '%s' "$BASHPID" > "$_lock" 2>/dev/null || true
_tg_process_commands 2>/dev/null || true
rm -f "$_lock" 2>/dev/null || true
) &>/dev/null &
disown 2>/dev/null || true
return 0
}
# Send a file via Telegram bot API (sendDocument).
# Args: file_path [caption]
_telegram_send_file() {
local _file="$1" _caption="${2:-}"
local _token _chat_id
_token=$(config_get TELEGRAM_BOT_TOKEN "")
_chat_id=$(config_get TELEGRAM_CHAT_ID "")
[[ -n "$_token" ]] && [[ -n "$_chat_id" ]] || return 1
[[ -f "$_file" ]] || return 1
local _tg_url="${_TG_API}/bot${_token}/sendDocument"
local _tg_cfg
_tg_cfg=$(mktemp "${TMP_DIR}/tg_cfg.XXXXXX") || return 1
printf 'url = "%s"\n' "$_tg_url" > "$_tg_cfg" 2>/dev/null || { rm -f "$_tg_cfg" 2>/dev/null || true; return 1; }
chmod 600 "$_tg_cfg" 2>/dev/null || true
local -a _TG_PROXY_ARGS=()
_tg_proxy_args || true
local -a curl_args=(
-s --max-time 30
"${_TG_PROXY_ARGS[@]}"
-X POST
-F "chat_id=${_chat_id}"
-F "document=@${_file}"
)
if [[ -n "$_caption" ]]; then
curl_args+=(-F "caption=${_caption}")
fi
local _rc=0
curl --config "$_tg_cfg" "${curl_args[@]}" >/dev/null 2>&1 || _rc=$?
rm -f "$_tg_cfg" 2>/dev/null || true
return "$_rc"
}
# Share client connection info + scripts via Telegram.
# Args: profile_name
telegram_share_client() {
local _name="${1:-}"
if ! _telegram_enabled; then
log_error "Telegram is not configured. Run: tunnelforge telegram setup"
return 1
fi
if [[ -z "$_name" ]]; then
# Pick from running profiles with inbound TLS
local _profiles="" _found=0
while IFS= read -r _pn; do
[[ -z "$_pn" ]] && continue
local -A _tp=()
if load_profile "$_pn" _tp 2>/dev/null; then
if [[ -n "${_tp[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_tp[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then
_profiles="${_profiles}${_pn}\n"
((++_found))
fi
fi
done < <(list_profiles)
if (( _found == 0 )); then
log_error "No profiles with inbound TLS found"
return 1
fi
printf "\n${BOLD}Profiles with inbound TLS:${RESET}\n"
local -a _parr=() _pi=0
while IFS= read -r _pn; do
[[ -z "$_pn" ]] && continue
((++_pi))
_parr+=("$_pn")
printf " ${CYAN}%d${RESET}) %s\n" "$_pi" "$_pn"
done < <(printf '%b' "$_profiles")
printf "\n"
local _sel=""
read -rp " Select profile [1-${_pi}]: " _sel </dev/tty || true
if [[ -z "$_sel" ]] || ! [[ "$_sel" =~ ^[0-9]+$ ]] || (( _sel < 1 || _sel > _pi )); then
log_error "Invalid selection"
return 1
fi
_name="${_parr[$((_sel - 1))]}"
fi
local -A _sp=()
load_profile "$_name" _sp || { log_error "Cannot load profile '$_name'"; return 1; }
local _olport="${_sp[OBFS_LOCAL_PORT]:-}"
local _psk="${_sp[OBFS_PSK]:-}"
local _lport="${_sp[LOCAL_PORT]:-}"
if [[ -z "$_olport" ]] || [[ "$_olport" == "0" ]]; then
log_error "Profile '$_name' has no inbound TLS configured"
return 1
fi
# Determine server IP
local _host="${_sp[SSH_HOST]:-localhost}"
local _pub_ip=""
_pub_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | cut -d' ' -f2) || true
if [[ -n "$_pub_ip" ]]; then _host="$_pub_ip"; fi
log_info "Sharing client info for '${_name}' via Telegram..."
# Determine running status
local _status_txt="STOPPED"
if is_tunnel_running "$_name" 2>/dev/null; then _status_txt="ALIVE"; fi
# 1. Send connection info message (clean, formatted like the menu display)
local _msg
_msg=$(printf '🔐 TunnelForge Client Config
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📡 %s [%s]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Server: %s
Port: %s
SOCKS5: 127.0.0.1:%s
PSK Key: %s
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚡ Quick Start (Windows)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. Install stunnel from stunnel.org
2. Save the .bat file below
3. Edit the SERVER, PORT, PSK values
4. Double-click to connect
5. Set browser proxy: 127.0.0.1:%s
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🐧 Quick Start (Linux/Mac)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. Install stunnel: apt install stunnel4
2. Save the .sh file below
3. chmod +x tunnelforge-connect.sh
4. ./tunnelforge-connect.sh
5. Set browser proxy: 127.0.0.1:%s
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔧 Manual stunnel Config
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
stunnel.conf:
[tunnelforge]
client = yes
accept = 127.0.0.1:%s
connect = %s:%s
PSKsecrets = psk.txt
ciphers = PSK
psk.txt:
tunnelforge:%s
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🌐 Browser Setup
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Firefox:
Settings → Proxy → Manual
SOCKS Host: 127.0.0.1
Port: %s
SOCKS v5 ✓
Proxy DNS ✓
Chrome:
chrome --proxy-server="socks5://127.0.0.1:%s"' \
"$_name" "$_status_txt" \
"$_host" "$_olport" "$_lport" "$_psk" \
"$_lport" \
"$_lport" \
"$_lport" "$_host" "$_olport" \
"$_psk" \
"$_lport" "$_lport")
if _telegram_send "$_msg"; then
log_success "Connection info sent"
else
log_error "Failed to send connection info"
return 1
fi
# 2. Generate and send Linux script
local _sh_file="${TMP_DIR}/tunnelforge-connect.sh"
if _obfs_generate_client_script "$_name" _sp "$_sh_file" 2>/dev/null; then
if _telegram_send_file "$_sh_file" "Linux/Mac client — chmod +x and run"; then
log_success "Linux script sent"
else
log_warn "Failed to send Linux script"
fi
fi
rm -f "$_sh_file" 2>/dev/null || true
# 3. Send Windows bat file if it exists in the install dir
local _bat_file=""
for _bp in "${INSTALL_DIR}/tunnelforge-client.bat" \
"$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")/tunnelforge-client.bat" \
"/opt/tunnelforge/tunnelforge-client.bat"; do
if [[ -f "$_bp" ]]; then _bat_file="$_bp"; break; fi
done
if [[ -n "$_bat_file" ]]; then
if _telegram_send_file "$_bat_file" "Windows client — double-click to run"; then
log_success "Windows script sent"
else
log_warn "Failed to send Windows script"
fi
else
log_info "Windows .bat not found — place tunnelforge-client.bat in /opt/tunnelforge/"
fi
printf "\n${GREEN}Client setup shared via Telegram.${RESET}\n"
printf "${DIM}Users in the chat can see the info and download the scripts.${RESET}\n\n"
return 0
}
# ── Telegram interactive menu ──
_menu_telegram() {
while true; do
clear >/dev/tty 2>/dev/null || true
printf "\n${BOLD_CYAN}═══ Telegram Notifications ═══${RESET}\n\n" >/dev/tty
local _tg_status_icon="${RED}${RESET}"
if _telegram_enabled; then
_tg_status_icon="${GREEN}${RESET}"
fi
printf " Status: %b %s\n\n" "$_tg_status_icon" \
"$(if _telegram_enabled; then echo 'Connected'; else echo 'Not configured'; fi)" >/dev/tty
printf " ${CYAN}1${RESET}) Setup / reconfigure\n" >/dev/tty
printf " ${CYAN}2${RESET}) Send test message\n" >/dev/tty
printf " ${CYAN}3${RESET}) Toggle alerts : ${BOLD}%s${RESET}\n" "$(config_get TELEGRAM_ALERTS true)" >/dev/tty
printf " ${CYAN}4${RESET}) Toggle status reports: ${BOLD}%s${RESET}\n" "$(config_get TELEGRAM_PERIODIC_STATUS false)" >/dev/tty
printf " ${CYAN}5${RESET}) Status interval : ${BOLD}%s${RESET}s\n" "$(config_get TELEGRAM_STATUS_INTERVAL 3600)" >/dev/tty
printf " ${CYAN}6${RESET}) Show full status\n" >/dev/tty
printf " ${CYAN}7${RESET}) Share client setup (scripts + PSK)\n" >/dev/tty
printf " ${CYAN}8${RESET}) Disable Telegram\n" >/dev/tty
printf " ${YELLOW}0${RESET}) Back\n\n" >/dev/tty
local _tg_choice
printf " ${BOLD}Select${RESET}: " >/dev/tty
read -rsn1 _tg_choice </dev/tty || true
_drain_esc _tg_choice
printf "\n" >/dev/tty
case "$_tg_choice" in
1) telegram_setup || true ;;
2) telegram_test || true; _press_any_key ;;
3)
if [[ "$(config_get TELEGRAM_ALERTS true)" == "true" ]]; then
config_set "TELEGRAM_ALERTS" "false"
log_info "Telegram alerts disabled"
else
config_set "TELEGRAM_ALERTS" "true"
log_info "Telegram alerts enabled"
fi
save_settings || true ;;
4)
if [[ "$(config_get TELEGRAM_PERIODIC_STATUS false)" == "true" ]]; then
config_set "TELEGRAM_PERIODIC_STATUS" "false"
log_info "Periodic status reports disabled"
else
config_set "TELEGRAM_PERIODIC_STATUS" "true"
log_info "Periodic status reports enabled"
fi
save_settings || true ;;
5)
local _tg_int
_read_tty " Status interval (seconds)" _tg_int "$(config_get TELEGRAM_STATUS_INTERVAL 3600)"
if [[ "$_tg_int" =~ ^[0-9]+$ ]] && (( _tg_int >= 60 )); then
config_set "TELEGRAM_STATUS_INTERVAL" "$_tg_int"
save_settings || true
log_info "Status interval set to ${_tg_int}s"
else
log_error "Invalid interval (minimum 60 seconds)"
fi
_press_any_key ;;
6) telegram_status || true; _press_any_key ;;
7) telegram_share_client "" || true; _press_any_key ;;
8)
config_set "TELEGRAM_ENABLED" "false"
save_settings || true
log_info "Telegram notifications disabled" ;;
0|q) return 0 ;;
*) true ;;
esac
done
}
# ── Log rotation ──
_rotate_dir_logs() {
local dir="$1" max_size="$2" max_count="$3"
local log_f
while IFS= read -r log_f; do
[[ -f "$log_f" ]] || continue
local fsize
fsize=$(stat -c %s "$log_f" 2>/dev/null || stat -f %z "$log_f" 2>/dev/null) || true
: "${fsize:=0}"
if (( fsize > max_size )); then
local ri
for (( ri=max_count; ri>=1; ri-- )); do
local prev=$(( ri - 1 ))
local src="${log_f}"
if [[ $prev -gt 0 ]]; then src="${log_f}.${prev}"; fi
if [[ -f "$src" ]]; then mv -f "$src" "${log_f}.${ri}" 2>/dev/null || true; fi
done
: > "$log_f" 2>/dev/null || true
log_debug "Rotated log: $(basename "$log_f")"
fi
done < <(find "$dir" -maxdepth 1 -name "*.log" -type f 2>/dev/null || true)
return 0
}
rotate_logs() {
local max_size max_count
max_size=$(config_get LOG_MAX_SIZE 10485760)
max_count=$(config_get LOG_ROTATE_COUNT 5)
_rotate_dir_logs "$LOG_DIR" "$max_size" "$max_count"
_rotate_dir_logs "$RECONNECT_LOG_DIR" "$max_size" "$max_count"
return 0
}
# ── Connection quality indicator ──
# Returns a quality rating based on latency to the SSH host
_get_ns_timestamp() {
local _ts
_ts=$(date +%s%N 2>/dev/null) || true
if [[ "$_ts" =~ ^[0-9]+$ ]]; then printf '%s' "$_ts"; return 0; fi
# macOS fallback: try perl for sub-second precision
_ts=$(perl -MTime::HiRes=time -e 'printf "%d", time()*1000000000' 2>/dev/null) || true
if [[ "$_ts" =~ ^[0-9]+$ ]]; then printf '%s' "$_ts"; return 0; fi
# Last resort: second-level precision
_ts=$(date +%s 2>/dev/null) || true
if [[ "$_ts" =~ ^[0-9]+$ ]]; then printf '%s' "$(( _ts * 1000000000 ))"; return 0; fi
printf '0'
}
_connection_quality() {
local host="$1" port="${2:-22}"
local start_ms end_ms
# Validate host/port to prevent injection in bash -c /dev/tcp
if ! [[ "$port" =~ ^[0-9]+$ ]]; then printf 'unknown'; return 0; fi
if ! [[ "$host" =~ ^[a-zA-Z0-9._:-]+$ ]]; then printf 'unknown'; return 0; fi
# Quick TCP connect test with 2s timeout (kills hangs from iptables DROP)
start_ms=$(_get_ns_timestamp)
local _cq_ok=false
if command -v timeout &>/dev/null; then
if timeout 2 bash -c ": </dev/tcp/${host}/${port}" 2>/dev/null; then
_cq_ok=true
fi
elif command -v nc &>/dev/null; then
if nc -z -w2 "$host" "$port" 2>/dev/null; then _cq_ok=true; fi
fi
if [[ "$_cq_ok" == true ]]; then
end_ms=$(_get_ns_timestamp)
if (( start_ms > 0 && end_ms > 0 )); then
local latency_ms=$(( (end_ms - start_ms) / 1000000 ))
if (( latency_ms < 50 )); then
printf "excellent"
elif (( latency_ms < 150 )); then
printf "good"
elif (( latency_ms < 300 )); then
printf "fair"
else
printf "poor"
fi
return 0
fi
fi
printf "unknown"
return 0
}
# Map quality to visual indicator
_quality_icon() {
case "$1" in
excellent) printf "${GREEN}▁▃▅▇${RESET}" ;;
good) printf "${GREEN}▁▃▅${RESET}${DIM}${RESET}" ;;
fair) printf "${YELLOW}▁▃${RESET}${DIM}▅▇${RESET}" ;;
poor) printf "${RED}${RESET}${DIM}▃▅▇${RESET}" ;;
*) printf "${DIM}▁▃▅▇${RESET}" ;;
esac
}
# ============================================================================
# DISPLAY HELPERS
# ============================================================================
show_banner() {
printf "${BOLD_CYAN}"
cat <<'BANNER'
╔════════════════════════════════════════════════════════════════╗
║ ▀▀█▀▀ █ █ █▄ █ █▄ █ █▀▀ █ █▀▀ █▀█ █▀█ █▀▀ █▀▀ ║
║ █ █ █ █ ▀█ █ ▀█ █▀▀ █ █▀ █ █ █▀█ █ █ █▀▀ ║
║ █ ▀▀ █ █ █ █ ▀▀▀ ▀▀▀ █ ▀▀▀ █ █ ▀▀▀ ▀▀▀ ║
╚════════════════════════════════════════════════════════════════╝
BANNER
printf "${RESET}"
printf "${DIM} SSH Tunnel Manager v%s${RESET}\n\n" "$VERSION"
}
show_help() {
show_banner
printf '%b\n' "${BOLD}USAGE:${RESET}
tunnelforge [command] [options]
${BOLD}TUNNEL COMMANDS:${RESET}
start <name> Start a tunnel
stop <name> Stop a tunnel
restart <name> Restart a tunnel
start-all Start all autostart tunnels
stop-all Stop all running tunnels
status Show all tunnel statuses
dashboard, dash Live TUI dashboard
logs [name] Tail tunnel logs
${BOLD}PROFILE COMMANDS:${RESET}
list, ls List all profiles
create, new Create new tunnel (wizard)
delete <name> Delete a profile
${BOLD}SECURITY COMMANDS:${RESET}
audit Run security audit
key-gen [type] Generate SSH key (ed25519/rsa)
key-deploy <name> Deploy SSH key to profile's server
fingerprint <host> Check SSH host fingerprint
${BOLD}TELEGRAM COMMANDS:${RESET}
telegram setup Configure Telegram bot
telegram test Send test message
telegram status Show notification config
telegram send <msg> Send a message via Telegram
telegram report Send status report now
${BOLD}SERVICE COMMANDS:${RESET}
service <name> Generate systemd service file
service <name> enable Enable + start service
service <name> disable Disable + stop service
service <name> status Show service status
service <name> remove Remove service file
${BOLD}SYSTEM COMMANDS:${RESET}
menu Interactive TUI menu
install Install TunnelForge
health Run health check
server-setup Harden local server for tunnels
server-setup <name> Enable forwarding on remote server
obfs-setup <name> Set up TLS obfuscation (stunnel) on server
client-config <name> Show client connection config (TLS+PSK)
client-script <name> Generate client scripts (Linux + Windows)
backup Backup profiles + keys
restore [file] Restore from backup
update Check for updates and install latest
uninstall Remove everything
version Show version
help Show this help
${BOLD}EXAMPLES:${RESET}
tunnelforge create # Interactive wizard
tunnelforge start office-proxy # Start a tunnel
tunnelforge dashboard # Live monitoring
tunnelforge service myproxy enable # Autostart on boot
tunnelforge backup # Backup everything
"
}
show_version() { printf "%s v%s\n" "$APP_NAME" "$VERSION"; }
show_status() {
local profiles name
profiles=$(list_profiles)
if [[ -z "$profiles" ]]; then
log_info "No profiles configured. Run 'tunnelforge create' to get started."
return 0
fi
local _st_width
_st_width=$(get_term_width)
if (( _st_width > 120 )); then _st_width=120; fi
if (( _st_width < 82 )); then _st_width=82; fi
local _name_col=$(( _st_width - 62 ))
if (( _name_col < 18 )); then _name_col=18; fi
printf "\n${BOLD}%-${_name_col}s %-8s %-10s %-22s %-12s %-10s${RESET}\n" \
"NAME" "TYPE" "STATUS" "LOCAL" "TRAFFIC" "UPTIME"
print_line "─" "$_st_width"
while IFS= read -r name; do
[[ -z "$name" ]] && continue
unset _st 2>/dev/null || true
local -A _st=()
load_profile "$name" _st 2>/dev/null || continue
local ttype="${_st[TUNNEL_TYPE]:-?}"
local addr="${_st[LOCAL_BIND_ADDR]:-}:${_st[LOCAL_PORT]:-}"
if is_tunnel_running "$name"; then
local up_s up_str traffic rchar wchar total traf_str
up_s=$(get_tunnel_uptime "$name" 2>/dev/null || true)
: "${up_s:=0}"
up_str=$(format_duration "$up_s")
traffic=$(get_tunnel_traffic "$name" 2>/dev/null || true)
: "${traffic:=0 0}"
read -r rchar wchar <<< "$traffic"
[[ "$rchar" =~ ^[0-9]+$ ]] || rchar=0
[[ "$wchar" =~ ^[0-9]+$ ]] || wchar=0
total=$(( rchar + wchar ))
traf_str=$(format_bytes "$total")
local _nd=$(( _name_col - 2 ))
printf " %-${_nd}s %-8s %s %-7s %-22s %-12s %-10s\n" \
"$name" "${ttype^^}" "${GREEN}${RESET}" "${GREEN}ALIVE${RESET}" \
"$addr" "$traf_str" "$up_str"
else
local _nd=$(( _name_col - 2 ))
printf " %-${_nd}s %-8s %s %-7s ${DIM}%-22s %-12s %-10s${RESET}\n" \
"$name" "${ttype^^}" "${DIM}${RESET}" "${DIM}STOP${RESET}" \
"$addr" "0 B" "-"
fi
done <<< "$profiles"
printf "\n"
}
# ============================================================================
# SETUP WIZARD (Phase 2)
# ============================================================================
# Read a line from the terminal (works even when stdin is piped)
_read_tty() {
local _prompt="$1" _var_name="$2" _default="${3:-}"
local _input
if [[ -n "$_default" ]]; then
printf "${BOLD}%s${RESET} ${DIM}[%s]${RESET}: " "$_prompt" "$_default" >/dev/tty
else
printf "${BOLD}%s${RESET}: " "$_prompt" >/dev/tty
fi
if ! read -r _input </dev/tty; then
# EOF on /dev/tty — use default if available, otherwise signal EOF
if [[ -n "$_default" ]]; then
_input="$_default"
else
printf -v "$_var_name" '%s' ""
return 1
fi
fi
_input="${_input:-$_default}"
printf -v "$_var_name" '%s' "$_input"
}
_read_secret_tty() {
local _prompt="$1" _var_name="$2" _default="${3:-}"
local _input
if [[ -n "$_default" ]]; then
printf "${BOLD}%s${RESET} ${DIM}[****]${RESET}: " "$_prompt" >/dev/tty
else
printf "${BOLD}%s${RESET}: " "$_prompt" >/dev/tty
fi
if ! read -rs _input </dev/tty; then
if [[ -n "$_default" ]]; then
_input="$_default"
else
printf "\n" >/dev/tty
printf -v "$_var_name" '%s' ""
return 1
fi
fi
printf "\n" >/dev/tty
_input="${_input:-$_default}"
printf -v "$_var_name" '%s' "$_input"
}
# Read a yes/no answer; returns 0=yes, 1=no
_read_yn() {
local _prompt="$1" _default="${2:-n}"
local _input _hint="y/N"
if [[ "$_default" == "y" ]]; then _hint="Y/n"; fi
printf "${BOLD}%s${RESET} ${DIM}[%s]${RESET}: " "$_prompt" "$_hint" >/dev/tty
read -r _input </dev/tty || true
_input="${_input:-$_default}"
if [[ "${_input,,}" == "y" || "${_input,,}" == "yes" ]]; then return 0; else return 1; fi
}
# Display a numbered selection menu and return 0-based index
_select_option() {
if [[ ! -t 0 ]] && [[ ! -e /dev/tty ]]; then
log_error "Interactive terminal required"
return 1
fi
local _title="$1"
shift
local _options=("$@")
local _count=${#_options[@]}
printf "\n${BOLD}%s${RESET}\n" "$_title" >/dev/tty
local _sep=""
for (( _si=0; _si<40; _si++ )); do _sep+="─"; done
printf " %s\n" "$_sep" >/dev/tty
local _oi
for (( _oi=0; _oi<_count; _oi++ )); do
printf " ${CYAN}%d${RESET}) %s\n" "$((_oi+1))" "${_options[$_oi]}" >/dev/tty
done
printf "\n" >/dev/tty
local _choice
while true; do
printf "${BOLD}Select [1-%d]${RESET} ${DIM}(q=quit, b=back)${RESET}: " "$_count" >/dev/tty
if ! read -r _choice </dev/tty; then return 1; fi
if [[ "${_choice,,}" == "q" || "${_choice,,}" == "quit" ]]; then
_WIZ_NAV="quit"; echo "q"; return 1
fi
if [[ "${_choice,,}" == "b" || "${_choice,,}" == "back" ]]; then
_WIZ_NAV="back"; echo "b"; return 1
fi
if [[ "$_choice" =~ ^[0-9]+$ ]] && (( _choice >= 1 && _choice <= _count )); then
_WIZ_NAV=""
echo "$((_choice - 1))"
return 0
fi
printf " ${RED}Invalid choice. Try again.${RESET}\n" >/dev/tty
done
}
# Wait for keypress
_press_any_key() {
printf "\n${DIM}Press any key to continue...${RESET}" >/dev/tty 2>/dev/null || true
read -rsn1 _ </dev/tty || true
_drain_esc _
printf "\n" >/dev/tty 2>/dev/null || true
}
# Test SSH connectivity
test_ssh_connection() {
local host="$1" port="$2" user="$3" key="${4:-}" password="${5:-}"
printf "\n${CYAN}Testing SSH connection to %s@%s:%s...${RESET}\n" \
"$user" "$host" "$port" >/dev/tty
local -a ssh_args=(-o "ConnectTimeout=10" -p "$port")
# Use accept-new for test: accepts host key on first connect (saves to known_hosts),
# rejects if key changes later. Subsequent tunnel connections use strict=yes.
ssh_args+=(-o "StrictHostKeyChecking=accept-new")
if [[ -n "$key" ]] && [[ -f "$key" ]]; then ssh_args+=(-i "$key"); fi
local -a cmd_prefix=()
if [[ -n "$password" ]]; then
if ! command -v sshpass &>/dev/null; then
printf " ${DIM}Installing sshpass...${RESET}\n" >/dev/tty
if [[ -n "${PKG_UPDATE:-}" ]]; then ${PKG_UPDATE} &>/dev/null || true; fi
install_package "sshpass" 2>/dev/null || true
fi
if command -v sshpass &>/dev/null; then
cmd_prefix=(env "SSHPASS=${password}" sshpass -e)
ssh_args+=(-o "BatchMode=no")
else
# No sshpass — let SSH prompt interactively on /dev/tty
ssh_args+=(-o "BatchMode=no")
printf " ${DIM}(sshpass unavailable — SSH will prompt for password)${RESET}\n" >/dev/tty
fi
else
ssh_args+=(-o "BatchMode=yes")
fi
local _test_output _test_rc=0
_test_output=$("${cmd_prefix[@]}" ssh "${ssh_args[@]}" "${user}@${host}" "echo ok" 2>&1 </dev/tty) || _test_rc=$?
if [[ "$_test_rc" -eq 0 ]] && [[ "$_test_output" == *"ok"* ]]; then
printf " ${GREEN}● Authentication successful${RESET}\n" >/dev/tty
return 0
else
printf " ${RED}✗ Authentication failed${RESET}\n" >/dev/tty
if [[ -n "$_test_output" ]]; then
printf " ${DIM}%s${RESET}\n" "$_test_output" >/dev/tty
fi
return 1
fi
}
# ── Wizard navigation helpers ──
declare -g _WIZ_NAV=""
_wiz_read() {
_WIZ_NAV=""
local _wr_prompt="$1" _wr_var="$2" _wr_default="${3:-}"
local _wr_input
if [[ -n "$_wr_default" ]]; then
printf "${BOLD}%s${RESET} ${DIM}[%s]${RESET} ${DIM}(q/b)${RESET}: " "$_wr_prompt" "$_wr_default" >/dev/tty
else
printf "${BOLD}%s${RESET} ${DIM}(q/b)${RESET}: " "$_wr_prompt" >/dev/tty
fi
if ! read -r _wr_input </dev/tty; then
if [[ -n "$_wr_default" ]]; then _wr_input="$_wr_default"
else printf -v "$_wr_var" '%s' ""; return 0; fi
fi
_wr_input="${_wr_input:-$_wr_default}"
printf -v "$_wr_var" '%s' "$_wr_input"
if [[ "${_wr_input,,}" == "q" || "${_wr_input,,}" == "quit" ]]; then
_WIZ_NAV="quit"; printf -v "$_wr_var" '%s' ""
elif [[ "${_wr_input,,}" == "b" || "${_wr_input,,}" == "back" ]]; then
_WIZ_NAV="back"; printf -v "$_wr_var" '%s' ""
fi
}
_wiz_yn() {
_WIZ_NAV=""
local _prompt="$1" _default="${2:-n}"
local _input _hint="y/N"
if [[ "$_default" == "y" ]]; then _hint="Y/n"; fi
printf "${BOLD}%s${RESET} ${DIM}[%s] (q/b)${RESET}: " "$_prompt" "$_hint" >/dev/tty
read -r _input </dev/tty || true
if [[ "${_input,,}" == "q" || "${_input,,}" == "quit" ]]; then
_WIZ_NAV="quit"; return 1
fi
if [[ "${_input,,}" == "b" || "${_input,,}" == "back" ]]; then
_WIZ_NAV="back"; return 1
fi
_input="${_input:-$_default}"
if [[ "${_input,,}" == "y" || "${_input,,}" == "yes" ]]; then return 0; else return 1; fi
}
_wiz_quit() { [[ "$_WIZ_NAV" == "quit" ]]; }
_wiz_back() { [[ "$_WIZ_NAV" == "back" ]]; }
_wiz_nav() { [[ "$_WIZ_NAV" == "quit" || "$_WIZ_NAV" == "back" ]]; }
_wiz_header() {
printf "\n${BOLD_CYAN}── %s ──${RESET} ${DIM}(q=quit, b=back)${RESET}\n\n" "$1" >/dev/tty
}
# ── Per-type sub-wizards ──
wizard_socks5() {
local -n _ws_prof="$1"
local _ss=1 bind="" port=""
while (( _ss >= 1 )); do
case $_ss in
1)
printf "\n${BOLD_MAGENTA}── SOCKS5 Proxy Configuration ──${RESET} ${DIM}(q=quit, b=back)${RESET}\n\n" >/dev/tty
cat >/dev/tty <<'DIAGRAM'
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │──SOCKS5──│ SSH Host │──────────│ Internet │
(local) │ :1080 │ (proxy) │ │ │
└──────────┘ └──────────┘ └──────────┘
ssh -D 1080 user@host
DIAGRAM
printf "\n" >/dev/tty
printf "${DIM} Your apps connect to a local SOCKS5 port and${RESET}\n" >/dev/tty
printf "${DIM} all traffic routes through the SSH server.${RESET}\n" >/dev/tty
printf "${DIM} After setup: set your browser proxy to this${RESET}\n" >/dev/tty
printf "${DIM} address and port.${RESET}\n\n" >/dev/tty
printf "${DIM} Tip: 127.0.0.1 = local only${RESET}\n" >/dev/tty
printf "${DIM} 0.0.0.0 = allow LAN devices${RESET}\n" >/dev/tty
_wiz_read "Local bind address" bind "${_ws_prof[LOCAL_BIND_ADDR]:-127.0.0.1}"
if _wiz_quit; then return 1; fi
if _wiz_back; then return 2; fi
if ! { validate_ip "$bind" || validate_ip6 "$bind" || [[ "$bind" == "localhost" ]] || [[ "$bind" == "*" ]]; }; then
printf " ${RED}Invalid bind address: ${bind}${RESET}\n" >/dev/tty; continue
fi
(( ++_ss )) ;;
2)
printf "${DIM} Tip: Common ports: 1080 (standard), 9050 (Tor-style). Avoid ports < 1024${RESET}\n" >/dev/tty
_wiz_read "Local SOCKS5 port" port "${_ws_prof[LOCAL_PORT]:-1080}"
if _wiz_quit; then return 1; fi
if _wiz_back; then (( --_ss )); continue; fi
if ! validate_port "$port"; then
printf " ${RED}Invalid port: ${port}${RESET}\n" >/dev/tty; continue
fi
if ! _check_port_conflict "$port"; then continue; fi
_ws_prof[LOCAL_BIND_ADDR]="$bind"
_ws_prof[LOCAL_PORT]="$port"
_ws_prof[REMOTE_HOST]=""
_ws_prof[REMOTE_PORT]=""
return 0 ;;
esac
done
}
wizard_local_forward() {
local -n _wlf_prof="$1"
local _ls=1 bind="" lport="" rhost="" rport=""
while (( _ls >= 1 )); do
case $_ls in
1)
printf "\n${BOLD_MAGENTA}── Local Port Forward Configuration ──${RESET} ${DIM}(q=quit, b=back)${RESET}\n\n" >/dev/tty
cat >/dev/tty <<'DIAGRAM'
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │──Local───│ SSH Host │──────────│ Remote │
│ :8080 │ Fwd │ (relay) │ │ :8080 │
└──────────┘ └──────────┘ └──────────┘
ssh -L 8080:127.0.0.1:8080 user@host
DIAGRAM
printf "\n" >/dev/tty
printf "${DIM} A port opens on THIS machine and connects${RESET}\n" >/dev/tty
printf "${DIM} through SSH to a service on the remote side.${RESET}\n" >/dev/tty
printf "${DIM} Example: VPS port 3306 (MySQL) → localhost:3306${RESET}\n\n" >/dev/tty
printf "${DIM} Tip: 127.0.0.1 = local only${RESET}\n" >/dev/tty
printf "${DIM} 0.0.0.0 = allow LAN devices${RESET}\n" >/dev/tty
_wiz_read "Local bind address" bind "${_wlf_prof[LOCAL_BIND_ADDR]:-127.0.0.1}"
if _wiz_quit; then return 1; fi
if _wiz_back; then return 2; fi
if ! { validate_ip "$bind" || validate_ip6 "$bind" || [[ "$bind" == "localhost" ]] || [[ "$bind" == "*" ]]; }; then
printf " ${RED}Invalid bind address: ${bind}${RESET}\n" >/dev/tty; continue
fi
(( ++_ls )) ;;
2)
printf "${DIM} Tip: The port you will connect to on THIS machine (e.g. 8080, 3306, 5432)${RESET}\n" >/dev/tty
_wiz_read "Local port" lport "${_wlf_prof[LOCAL_PORT]:-8080}"
if _wiz_quit; then return 1; fi
if _wiz_back; then (( --_ls )); continue; fi
if ! validate_port "$lport"; then
printf " ${RED}Invalid port: ${lport}${RESET}\n" >/dev/tty; continue
fi
if ! _check_port_conflict "$lport"; then continue; fi
(( ++_ls )) ;;
3)
printf "${DIM} Tip: 127.0.0.1 = service on the SSH server${RESET}\n" >/dev/tty
printf "${DIM} Or use another IP for a different machine.${RESET}\n" >/dev/tty
_wiz_read "Remote target host" rhost "${_wlf_prof[REMOTE_HOST]:-127.0.0.1}"
if _wiz_quit; then return 1; fi
if _wiz_back; then (( --_ls )); continue; fi
(( ++_ls )) ;;
4)
printf "${DIM} Tip: The port of the service on the remote side (e.g. 8080, 3306, 443)${RESET}\n" >/dev/tty
_wiz_read "Remote target port" rport "${_wlf_prof[REMOTE_PORT]:-8080}"
if _wiz_quit; then return 1; fi
if _wiz_back; then (( --_ls )); continue; fi
if ! validate_port "$rport"; then
printf " ${RED}Invalid port: ${rport}${RESET}\n" >/dev/tty; continue
fi
_wlf_prof[LOCAL_BIND_ADDR]="$bind"
_wlf_prof[LOCAL_PORT]="$lport"
_wlf_prof[REMOTE_HOST]="$rhost"
_wlf_prof[REMOTE_PORT]="$rport"
return 0 ;;
esac
done
}
wizard_remote_forward() {
local -n _wrf_prof="$1"
local _rs=1 bind="" rport="" lhost="" lport=""
while (( _rs >= 1 )); do
case $_rs in
1)
printf "\n${BOLD_MAGENTA}── Remote (Reverse) Forward Configuration ──${RESET} ${DIM}(q=quit, b=back)${RESET}\n\n" >/dev/tty
cat >/dev/tty <<'DIAGRAM'
┌──────────────┐ ┌──────────────┐ ┌──────────┐
│ THIS machine │──Reverse─│ SSH Server │──Listen──│ Users │
│ :3000 │ Fwd │ :9090 │ │ │
└──────────────┘ └──────────────┘ └──────────┘
ssh -R 9090:127.0.0.1:3000 user@host
DIAGRAM
printf "\n" >/dev/tty
printf "${DIM} A port opens on the SSH SERVER and connects${RESET}\n" >/dev/tty
printf "${DIM} back to a service on THIS machine.${RESET}\n" >/dev/tty
printf "${DIM} Example: local :3000 → reachable at VPS:9090${RESET}\n\n" >/dev/tty
printf "${DIM} Tip: 127.0.0.1 = SSH server only${RESET}\n" >/dev/tty
printf "${DIM} 0.0.0.0 = public (needs GatewayPorts=yes)${RESET}\n" >/dev/tty
_wiz_read "Remote bind address (on SSH server)" bind "${_wrf_prof[LOCAL_BIND_ADDR]:-127.0.0.1}"
if _wiz_quit; then return 1; fi
if _wiz_back; then return 2; fi
if ! { validate_ip "$bind" || validate_ip6 "$bind" || [[ "$bind" == "localhost" ]] || [[ "$bind" == "*" ]]; }; then
printf " ${RED}Invalid bind address: ${bind}${RESET}\n" >/dev/tty; continue
fi
(( ++_rs )) ;;
2)
printf "${DIM} Tip: Port to open on the SSH server.${RESET}\n" >/dev/tty
printf "${DIM} Example: 9090, 8080, 443${RESET}\n" >/dev/tty
_wiz_read "Remote listen port (on SSH server)" rport "${_wrf_prof[REMOTE_PORT]:-9090}"
if _wiz_quit; then return 1; fi
if _wiz_back; then (( --_rs )); continue; fi
if ! validate_port "$rport"; then
printf " ${RED}Invalid port: ${rport}${RESET}\n" >/dev/tty; continue
fi
(( ++_rs )) ;;
3)
printf "${DIM} Tip: 127.0.0.1 = this machine, or another${RESET}\n" >/dev/tty
printf "${DIM} IP for a LAN device.${RESET}\n" >/dev/tty
_wiz_read "Local service host" lhost "${_wrf_prof[REMOTE_HOST]:-127.0.0.1}"
if _wiz_quit; then return 1; fi
if _wiz_back; then (( --_rs )); continue; fi
(( ++_rs )) ;;
4)
printf "${DIM} Tip: What port is your local service running on? (e.g. 3000, 8080, 22)${RESET}\n" >/dev/tty
_wiz_read "Local service port" lport "${_wrf_prof[LOCAL_PORT]:-3000}"
if _wiz_quit; then return 1; fi
if _wiz_back; then (( --_rs )); continue; fi
if ! validate_port "$lport"; then
printf " ${RED}Invalid port: ${lport}${RESET}\n" >/dev/tty; continue
fi
_wrf_prof[LOCAL_BIND_ADDR]="$bind"
_wrf_prof[LOCAL_PORT]="$lport"
_wrf_prof[REMOTE_HOST]="$lhost"
_wrf_prof[REMOTE_PORT]="$rport"
return 0 ;;
esac
done
}
wizard_jump_host() {
local -n _wjh_prof="$1"
local _js=1 jumps="" _jh_ttype="" bind="" port="" lport="" rhost="" rport=""
while (( _js >= 1 )); do
case $_js in
1)
printf "\n${BOLD_MAGENTA}── Jump Host (Multi-Hop) Configuration ──${RESET} ${DIM}(q=quit, b=back)${RESET}\n\n" >/dev/tty
cat >/dev/tty <<'DIAGRAM'
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │─────│ Jump 1 │─────│ Jump 2 │─────│ Target │
(local) │ │ (relay) │ │ (relay) │ │ (final)
└──────────┘ └──────────┘ └──────────┘ └──────────┘
ssh -J jump1,jump2 user@target -D 1080
DIAGRAM
printf "\n" >/dev/tty
printf "${DIM} SSH hops through intermediate servers${RESET}\n" >/dev/tty
printf "${DIM} to reach the final target.${RESET}\n" >/dev/tty
printf "${DIM} Use when target is behind a firewall.${RESET}\n\n" >/dev/tty
printf "${DIM} Tip: Comma-separated, in hop order.${RESET}\n" >/dev/tty
printf "${DIM} Format: user@host:port or just host${RESET}\n" >/dev/tty
printf "${DIM} e.g. admin@bastion:22,10.0.0.5${RESET}\n\n" >/dev/tty
_wiz_read "Jump hosts (comma-separated)" jumps "${_wjh_prof[JUMP_HOSTS]:-}"
if _wiz_quit; then return 1; fi
if _wiz_back; then return 2; fi
if [[ -z "$jumps" ]]; then
printf " ${RED}At least one jump host is required${RESET}\n" >/dev/tty; continue
fi
(( ++_js )) ;;
2)
# Select tunnel type at destination
printf "\n${BOLD}Choose tunnel type at destination:${RESET}\n" >/dev/tty
printf "${DIM} SOCKS5 = route all traffic (VPN-like)${RESET}\n" >/dev/tty
printf "${DIM} Local Forward = access a specific port${RESET}\n" >/dev/tty
local _jh_types=("SOCKS5 Proxy" "Local Port Forward")
local _jh_choice
_jh_choice=$(_select_option "Tunnel type at destination" "${_jh_types[@]}") || true
# _select_option echoes "q"/"b" for nav (since _WIZ_NAV is lost in subshell)
if [[ "$_jh_choice" == "q" ]]; then _WIZ_NAV="quit"; return 1; fi
if [[ "$_jh_choice" == "b" ]]; then (( --_js )); continue; fi
case "$_jh_choice" in
0) _jh_ttype="socks5" ;;
1) _jh_ttype="local" ;;
*) continue ;;
esac
(( ++_js )) ;;
3)
printf "${DIM} Tip: 127.0.0.1 = local only${RESET}\n" >/dev/tty
printf "${DIM} 0.0.0.0 = allow LAN devices${RESET}\n" >/dev/tty
if [[ "$_jh_ttype" == "socks5" ]]; then
_wiz_read "Local SOCKS5 bind address" bind "${_wjh_prof[LOCAL_BIND_ADDR]:-127.0.0.1}"
else
_wiz_read "Local bind address" bind "${_wjh_prof[LOCAL_BIND_ADDR]:-127.0.0.1}"
fi
if _wiz_quit; then return 1; fi
if _wiz_back; then (( --_js )); continue; fi
if ! { validate_ip "$bind" || validate_ip6 "$bind" || [[ "$bind" == "localhost" ]] || [[ "$bind" == "*" ]]; }; then
printf " ${RED}Invalid bind address: ${bind}${RESET}\n" >/dev/tty; continue
fi
(( ++_js )) ;;
4)
if [[ "$_jh_ttype" == "socks5" ]]; then
printf "${DIM} Tip: Common ports: 1080 (standard), 9050 (Tor-style). Avoid ports < 1024${RESET}\n" >/dev/tty
_wiz_read "Local SOCKS5 port" port "${_wjh_prof[LOCAL_PORT]:-1080}"
if _wiz_quit; then return 1; fi
if _wiz_back; then (( --_js )); continue; fi
if ! validate_port "$port"; then
printf " ${RED}Invalid port: ${port}${RESET}\n" >/dev/tty; continue
fi
if ! _check_port_conflict "$port"; then continue; fi
# Save SOCKS5 config
_wjh_prof[JUMP_HOSTS]="$jumps"
_wjh_prof[TUNNEL_TYPE]="socks5"
_wjh_prof[LOCAL_BIND_ADDR]="$bind"
_wjh_prof[LOCAL_PORT]="$port"
_wjh_prof[REMOTE_HOST]=""
_wjh_prof[REMOTE_PORT]=""
return 0
else
printf "${DIM} Tip: The port you will connect to on THIS machine (e.g. 8080, 3306, 5432)${RESET}\n" >/dev/tty
_wiz_read "Local port" lport "${_wjh_prof[LOCAL_PORT]:-8080}"
if _wiz_quit; then return 1; fi
if _wiz_back; then (( --_js )); continue; fi
if ! validate_port "$lport"; then
printf " ${RED}Invalid port: ${lport}${RESET}\n" >/dev/tty; continue
fi
if ! _check_port_conflict "$lport"; then continue; fi
(( ++_js ))
fi ;;
5)
printf "${DIM} Tip: The host the final SSH server connects to (127.0.0.1 = the target itself)${RESET}\n" >/dev/tty
_wiz_read "Remote target host" rhost "${_wjh_prof[REMOTE_HOST]:-127.0.0.1}"
if _wiz_quit; then return 1; fi
if _wiz_back; then (( --_js )); continue; fi
(( ++_js )) ;;
6)
printf "${DIM} Tip: The port of the service on the remote side (e.g. 8080, 3306, 443)${RESET}\n" >/dev/tty
_wiz_read "Remote target port" rport "${_wjh_prof[REMOTE_PORT]:-8080}"
if _wiz_quit; then return 1; fi
if _wiz_back; then (( --_js )); continue; fi
if ! validate_port "$rport"; then
printf " ${RED}Invalid port: ${rport}${RESET}\n" >/dev/tty; continue
fi
# Save Local Forward config
_wjh_prof[JUMP_HOSTS]="$jumps"
_wjh_prof[TUNNEL_TYPE]="local"
_wjh_prof[LOCAL_BIND_ADDR]="$bind"
_wjh_prof[LOCAL_PORT]="$lport"
_wjh_prof[REMOTE_HOST]="$rhost"
_wjh_prof[REMOTE_PORT]="$rport"
return 0 ;;
esac
done
}
# ── Main wizard flow ──
wizard_create_profile() {
if [[ ! -t 0 ]] && [[ ! -e /dev/tty ]]; then
log_error "Interactive terminal required for wizard"
return 1
fi
_WIZ_NAV=""
local _step=1
local name="" ssh_host="" ssh_port="" ssh_user="" ssh_password="" identity_key=""
local tunnel_type="" type_choice="" desc=""
local -A _new_profile=()
while (( _step >= 1 )); do
case $_step in
1) # ── Profile name ──
show_banner >/dev/tty
_wiz_header "New Tunnel Profile"
printf "${DIM} A profile saves all settings for one tunnel connection.${RESET}\n" >/dev/tty
printf "${DIM} Tip: Use a short descriptive name (e.g. 'work-vpn', 'db-tunnel', 'home-proxy')${RESET}\n" >/dev/tty
_wiz_read "Profile name" name ""
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then log_info "Already at first step"; continue; fi
if [[ -z "$name" ]]; then
printf " ${RED}Name cannot be empty${RESET}\n" >/dev/tty; continue
fi
if ! validate_profile_name "$name"; then
printf " ${RED}Invalid name. Use letters, numbers, hyphens, underscores (max 64 chars)${RESET}\n" >/dev/tty; continue
fi
if [[ -f "$(_profile_path "$name")" ]]; then
printf " ${RED}Profile '%s' already exists${RESET}\n" "$name" >/dev/tty; continue
fi
(( ++_step )) ;;
2) # ── Tunnel type selection ──
_wiz_header "Choose Tunnel Type"
printf "${DIM} What type of tunnel do you need?${RESET}\n\n" >/dev/tty
printf " ${BOLD}SOCKS5 Proxy${RESET} ${DIM}Route all traffic through${RESET}\n" >/dev/tty
printf " ${DIM} the remote server (VPN-like)${RESET}\n\n" >/dev/tty
printf " ${BOLD}Local Forward${RESET} ${DIM}Access a remote service${RESET}\n" >/dev/tty
printf " ${DIM} on your local machine${RESET}\n\n" >/dev/tty
printf " ${BOLD}Remote Forward${RESET} ${DIM}Expose a local service${RESET}\n" >/dev/tty
printf " ${DIM} to the remote server${RESET}\n\n" >/dev/tty
printf " ${BOLD}Jump Host${RESET} ${DIM}Connect through relay${RESET}\n" >/dev/tty
printf " ${DIM} servers to the target${RESET}\n\n" >/dev/tty
local _wz_types=("SOCKS5 Proxy (-D)" "Local Port Forward (-L)" "Remote/Reverse Forward (-R)" "Jump Host / Multi-hop (-J)")
type_choice=$(_select_option "Select tunnel type" "${_wz_types[@]}") || true
# _select_option echoes "q"/"b" for nav (since _WIZ_NAV is lost in subshell)
if [[ "$type_choice" == "q" ]]; then log_info "Wizard cancelled"; return 0; fi
if [[ "$type_choice" == "b" ]]; then (( --_step )); continue; fi
case "$type_choice" in
0) tunnel_type="socks5" ;;
1) tunnel_type="local" ;;
2) tunnel_type="remote" ;;
3) tunnel_type="jump" ;;
*) continue ;;
esac
(( ++_step )) ;;
3) # ── SSH host ──
_wiz_header "SSH Connection Details"
printf "${DIM} Enter the details of the SSH server you want to connect to.${RESET}\n\n" >/dev/tty
case "$tunnel_type" in
socks5) printf "${DIM} Tip: This server will proxy your traffic${RESET}\n" >/dev/tty ;;
local) printf "${DIM} Tip: Server with the service you want to access${RESET}\n" >/dev/tty ;;
remote) printf "${DIM} Tip: Server where your local service will be exposed${RESET}\n" >/dev/tty ;;
jump) printf "${DIM} Tip: FINAL target server (jump hosts configured next)${RESET}\n" >/dev/tty ;;
esac
_wiz_read "SSH host (IP or hostname)" ssh_host "${ssh_host:-}"
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then (( --_step )); continue; fi
if [[ -z "$ssh_host" ]]; then
printf " ${RED}SSH host is required${RESET}\n" >/dev/tty; continue
fi
if ! validate_hostname "$ssh_host"; then
printf " ${RED}Invalid hostname: ${ssh_host}${RESET}\n" >/dev/tty; continue
fi
(( ++_step )) ;;
4) # ── SSH port ──
printf "${DIM} Tip: Default SSH port is 22. Change only if your server uses a custom port${RESET}\n" >/dev/tty
_wiz_read "SSH port" ssh_port "${ssh_port:-$(config_get SSH_DEFAULT_PORT 22)}"
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then (( --_step )); continue; fi
if ! validate_port "$ssh_port"; then
printf " ${RED}Invalid port: ${ssh_port}${RESET}\n" >/dev/tty; continue
fi
(( ++_step )) ;;
5) # ── SSH user ──
printf "${DIM} Tip: The username to log in with (e.g. root, ubuntu, admin)${RESET}\n" >/dev/tty
if [[ "$tunnel_type" == "jump" ]]; then
printf "${DIM} Note: This is the user on the FINAL target, not the jump host.${RESET}\n" >/dev/tty
fi
_wiz_read "SSH user" ssh_user "${ssh_user:-$(config_get SSH_DEFAULT_USER root)}"
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then (( --_step )); continue; fi
(( ++_step )) ;;
6) # ── SSH password ──
printf "${DIM} Tip: Enter your SSH password, or press Enter to skip if using SSH keys.${RESET}\n" >/dev/tty
printf "${DIM} The password is stored securely and used for automatic login.${RESET}\n" >/dev/tty
if [[ "$tunnel_type" == "jump" ]]; then
printf "${DIM} Note: This is for the FINAL target, not the jump host.${RESET}\n" >/dev/tty
fi
_read_secret_tty "SSH password (Enter to skip)" ssh_password "$ssh_password" || true
# No q/b detection for passwords — password could literally be "q" or "b"
(( ++_step )) ;;
7) # ── Identity key ──
printf "${DIM} Tip: Path to your SSH private key file (e.g. ~/.ssh/id_rsa, ~/.ssh/id_ed25519)${RESET}\n" >/dev/tty
printf "${DIM} Press Enter to skip if you entered a password above.${RESET}\n" >/dev/tty
if [[ "$tunnel_type" == "jump" ]]; then
printf "${DIM} Note: This key is for the FINAL target, not the jump host.${RESET}\n" >/dev/tty
fi
_wiz_read "Identity key path (optional)" identity_key "${identity_key:-$(config_get SSH_DEFAULT_KEY)}"
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then (( --_step )); continue; fi
if [[ -n "$identity_key" ]] && [[ ! -f "$identity_key" ]]; then
log_warn "Key file not found: ${identity_key}"
if ! _wiz_yn "Continue anyway?"; then
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then (( --_step )); continue; fi
continue # "no" → re-ask for key path
fi
fi
(( ++_step )) ;;
8) # ── Auth test ──
_wiz_header "Testing Authentication"
printf "${DIM} Verifying SSH connection to ${ssh_user}@${ssh_host}:${ssh_port}...${RESET}\n" >/dev/tty
if [[ "$tunnel_type" == "jump" ]]; then
printf "${DIM} Note: Direct test only — jump hosts are configured next.${RESET}\n" >/dev/tty
fi
if ! test_ssh_connection "$ssh_host" "$ssh_port" "$ssh_user" "$identity_key" "$ssh_password"; then
printf "\n${DIM} Common fixes: wrong password, wrong user,${RESET}\n" >/dev/tty
printf "${DIM} host unreachable, or SSH key not accepted.${RESET}\n" >/dev/tty
if [[ "$tunnel_type" == "jump" ]]; then
printf "${DIM} For jump hosts, this test may fail if the${RESET}\n" >/dev/tty
printf "${DIM} target is only reachable via relay servers.${RESET}\n" >/dev/tty
fi
printf "${DIM} 'no' takes you back to edit details.${RESET}\n\n" >/dev/tty
if ! _wiz_yn "Authentication failed. Continue anyway?"; then
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
_step=5; continue # back to user/password/key
fi
fi
(( ++_step )) ;;
9) # ── Type-specific sub-wizard ──
_new_profile=(
[PROFILE_NAME]="$name"
[TUNNEL_TYPE]="$tunnel_type"
[SSH_HOST]="$ssh_host"
[SSH_PORT]="$ssh_port"
[SSH_USER]="$ssh_user"
[SSH_PASSWORD]="$ssh_password"
[IDENTITY_KEY]="$identity_key"
[LOCAL_BIND_ADDR]="127.0.0.1"
[LOCAL_PORT]=""
[REMOTE_HOST]=""
[REMOTE_PORT]=""
[JUMP_HOSTS]=""
[SSH_OPTIONS]=""
[AUTOSSH_ENABLED]="$(config_get AUTOSSH_ENABLED true)"
[AUTOSSH_MONITOR_PORT]="0"
[DNS_LEAK_PROTECTION]="false"
[KILL_SWITCH]="false"
[AUTOSTART]="false"
[OBFS_MODE]="none"
[OBFS_PORT]="443"
[OBFS_LOCAL_PORT]=""
[OBFS_PSK]=""
[DESCRIPTION]=""
)
local _sub_rc=0
case "$tunnel_type" in
socks5) wizard_socks5 _new_profile || _sub_rc=$? ;;
local) wizard_local_forward _new_profile || _sub_rc=$? ;;
remote) wizard_remote_forward _new_profile || _sub_rc=$? ;;
jump) wizard_jump_host _new_profile || _sub_rc=$? ;;
esac
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back || (( _sub_rc == 2 )); then _step=2; continue; fi
if (( _sub_rc != 0 )); then return 1; fi
(( ++_step )) ;;
10) # ── Connection mode: Regular SSH or TLS encrypted ──
_wiz_header "Connection Mode"
printf "${DIM} Choose how your SSH connection reaches the server.${RESET}\n\n" >/dev/tty
printf " ${BOLD}1)${RESET} ${GREEN}Regular SSH${RESET} — standard SSH connection (default)\n" >/dev/tty
printf " ${DIM}Works everywhere SSH is not blocked.${RESET}\n" >/dev/tty
printf " ${BOLD}2)${RESET} ${CYAN}TLS Encrypted (stunnel)${RESET} — SSH wrapped in HTTPS\n" >/dev/tty
printf " ${DIM}Bypasses DPI firewalls (Iran, China, etc.)${RESET}\n" >/dev/tty
printf " ${DIM}Traffic looks like normal HTTPS on port 443.${RESET}\n\n" >/dev/tty
printf " Regular: TLS Encrypted:\n" >/dev/tty
printf " ┌──────┐ SSH:22 ┌──────┐ ┌──────┐ TLS:443 ┌────────┐\n" >/dev/tty
printf " │Client├────────┤Server│ │Client├─────────┤stunnel │\n" >/dev/tty
printf " └──────┘ └──────┘ └──────┘ HTTPS │→SSH :22│\n" >/dev/tty
printf " └────────┘\n" >/dev/tty
printf "\n" >/dev/tty
local _conn_mode=""
_wiz_read "Connection mode [1=Regular, 2=TLS]" _conn_mode "1"
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then _step=2; continue; fi
case "$_conn_mode" in
2)
_new_profile[OBFS_MODE]="stunnel"
local _obfs_port=""
printf "\n${DIM} Port 443 mimics HTTPS (most effective).${RESET}\n" >/dev/tty
printf "${DIM} Only change if 443 is already in use on the server.${RESET}\n" >/dev/tty
_wiz_read "TLS port" _obfs_port "${_new_profile[OBFS_PORT]:-443}"
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then continue; fi
if ! validate_port "$_obfs_port"; then
printf " ${RED}Invalid port: ${_obfs_port}${RESET}\n" >/dev/tty
continue
fi
# Check if port is available on the remote server
printf "\n${DIM} Checking port ${_obfs_port} on ${ssh_host}...${RESET}\n" >/dev/tty
local _port_check_rc=0
_obfs_remote_ssh _new_profile || _port_check_rc=1
if (( _port_check_rc == 0 )); then
local _port_in_use=""
_port_in_use=$("${_OBFS_SSH_CMD[@]}" "ss -tln 2>/dev/null | grep -E ':${_obfs_port}[[:space:]]' | head -1" 2>/dev/null) || true
unset SSHPASS 2>/dev/null || true
if [[ -n "$_port_in_use" ]]; then
printf " ${YELLOW}Port ${_obfs_port} is in use on ${ssh_host}:${RESET}\n" >/dev/tty
printf " ${DIM}${_port_in_use}${RESET}\n" >/dev/tty
local _alt_port="8443"
if [[ "$_obfs_port" == "8443" ]]; then _alt_port="8444"; fi
printf " ${DIM}Suggested alternative: ${_alt_port}${RESET}\n\n" >/dev/tty
_wiz_read "TLS port (try ${_alt_port})" _obfs_port "$_alt_port"
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then continue; fi
if ! validate_port "$_obfs_port"; then
printf " ${RED}Invalid port: ${_obfs_port}${RESET}\n" >/dev/tty
continue
fi
else
printf " ${GREEN}Port ${_obfs_port} is available${RESET}\n" >/dev/tty
fi
else
unset SSHPASS 2>/dev/null || true
printf " ${DIM}Could not check (SSH failed) — will verify during setup${RESET}\n" >/dev/tty
fi
_new_profile[OBFS_PORT]="$_obfs_port"
printf "\n${DIM} TunnelForge can install stunnel on your server automatically.${RESET}\n" >/dev/tty
printf "${DIM} This requires SSH access (using the credentials above).${RESET}\n" >/dev/tty
if _wiz_yn "Set up stunnel on server now?"; then
_obfs_setup_stunnel_direct _new_profile || true
fi
printf "\n" >/dev/tty
;;
*)
_new_profile[OBFS_MODE]="none"
;;
esac
(( ++_step )) ;;
11) # ── Inbound TLS protection (for VPS deployments) ──
_wiz_header "Inbound Protection"
printf "${DIM} If this server is a VPS (not your home PC), users connecting${RESET}\n" >/dev/tty
printf "${DIM} from their devices need TLS protection too — otherwise DPI${RESET}\n" >/dev/tty
printf "${DIM} can detect the SOCKS5 traffic entering the VPS.${RESET}\n\n" >/dev/tty
printf " User PC ──TLS+PSK──→ This VPS ──tunnel──→ Exit VPS ──→ Internet\n\n" >/dev/tty
printf " ${BOLD}1)${RESET} ${GREEN}No inbound protection${RESET} — direct SOCKS5 access (default)\n" >/dev/tty
printf " ${DIM}Fine for home server or trusted LAN.${RESET}\n" >/dev/tty
printf " ${BOLD}2)${RESET} ${CYAN}TLS + PSK inbound${RESET} — encrypted + authenticated access\n" >/dev/tty
printf " ${DIM}Users need stunnel client + pre-shared key to connect.${RESET}\n" >/dev/tty
printf " ${DIM}Recommended for VPS in censored networks.${RESET}\n\n" >/dev/tty
local _inbound_mode=""
_wiz_read "Inbound protection [1=None, 2=TLS+PSK]" _inbound_mode "1"
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then (( --_step )); continue; fi
case "$_inbound_mode" in
2)
local _ol_port=""
printf "\n${DIM} Choose a port for client TLS connections.${RESET}\n" >/dev/tty
printf "${DIM} Use 443 or 8443 to look like HTTPS traffic.${RESET}\n" >/dev/tty
_wiz_read "Inbound TLS port" _ol_port "1443"
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then continue; fi
if ! validate_port "$_ol_port"; then
printf " ${RED}Invalid port: ${_ol_port}${RESET}\n" >/dev/tty
continue
fi
_new_profile[OBFS_LOCAL_PORT]="$_ol_port"
# Auto-generate PSK
printf "\n${DIM} Generating pre-shared key...${RESET}\n" >/dev/tty
local _gen_psk=""
_gen_psk=$(_obfs_generate_psk) || true
if [[ -z "$_gen_psk" ]]; then
printf " ${RED}Failed to generate PSK${RESET}\n" >/dev/tty
continue
fi
_new_profile[OBFS_PSK]="$_gen_psk"
printf " ${GREEN}PSK generated${RESET} ${DIM}(will be shown after tunnel starts)${RESET}\n" >/dev/tty
# Force 127.0.0.1 binding
_new_profile[LOCAL_BIND_ADDR]="127.0.0.1"
printf " ${DIM}Bind address forced to 127.0.0.1 (stunnel handles external access)${RESET}\n" >/dev/tty
printf "\n" >/dev/tty
;;
*)
_new_profile[OBFS_LOCAL_PORT]=""
_new_profile[OBFS_PSK]=""
;;
esac
(( ++_step )) ;;
12) # ── Optional settings ──
_wiz_header "Optional Settings"
printf "${DIM} Tip: A short note to help you remember${RESET}\n" >/dev/tty
printf "${DIM} e.g. 'MySQL access', 'browsing proxy'${RESET}\n" >/dev/tty
_wiz_read "Description (optional)" desc ""
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
# Go back to tunnel type selection (step 9 reinits _new_profile, so skip it)
if _wiz_back; then _step=2; continue; fi
_new_profile[DESCRIPTION]="$desc"
printf "\n${DIM} AutoSSH auto-reconnects if the tunnel drops.${RESET}\n" >/dev/tty
printf "${DIM} Recommended for long-running tunnels.${RESET}\n" >/dev/tty
if _wiz_yn "Enable AutoSSH reconnection?" "y"; then
_new_profile[AUTOSSH_ENABLED]="true"
else
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then continue; fi
_new_profile[AUTOSSH_ENABLED]="false"
fi
printf "\n${DIM} Creates a systemd service so this tunnel${RESET}\n" >/dev/tty
printf "${DIM} starts automatically on boot.${RESET}\n" >/dev/tty
if _wiz_yn "Auto-start on system boot?"; then
_new_profile[AUTOSTART]="true"
else
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then continue; fi
fi
(( ++_step )) ;;
13) # ── Summary + save ──
printf "\n${BOLD_CYAN}── Profile Summary ──${RESET}\n\n" >/dev/tty
printf " ${BOLD}Name:${RESET} %s\n" "$name" >/dev/tty
printf " ${BOLD}Type:${RESET} %s\n" "${_new_profile[TUNNEL_TYPE]^^}" >/dev/tty
printf " ${BOLD}SSH Host:${RESET} %s@%s:%s\n" "$ssh_user" "$ssh_host" "$ssh_port" >/dev/tty
if [[ -n "$ssh_password" ]]; then
printf " ${BOLD}Password:${RESET} ****\n" >/dev/tty
fi
if [[ -n "$identity_key" ]]; then
printf " ${BOLD}Key:${RESET} %s\n" "$identity_key" >/dev/tty
fi
if [[ -n "${_new_profile[LOCAL_PORT]}" ]]; then
printf " ${BOLD}Local:${RESET} %s:%s\n" "${_new_profile[LOCAL_BIND_ADDR]}" "${_new_profile[LOCAL_PORT]}" >/dev/tty
fi
if [[ -n "${_new_profile[REMOTE_HOST]}" ]]; then
printf " ${BOLD}Remote:${RESET} %s:%s\n" "${_new_profile[REMOTE_HOST]}" "${_new_profile[REMOTE_PORT]}" >/dev/tty
fi
if [[ -n "${_new_profile[JUMP_HOSTS]}" ]]; then
printf " ${BOLD}Jump Hosts:${RESET} %s\n" "${_new_profile[JUMP_HOSTS]}" >/dev/tty
fi
if [[ "${_new_profile[OBFS_MODE]:-none}" != "none" ]]; then
printf " ${BOLD}Obfuscation:${RESET} %s (port %s)\n" "${_new_profile[OBFS_MODE]}" "${_new_profile[OBFS_PORT]}" >/dev/tty
fi
if [[ -n "${_new_profile[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_new_profile[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then
printf " ${BOLD}Inbound TLS:${RESET} port %s (PSK protected)\n" "${_new_profile[OBFS_LOCAL_PORT]}" >/dev/tty
fi
printf " ${BOLD}AutoSSH:${RESET} %s\n" "${_new_profile[AUTOSSH_ENABLED]}" >/dev/tty
printf " ${BOLD}Autostart:${RESET} %s\n" "${_new_profile[AUTOSTART]}" >/dev/tty
if [[ -n "$desc" ]]; then
printf " ${BOLD}Description:${RESET} %s\n" "$desc" >/dev/tty
fi
printf "\n" >/dev/tty
if ! _wiz_yn "Save this profile?" "y"; then
if _wiz_quit; then log_info "Wizard cancelled"; return 0; fi
if _wiz_back; then (( --_step )); continue; fi
log_info "Profile creation cancelled"
return 0
fi
local _wz_pfile
_wz_pfile=$(_profile_path "$name")
if ! _save_profile_data "$_wz_pfile" _new_profile; then
log_error "Failed to save profile '${name}'"
return 1
fi
log_success "Profile '${name}' created successfully"
printf "\n${DIM} You can start/stop tunnels from the main menu.${RESET}\n" >/dev/tty
if _read_yn "Start tunnel now?"; then
start_tunnel "$name" || true
# Show "What's Next?" guide based on tunnel type
local _wn_bind="${_new_profile[LOCAL_BIND_ADDR]}"
local _wn_lport="${_new_profile[LOCAL_PORT]}"
local _wn_rhost="${_new_profile[REMOTE_HOST]}"
local _wn_rport="${_new_profile[REMOTE_PORT]}"
local _wn_shost="${_new_profile[SSH_HOST]}"
printf "\n${BOLD_CYAN}── What's Next? ──${RESET}\n\n" >/dev/tty
case "${_new_profile[TUNNEL_TYPE]}" in
socks5)
printf " ${BOLD}Configure your apps to use the proxy:${RESET}\n\n" >/dev/tty
printf " ${DIM}Browser (Firefox):${RESET}\n" >/dev/tty
printf " Settings → Proxy → Manual config\n" >/dev/tty
printf " SOCKS Host: ${BOLD}%s${RESET} Port: ${BOLD}%s${RESET}\n" "$_wn_bind" "$_wn_lport" >/dev/tty
printf " Select SOCKS v5, enable Proxy DNS\n\n" >/dev/tty
printf " ${DIM}Test from command line:${RESET}\n" >/dev/tty
printf " curl --socks5-hostname %s:%s https://ifconfig.me\n\n" "$_wn_bind" "$_wn_lport" >/dev/tty
if [[ "$_wn_bind" == "0.0.0.0" ]]; then
printf " ${DIM}From other devices on your LAN, use${RESET}\n" >/dev/tty
printf " ${DIM}this machine's IP instead of 0.0.0.0${RESET}\n\n" >/dev/tty
fi ;;
local)
printf " ${BOLD}Access the remote service locally:${RESET}\n\n" >/dev/tty
printf " ${DIM}Open in browser or connect to:${RESET}\n" >/dev/tty
printf " http://%s:%s\n\n" "$_wn_bind" "$_wn_lport" >/dev/tty
printf " ${DIM}This reaches %s:%s on the remote side${RESET}\n" "$_wn_rhost" "$_wn_rport" >/dev/tty
printf " ${DIM}through the SSH tunnel.${RESET}\n\n" >/dev/tty
if [[ "$_wn_bind" == "0.0.0.0" ]]; then
printf " ${DIM}From other devices on your LAN, use${RESET}\n" >/dev/tty
printf " ${DIM}this machine's IP instead of 0.0.0.0${RESET}\n\n" >/dev/tty
fi ;;
remote)
printf " ${BOLD}Before using this tunnel:${RESET}\n\n" >/dev/tty
printf " ${DIM}1. Make sure a service is running on${RESET}\n" >/dev/tty
printf " ${BOLD}%s:%s${RESET} (this machine)\n\n" "$_wn_rhost" "$_wn_lport" >/dev/tty
printf " ${DIM}2. The service is now reachable at:${RESET}\n" >/dev/tty
printf " ${BOLD}%s:%s${RESET} (on the SSH server)\n\n" "$_wn_bind" "$_wn_rport" >/dev/tty
printf " ${DIM}Test from the SSH server:${RESET}\n" >/dev/tty
printf " curl http://localhost:%s\n\n" "$_wn_rport" >/dev/tty ;;
jump)
printf " ${DIM}Same as the tunnel type you chose${RESET}\n" >/dev/tty
printf " ${DIM}(SOCKS5 or Local Forward) but routed${RESET}\n" >/dev/tty
printf " ${DIM}through the jump host(s).${RESET}\n\n" >/dev/tty ;;
esac
fi
return 0 ;;
esac
done
}
setup_wizard() {
wizard_create_profile || true
}
# ============================================================================
# INTERACTIVE MENUS (Phase 2)
# ============================================================================
# Clear screen and show banner for menus
_menu_header() {
local title="${1:-}"
clear >/dev/tty 2>/dev/null || true
show_banner >/dev/tty
if [[ -n "$title" ]]; then
printf " ${BOLD_CYAN}%s${RESET}\n" "$title" >/dev/tty
local _mh_sep=""
for (( _mhi=0; _mhi<60; _mhi++ )); do _mh_sep+="─"; done
printf " %s\n\n" "$_mh_sep" >/dev/tty
fi
}
# ── Settings menu ──
show_settings_menu() {
while true; do
_menu_header "Settings"
printf " ${BOLD}Current Defaults:${RESET}\n\n" >/dev/tty
printf " ${CYAN}1${RESET}) SSH User : ${BOLD}%s${RESET}\n" "$(config_get SSH_DEFAULT_USER root)" >/dev/tty
printf " ${CYAN}2${RESET}) SSH Port : ${BOLD}%s${RESET}\n" "$(config_get SSH_DEFAULT_PORT 22)" >/dev/tty
printf " ${CYAN}3${RESET}) SSH Key : ${BOLD}%s${RESET}\n" "$(config_get SSH_DEFAULT_KEY '(none)')" >/dev/tty
printf " ${CYAN}4${RESET}) Connect Timeout : ${BOLD}%s${RESET}s\n" "$(config_get SSH_CONNECT_TIMEOUT 10)" >/dev/tty
printf " ${CYAN}5${RESET}) AutoSSH Enabled : ${BOLD}%s${RESET}\n" "$(config_get AUTOSSH_ENABLED true)" >/dev/tty
printf " ${CYAN}6${RESET}) AutoSSH Poll : ${BOLD}%s${RESET}s\n" "$(config_get AUTOSSH_POLL 30)" >/dev/tty
printf " ${CYAN}7${RESET}) ControlMaster : ${BOLD}%s${RESET}\n" "$(config_get CONTROLMASTER_ENABLED false)" >/dev/tty
printf " ${CYAN}8${RESET}) Log Level : ${BOLD}%s${RESET}\n" "$(config_get LOG_LEVEL info)" >/dev/tty
printf " ${CYAN}9${RESET}) Dashboard Refresh : ${BOLD}%s${RESET}s\n" "$(config_get DASHBOARD_REFRESH 5)" >/dev/tty
printf "\n ${YELLOW}0${RESET}) Back\n\n" >/dev/tty
local _sm_choice
printf " ${BOLD}Select [0-9]${RESET}: " >/dev/tty
read -rsn1 _sm_choice </dev/tty || true
_drain_esc _sm_choice
printf "\n" >/dev/tty
case "$_sm_choice" in
1) local val; _read_tty " SSH default user" val "$(config_get SSH_DEFAULT_USER root)" || true
if [[ -n "$val" ]]; then
config_set "SSH_DEFAULT_USER" "$val"; save_settings || true
else
log_error "User cannot be empty"; _press_any_key
fi ;;
2) local val; _read_tty " SSH default port" val "$(config_get SSH_DEFAULT_PORT 22)" || true
if validate_port "$val"; then
config_set "SSH_DEFAULT_PORT" "$val"; save_settings || true
else
log_error "Invalid port"; _press_any_key
fi ;;
3) local val; _read_tty " SSH default key path" val "$(config_get SSH_DEFAULT_KEY)" || true
config_set "SSH_DEFAULT_KEY" "$val"; save_settings || true ;;
4) local val; _read_tty " Connect timeout (seconds)" val "$(config_get SSH_CONNECT_TIMEOUT 10)" || true
if [[ "$val" =~ ^[0-9]+$ ]] && (( val >= 1 )); then
config_set "SSH_CONNECT_TIMEOUT" "$val"; save_settings || true
else
log_error "Must be a positive number"; _press_any_key
fi ;;
5) local cur; cur=$(config_get AUTOSSH_ENABLED true)
if [[ "$cur" == "true" ]]; then
config_set "AUTOSSH_ENABLED" "false"
log_success "AutoSSH disabled"
else
config_set "AUTOSSH_ENABLED" "true"
log_success "AutoSSH enabled"
fi
save_settings || true ;;
6) local val; _read_tty " AutoSSH poll interval (seconds)" val "$(config_get AUTOSSH_POLL 30)" || true
if [[ "$val" =~ ^[0-9]+$ ]] && (( val >= 1 )); then
config_set "AUTOSSH_POLL" "$val"; save_settings || true
else
log_error "Must be a positive number"; _press_any_key
fi ;;
7) local cur; cur=$(config_get CONTROLMASTER_ENABLED false)
if [[ "$cur" == "true" ]]; then
config_set "CONTROLMASTER_ENABLED" "false"
log_success "ControlMaster disabled"
else
config_set "CONTROLMASTER_ENABLED" "true"
log_success "ControlMaster enabled"
fi
save_settings || true ;;
8) local _ll_opts=("debug" "info" "warn" "error")
local _ll_idx
if _ll_idx=$(_select_option " Log level" "${_ll_opts[@]}"); then
config_set "LOG_LEVEL" "${_ll_opts[$_ll_idx]}"
save_settings || true
fi ;;
9) local val; _read_tty " Dashboard refresh rate (seconds)" val "$(config_get DASHBOARD_REFRESH 5)" || true
if [[ "$val" =~ ^[0-9]+$ ]] && (( val >= 1 )); then
config_set "DASHBOARD_REFRESH" "$val"; save_settings || true
else
log_error "Must be a positive number"; _press_any_key
fi ;;
0|q) return 0 ;;
*) true ;;
esac
done
}
# ── Edit profile sub-menu ──
_edit_profile_menu() {
local _ep_name="$1"
local -A _eprof
load_profile "$_ep_name" _eprof || { log_error "Cannot load profile"; return 1; }
# Snapshot original values for dirty-check on discard
local -A _eprof_orig
local _ep_fld
for _ep_fld in "${!_eprof[@]}"; do _eprof_orig[$_ep_fld]="${_eprof[$_ep_fld]}"; done
while true; do
_menu_header "Edit Profile: ${_ep_name}"
printf " ${CYAN}1${RESET}) SSH Host : ${BOLD}%s${RESET}\n" "${_eprof[SSH_HOST]:-}" >/dev/tty
printf " ${CYAN}2${RESET}) SSH Port : ${BOLD}%s${RESET}\n" "${_eprof[SSH_PORT]:-22}" >/dev/tty
printf " ${CYAN}3${RESET}) SSH User : ${BOLD}%s${RESET}\n" "${_eprof[SSH_USER]:-}" >/dev/tty
printf " ${CYAN}4${RESET}) Identity Key : ${BOLD}%s${RESET}\n" "${_eprof[IDENTITY_KEY]:-none}" >/dev/tty
printf " ${CYAN}5${RESET}) Local Bind : ${BOLD}%s:%s${RESET}\n" "${_eprof[LOCAL_BIND_ADDR]:-}" "${_eprof[LOCAL_PORT]:-}" >/dev/tty
printf " ${CYAN}6${RESET}) Remote Target : ${BOLD}%s:%s${RESET}\n" "${_eprof[REMOTE_HOST]:-}" "${_eprof[REMOTE_PORT]:-}" >/dev/tty
printf " ${CYAN}7${RESET}) AutoSSH : ${BOLD}%s${RESET}\n" "${_eprof[AUTOSSH_ENABLED]:-true}" >/dev/tty
printf " ${CYAN}8${RESET}) Autostart : ${BOLD}%s${RESET}\n" "${_eprof[AUTOSTART]:-false}" >/dev/tty
printf " ${CYAN}9${RESET}) Description : ${BOLD}%s${RESET}\n" "${_eprof[DESCRIPTION]:-}" >/dev/tty
printf "\n" >/dev/tty
printf " ${CYAN}a${RESET}) Kill Switch : ${BOLD}%s${RESET}\n" "${_eprof[KILL_SWITCH]:-false}" >/dev/tty
printf " ${CYAN}b${RESET}) DNS Leak Prot. : ${BOLD}%s${RESET}\n" "${_eprof[DNS_LEAK_PROTECTION]:-false}" >/dev/tty
printf " ${CYAN}c${RESET}) TLS Obfuscation : ${BOLD}%s${RESET}\n" "${_eprof[OBFS_MODE]:-none}" >/dev/tty
printf " ${CYAN}d${RESET}) TLS Port : ${BOLD}%s${RESET}\n" "${_eprof[OBFS_PORT]:-443}" >/dev/tty
printf " ${CYAN}e${RESET}) Jump Hosts : ${BOLD}%s${RESET}\n" "${_eprof[JUMP_HOSTS]:-none}" >/dev/tty
printf "\n ${GREEN}s${RESET}) Save changes\n" >/dev/tty
printf " ${YELLOW}0${RESET}) Back (discard)\n\n" >/dev/tty
local _ep_choice
printf " ${BOLD}Select${RESET}: " >/dev/tty
read -rsn1 _ep_choice </dev/tty || true
_drain_esc _ep_choice
printf "\n" >/dev/tty
case "$_ep_choice" in
1) local val; _read_tty " SSH host" val "${_eprof[SSH_HOST]:-}" || true
if validate_hostname "$val" || validate_ip "$val"; then
_eprof[SSH_HOST]="$val"
else
log_error "Invalid hostname or IP"; _press_any_key
fi ;;
2) local val; _read_tty " SSH port" val "${_eprof[SSH_PORT]:-22}" || true
if validate_port "$val"; then
_eprof[SSH_PORT]="$val"
else
log_error "Invalid port"; _press_any_key
fi ;;
3) local val; _read_tty " SSH user" val "${_eprof[SSH_USER]:-}" || true
if [[ -n "$val" ]] && [[ "$val" =~ ^[a-zA-Z0-9._@-]+$ ]]; then
_eprof[SSH_USER]="$val"
elif [[ -z "$val" ]]; then
log_warn "SSH user cleared — will use default ($(config_get SSH_DEFAULT_USER root))"
_eprof[SSH_USER]=""
else
log_error "Invalid SSH user"; _press_any_key
fi ;;
4) local val; _read_tty " Identity key path" val "${_eprof[IDENTITY_KEY]:-}" || true
_eprof[IDENTITY_KEY]="$val" ;;
5) local bval pval
_read_tty " Bind address" bval "${_eprof[LOCAL_BIND_ADDR]:-127.0.0.1}" || true
_read_tty " Local port" pval "${_eprof[LOCAL_PORT]:-}" || true
if ! { validate_ip "$bval" || validate_ip6 "$bval" || [[ "$bval" == "localhost" ]] || [[ "$bval" == "*" ]]; }; then
log_error "Invalid bind address — changes discarded"; _press_any_key
elif ! validate_port "$pval"; then
log_error "Invalid port — changes discarded"; _press_any_key
else
_eprof[LOCAL_BIND_ADDR]="$bval"
_eprof[LOCAL_PORT]="$pval"
fi ;;
6) local hval pval
_read_tty " Remote host" hval "${_eprof[REMOTE_HOST]:-}" || true
_read_tty " Remote port" pval "${_eprof[REMOTE_PORT]:-}" || true
if [[ -z "$pval" ]] && [[ "${_eprof[TUNNEL_TYPE]:-}" == "socks5" ]]; then
# SOCKS5 doesn't use remote host/port — allow clearing
_eprof[REMOTE_HOST]=""
_eprof[REMOTE_PORT]=""
elif [[ -n "$pval" ]] && validate_port "$pval"; then
_eprof[REMOTE_HOST]="$hval"
_eprof[REMOTE_PORT]="$pval"
else
log_error "Invalid port — changes discarded"; _press_any_key
fi ;;
7) if [[ "${_eprof[AUTOSSH_ENABLED]:-true}" == "true" ]]; then
_eprof[AUTOSSH_ENABLED]="false"
else
_eprof[AUTOSSH_ENABLED]="true"
fi ;;
8) if [[ "${_eprof[AUTOSTART]:-false}" == "true" ]]; then
_eprof[AUTOSTART]="false"
else
_eprof[AUTOSTART]="true"
fi ;;
9) local val; _read_tty " Description" val "${_eprof[DESCRIPTION]:-}" || true
_eprof[DESCRIPTION]="$val" ;;
a|A) if [[ "${_eprof[KILL_SWITCH]:-false}" == "true" ]]; then
_eprof[KILL_SWITCH]="false"
log_info "Kill switch disabled"
else
_eprof[KILL_SWITCH]="true"
log_warn "Kill switch enabled — blocks traffic if tunnel drops (requires root)"
fi ;;
b|B) if [[ "${_eprof[DNS_LEAK_PROTECTION]:-false}" == "true" ]]; then
_eprof[DNS_LEAK_PROTECTION]="false"
log_info "DNS leak protection disabled"
else
_eprof[DNS_LEAK_PROTECTION]="true"
log_warn "DNS leak protection enabled — rewrites resolv.conf (requires root)"
fi ;;
c|C) if [[ "${_eprof[OBFS_MODE]:-none}" == "none" ]]; then
_eprof[OBFS_MODE]="stunnel"
log_info "TLS obfuscation enabled (stunnel)"
else
_eprof[OBFS_MODE]="none"
log_info "TLS obfuscation disabled"
fi ;;
d|D) local val; _read_tty " TLS obfuscation port" val "${_eprof[OBFS_PORT]:-443}" || true
if validate_port "$val"; then
_eprof[OBFS_PORT]="$val"
else
log_error "Invalid port"; _press_any_key
fi ;;
e|E) local val; _read_tty " Jump hosts (user@host:port or blank)" val "${_eprof[JUMP_HOSTS]:-}" || true
_eprof[JUMP_HOSTS]="$val" ;;
s|S)
if save_profile "$_ep_name" _eprof; then
log_success "Profile '${_ep_name}' saved"
else
log_error "Failed to save profile '${_ep_name}'"
fi
_press_any_key
return 0 ;;
0|q)
# Check if any field was modified
local _ep_dirty=false _ep_ck
for _ep_ck in "${!_eprof[@]}"; do
if [[ "${_eprof[$_ep_ck]}" != "${_eprof_orig[$_ep_ck]:-}" ]]; then
_ep_dirty=true; break
fi
done
if [[ "$_ep_dirty" == true ]]; then
if confirm_action "Discard unsaved changes?"; then
return 0
fi
else
return 0
fi ;;
*) true ;;
esac
done
}
# ── Profile management menu ──
show_profiles_menu() {
while true; do
_menu_header "Profile Management"
local _pm_profiles
_pm_profiles=$(list_profiles)
local _pm_names=()
if [[ -z "$_pm_profiles" ]]; then
printf " ${DIM}No profiles configured.${RESET}\n\n" >/dev/tty
else
printf " ${BOLD}%-4s %-18s %-8s %-12s %-22s${RESET}\n" \
"#" "NAME" "TYPE" "STATUS" "LOCAL" >/dev/tty
local _pm_sep=""
for (( _pmi=0; _pmi<66; _pmi++ )); do _pm_sep+="─"; done
printf " %s\n" "$_pm_sep" >/dev/tty
local _pm_idx=0
while IFS= read -r _pm_name; do
[[ -z "$_pm_name" ]] && continue
(( ++_pm_idx ))
_pm_names+=("$_pm_name")
local _pm_type _pm_status _pm_local
_pm_type=$(get_profile_field "$_pm_name" "TUNNEL_TYPE" 2>/dev/null) || true
_pm_local="$(get_profile_field "$_pm_name" "LOCAL_BIND_ADDR" 2>/dev/null || true):$(get_profile_field "$_pm_name" "LOCAL_PORT" 2>/dev/null || true)"
if is_tunnel_running "$_pm_name"; then
_pm_status="${GREEN}● running ${RESET}"
else
_pm_status="${DIM}■ stopped ${RESET}"
fi
printf " ${CYAN}%-4s${RESET} %-18s %-8s %b%-22s\n" \
"${_pm_idx}" "$_pm_name" "${_pm_type:-?}" "$_pm_status" "$_pm_local" >/dev/tty
done <<< "$_pm_profiles"
fi
printf "\n" >/dev/tty
printf " ${CYAN}c${RESET}) Create new profile\n" >/dev/tty
printf " ${CYAN}d${RESET}) Delete a profile\n" >/dev/tty
printf " ${CYAN}e${RESET}) Edit a profile\n" >/dev/tty
printf " ${YELLOW}0${RESET}) Back\n\n" >/dev/tty
local _pm_choice
printf " ${BOLD}Select${RESET}: " >/dev/tty
read -rsn1 _pm_choice </dev/tty || true
_drain_esc _pm_choice
printf "\n" >/dev/tty
case "$_pm_choice" in
c|C) wizard_create_profile || true; _press_any_key ;;
d|D)
local _pm_dinput _pm_dname
_read_tty " Profile # or name to delete" _pm_dinput "" || true
if [[ "$_pm_dinput" =~ ^[0-9]+$ ]] && (( _pm_dinput >= 1 && _pm_dinput <= ${#_pm_names[@]} )); then
_pm_dname="${_pm_names[$((_pm_dinput-1))]}"
else
_pm_dname="$_pm_dinput"
fi
if [[ -n "$_pm_dname" ]] && validate_profile_name "$_pm_dname"; then
if confirm_action "Delete profile '${_pm_dname}'?"; then
delete_profile "$_pm_dname" || true
fi
fi
_press_any_key ;;
e|E)
local _pm_einput _pm_ename
_read_tty " Profile # or name to edit" _pm_einput "" || true
if [[ "$_pm_einput" =~ ^[0-9]+$ ]] && (( _pm_einput >= 1 && _pm_einput <= ${#_pm_names[@]} )); then
_pm_ename="${_pm_names[$((_pm_einput-1))]}"
else
_pm_ename="$_pm_einput"
fi
if [[ -n "$_pm_ename" ]] && validate_profile_name "$_pm_ename" && [[ -f "$(_profile_path "$_pm_ename")" ]]; then
_edit_profile_menu "$_pm_ename" || true
else
log_error "Profile not found: ${_pm_ename}"
_press_any_key
fi ;;
0|q) return 0 ;;
*) true ;;
esac
done
}
# ── About screen ──
show_about() {
_menu_header ""
local _ab_os _ab_w=58
_ab_os="$(uname -s 2>/dev/null || echo unknown) $(uname -m 2>/dev/null || echo unknown)"
local _ab_max_os=$(( _ab_w - 2 - 5 - 11 ))
if (( ${#_ab_os} > _ab_max_os )); then _ab_os="${_ab_os:0:_ab_max_os}"; fi
local _ab_border="" _abi
for (( _abi=0; _abi < _ab_w - 2; _abi++ )); do _ab_border+="═"; done
printf "\n ${BOLD_CYAN}╔%s╗${RESET}\n" "$_ab_border" >/dev/tty
printf " ${BOLD_CYAN}║%-*s║${RESET}\n" "$((_ab_w - 2))" "" >/dev/tty
printf " ${BOLD_CYAN}${RESET} ${BOLD_WHITE}TunnelForge${RESET} — SSH Tunnel Manager%*s${BOLD_CYAN}${RESET}\n" \
"$((_ab_w - 38))" "" >/dev/tty
printf " ${BOLD_CYAN}║%-*s║${RESET}\n" "$((_ab_w - 2))" "" >/dev/tty
printf " ${BOLD_CYAN}${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}${RESET}\n" "$((_ab_w - 5))" "Version : ${VERSION}" >/dev/tty
printf " ${BOLD_CYAN}${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}${RESET}\n" "$((_ab_w - 5))" "Author : SamNet Technologies, LLC" >/dev/tty
printf " ${BOLD_CYAN}${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}${RESET}\n" "$((_ab_w - 5))" "License : GPL v3.0" >/dev/tty
printf " ${BOLD_CYAN}${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}${RESET}\n" "$((_ab_w - 5))" "Platform : ${_ab_os}" >/dev/tty
printf " ${BOLD_CYAN}${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}${RESET}\n" "$((_ab_w - 5))" "Repo : git.samnet.dev/SamNet-dev/tunnelforge" >/dev/tty
printf " ${BOLD_CYAN}║%-*s║${RESET}\n" "$((_ab_w - 2))" "" >/dev/tty
printf " ${BOLD_CYAN}${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}${RESET}\n" "$((_ab_w - 5))" "A single-file SSH tunnel manager with TUI menu," >/dev/tty
printf " ${BOLD_CYAN}${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}${RESET}\n" "$((_ab_w - 5))" "live dashboard, DNS leak protection, kill switch," >/dev/tty
printf " ${BOLD_CYAN}${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}${RESET}\n" "$((_ab_w - 5))" "server hardening, and Telegram notifications." >/dev/tty
printf " ${BOLD_CYAN}║%-*s║${RESET}\n" "$((_ab_w - 2))" "" >/dev/tty
printf " ${BOLD_CYAN}${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}${RESET}\n" "$((_ab_w - 5))" "This program is free software under the GNU GPL" >/dev/tty
printf " ${BOLD_CYAN}${RESET} ${DIM}%-*s${RESET}${BOLD_CYAN}${RESET}\n" "$((_ab_w - 5))" "v3. See LICENSE file or gnu.org/licenses/gpl-3.0" >/dev/tty
printf " ${BOLD_CYAN}║%-*s║${RESET}\n" "$((_ab_w - 2))" "" >/dev/tty
printf " ${BOLD_CYAN}╚%s╝${RESET}\n\n" "$_ab_border" >/dev/tty
_press_any_key
}
# ── Learn: SSH tunnel explanations ──
show_learn_menu() {
while true; do
_menu_header "Learn: SSH Tunnels"
printf " ${BOLD}── SSH Fundamentals ──${RESET}\n" >/dev/tty
printf " ${CYAN}1${RESET}) What is an SSH Tunnel?\n" >/dev/tty
printf " ${CYAN}2${RESET}) SOCKS5 Dynamic Proxy (-D)\n" >/dev/tty
printf " ${CYAN}3${RESET}) Local Port Forwarding (-L)\n" >/dev/tty
printf " ${CYAN}4${RESET}) Remote/Reverse Forwarding (-R)\n" >/dev/tty
printf " ${CYAN}5${RESET}) Jump Hosts & Multi-hop (-J)\n" >/dev/tty
printf " ${CYAN}6${RESET}) ControlMaster Multiplexing\n" >/dev/tty
printf " ${CYAN}7${RESET}) AutoSSH & Reconnection\n" >/dev/tty
printf "\n ${BOLD}── TLS Obfuscation ──${RESET}\n" >/dev/tty
printf " ${CYAN}8${RESET}) What is TLS Obfuscation?\n" >/dev/tty
printf " ${CYAN}9${RESET}) PSK Authentication\n" >/dev/tty
printf "\n ${BOLD}── Clients ──${RESET}\n" >/dev/tty
printf " ${CYAN}m${RESET}) Mobile Client Connection\n" >/dev/tty
printf "\n ${YELLOW}0${RESET}) Back\n\n" >/dev/tty
local _lm_choice
printf " ${BOLD}Select${RESET}: " >/dev/tty
read -rsn1 _lm_choice </dev/tty || true
_drain_esc _lm_choice
printf "\n" >/dev/tty
case "$_lm_choice" in
1) _learn_what_is_tunnel || true ;;
2) _learn_socks5 || true ;;
3) _learn_local_forward || true ;;
4) _learn_remote_forward || true ;;
5) _learn_jump_host || true ;;
6) _learn_controlmaster || true ;;
7) _learn_autossh || true ;;
8) _learn_tls_obfuscation || true ;;
9) _learn_psk_auth || true ;;
m|M) _learn_mobile_client || true ;;
0|q) return 0 ;;
*) true ;;
esac
done
}
_learn_what_is_tunnel() {
_menu_header "What is an SSH Tunnel?"
cat >/dev/tty <<'EOF'
An SSH tunnel creates an encrypted channel between your local
machine and a remote server. Network traffic is forwarded through
this encrypted tunnel, protecting it from eavesdropping.
┌──────────────────────────────────────────────────────────────┐
│ │
│ ┌────────┐ Encrypted SSH Tunnel ┌────────────┐ │
│ │ Your │ ══════════════════════════ │ Remote │ │
│ │Machine │ (port 22) │ Server │ │
│ └────────┘ └────────────┘ │
│ │
│ All traffic inside the tunnel is encrypted and secure. │
│ │
└──────────────────────────────────────────────────────────────┘
Common use cases:
• Bypass firewalls and NAT
• Secure access to remote services
• Create encrypted SOCKS proxies
• Expose local services to the internet
EOF
_press_any_key
}
_learn_socks5() {
_menu_header "SOCKS5 Dynamic Proxy (-D)"
cat >/dev/tty <<'EOF'
A SOCKS5 proxy creates a dynamic forwarding tunnel. Any application
configured to use the SOCKS proxy will route traffic through the
SSH server.
┌──────────┐ ┌─────────────┐ ┌───────────┐
│ Browser │────>│ SSH Server │────>│ Website │
│ :1080 │ │ (proxy) │ │ │
└──────────┘ └─────────────┘ └───────────┘
Command: ssh -D 1080 user@server
Configure your browser/app to use:
SOCKS5 proxy: 127.0.0.1:1080
Benefits:
• Route ALL TCP traffic through the tunnel
• Appears to browse from the server's IP
• Supports DNS resolution through proxy
• Works with any SOCKS5-aware application
EOF
_press_any_key
}
_learn_local_forward() {
_menu_header "Local Port Forwarding (-L)"
cat >/dev/tty <<'EOF'
Local forwarding maps a port on your local machine to a port on
a remote machine, through the SSH server.
┌──────────┐ ┌─────────────┐ ┌───────────┐
│ Local │────>│ SSH Server │────>│ Target │
│ :8080 │ │ (relay) │ │ :80 │
└──────────┘ └─────────────┘ └───────────┘
Command: ssh -L 8080:target:80 user@server
Now http://localhost:8080 → target:80 via SSH server
Use cases:
• Access a database behind a firewall
• Reach internal web apps securely
• Connect to services on a private network
EOF
_press_any_key
}
_learn_remote_forward() {
_menu_header "Remote/Reverse Forwarding (-R)"
cat >/dev/tty <<'EOF'
Remote forwarding exposes a local service on the remote SSH server.
Users connecting to the server's port reach your local machine.
┌──────────┐ ┌─────────────┐ ┌───────────┐
│ Local │<────│ SSH Server │<────│ Users │
│ :3000 │ │ :9090 │ │ │
└──────────┘ └─────────────┘ └───────────┘
Command: ssh -R 9090:localhost:3000 user@server
Now server:9090 → your localhost:3000
Use cases:
• Expose local dev server to the internet
• Webhook development & testing
• Remote access to services behind NAT
• Demo local apps to clients
EOF
_press_any_key
}
_learn_jump_host() {
_menu_header "Jump Hosts & Multi-hop (-J)"
cat >/dev/tty <<'EOF'
Jump hosts let you reach a target through one or more intermediate
SSH servers. Useful when the target is not directly accessible.
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ Local │───>│ Jump 1 │───>│ Jump 2 │───>│ Target │
│ │ │ │ │ │ │ │
└────────┘ └────────┘ └────────┘ └────────┘
Command: ssh -J jump1,jump2 user@target
Combined with tunnel:
ssh -J jump1,jump2 -D 1080 user@target
Use cases:
• Reach servers in isolated networks
• Multi-tier security environments
• Bastion/jump box architectures
• Chain through multiple datacenters
EOF
_press_any_key
}
_learn_controlmaster() {
_menu_header "ControlMaster Multiplexing"
cat >/dev/tty <<'EOF'
ControlMaster allows multiple SSH sessions to share a single
network connection. Subsequent connections reuse the existing
TCP connection and skip authentication.
First connection (creates socket):
┌────────┐ ═══TCP═══ ┌────────┐
│ Local │──socket──>│ Server │
└────────┘ └────────┘
Subsequent connections (reuse socket):
┌────────┐ ──socket──>
│ Local │ ──socket──> (instant, no auth)
└────────┘ ──socket──>
Config:
ControlMaster auto
ControlPath ~/.ssh/sockets/%r@%h:%p
ControlPersist 600
Benefits:
• Instant connection for subsequent sessions
• Reduced server authentication load
• Shared connection for tunnels + shell
EOF
_press_any_key
}
_learn_autossh() {
_menu_header "AutoSSH & Reconnection"
cat >/dev/tty <<'EOF'
AutoSSH monitors an SSH connection and automatically restarts
it if the connection drops. Essential for persistent tunnels.
┌──────────┐ ┌──────────┐
│ AutoSSH │──watch──>│ SSH │
(monitor)│──restart>│ (tunnel)
└──────────┘ └──────────┘
How it works:
1. AutoSSH launches SSH with your tunnel config
2. It monitors the connection health
3. If SSH dies, AutoSSH restarts it automatically
4. Exponential backoff prevents rapid reconnection loops
TunnelForge config:
AUTOSSH_POLL = 30 (seconds between health checks)
AUTOSSH_GATETIME = 30 (min uptime before restart)
AUTOSSH_MONITOR = 0 (use ServerAlive instead)
Tip: Combined with systemd, AutoSSH gives you a tunnel
that survives reboots AND network outages.
EOF
_press_any_key
}
_learn_tls_obfuscation() {
_menu_header "TLS Obfuscation (stunnel)"
printf >/dev/tty '%s\n' \
'' \
' THE PROBLEM:' \
' Some countries (Iran, China, Russia) use Deep Packet' \
' Inspection (DPI) to detect and block SSH connections.' \
' Even though SSH is encrypted, DPI can identify the' \
' SSH protocol by its handshake pattern.' \
'' \
' THE SOLUTION — TLS WRAPPING:' \
' Wrap SSH inside a TLS (HTTPS) connection using stunnel.' \
' DPI sees standard HTTPS traffic — the same protocol' \
' used by every website. It cannot tell SSH is inside.' \
'' \
' WITHOUT OBFUSCATION:' \
' ┌──────┐ SSH:22 ┌──────┐' \
' │Client├─────────>│Server│ DPI: "This is SSH → BLOCK"' \
' └──────┘ └──────┘' \
'' \
' WITH TLS OBFUSCATION:' \
' ┌──────┐ TLS:443 ┌────────┐' \
' │Client├────────>│stunnel │ DPI: "This is HTTPS → ALLOW"' \
' └──────┘ (HTTPS) │→SSH :22│' \
' └────────┘' \
'' \
' HOW STUNNEL WORKS:' \
' Server side:' \
' stunnel listens on port 443 (TLS)' \
' → unwraps TLS → forwards to SSH on port 22' \
'' \
' Client side (TunnelForge handles this):' \
' SSH uses ProxyCommand with openssl to connect' \
' through the TLS tunnel instead of directly' \
'' \
' WHY PORT 443?' \
' Port 443 is the standard HTTPS port. Every website' \
' uses it. Blocking port 443 would break the internet,' \
' so censors cannot block it.' \
'' \
' SETUP IN TUNNELFORGE:' \
' In the wizard, at "Connection Mode", pick:' \
' 2) TLS Encrypted (stunnel)' \
' TunnelForge auto-installs stunnel on your server.' \
'' \
' TWO TYPES OF TLS IN TUNNELFORGE:' \
' Outbound TLS — wraps SSH going TO a remote server' \
' Inbound TLS — wraps SOCKS5 port for clients coming IN' \
' Both can be active at once for full-chain encryption.' \
''
_press_any_key
}
_learn_psk_auth() {
_menu_header "PSK Authentication"
printf >/dev/tty '%s\n' \
'' \
' WHAT IS PSK?' \
' Pre-Shared Key — a shared secret between server and' \
' client. Both sides know the same key. Only clients' \
' with the correct key can connect.' \
'' \
' WHY USE PSK?' \
' When TunnelForge runs on a VPS and accepts connections' \
' from user PCs, the SOCKS5 port needs protection:' \
' - Without PSK: anyone who finds the port can use it' \
' - With PSK: only authorized users can connect' \
'' \
' HOW IT WORKS:' \
' ┌──────────┐ TLS + PSK ┌──────────────┐' \
' │ User PC ├────────────────>│ VPS stunnel │' \
' │ stunnel │ "I know the │ verifies PSK │' \
' │ (client) │ secret key" │ → SOCKS5 │' \
' └──────────┘ └──────────────┘' \
'' \
' 1. Server stunnel has a PSK secrets file' \
' 2. Client stunnel has the SAME PSK' \
' 3. During TLS handshake, they prove they share' \
' the same key — no certificates needed' \
' 4. If key doesn'"'"'t match → connection refused' \
'' \
' PSK FORMAT:' \
' identity:hexkey' \
' Example: tunnelforge:a1b2c3d4e5f6....' \
'' \
' IN TUNNELFORGE:' \
' PSK is auto-generated when you enable "Inbound TLS+PSK"' \
' in the wizard (step 11). 32-byte random hex key.' \
'' \
' View PSK: tunnelforge client-config <profile>' \
' Share setup: tunnelforge client-script <profile>' \
'' \
' REVOKING ACCESS:' \
' To block a user: change the PSK in the profile,' \
' restart the tunnel, and send the new script only' \
' to authorized users. Old PSK stops working.' \
''
_press_any_key
}
_learn_mobile_client() {
_menu_header "Mobile Client Connection"
cat >/dev/tty <<'EOF'
HOW TO CONNECT FROM A MOBILE PHONE
Your TunnelForge tunnel runs on a server and exposes a SOCKS5
port. To use it from a phone, you need a SOCKS5-capable app.
┌──────────┐ SOCKS5 ┌──────────────┐ SSH ┌──────┐
│ Phone ├─────────────>│ VPS/Server ├─────────>│ Dest │
│ App │ proxy conn │ TunnelForge │ tunnel │ │
└──────────┘ └──────────────┘ └──────┘
── WITHOUT TLS/PSK (bind 0.0.0.0) ──
Make sure your profile uses LOCAL_BIND_ADDR=0.0.0.0 so
the SOCKS5 port accepts external connections.
Android:
• SocksDroid (free)set SOCKS5: <server_ip>:<port>
• Drony — per-app SOCKS5 routing
• Any browser with proxy settings
iOS:
• Shadowrocket — add SOCKS5 server
• Surge / Quantumult — SOCKS5 proxy node
• iOS WiFi Settings — HTTP proxy (limited)
Settings:
Type: SOCKS5
Server: <your_server_ip>
Port: <LOCAL_PORT from profile>
WARNING: Without PSK, anyone who finds the port can use
your tunnel. Enable inbound TLS+PSK for protection.
── WITH TLS+PSK (recommended) ──
When inbound TLS+PSK is enabled, stunnel wraps the SOCKS5
port. Mobile clients need an stunnel-compatible layer:
Android:
1. Install SST (Simple Stunnel Tunnel) or Termux
2. In Termux: pkg install stunnel, then use the config
from: tunnelforge client-config <profile>
3. Point your SOCKS5 app at 127.0.0.1:<OBFS_LOCAL_PORT>
iOS:
1. Shadowrocket supports TLS-over-SOCKS natively
2. Or use iSH terminal + stunnel with client config
Generate client config:
tunnelforge client-config <profile>
tunnelforge client-script <profile>
── QUICK CHECKLIST ──
[ ] Server tunnel is running (tunnelforge status)
[ ] Firewall allows the port (ufw allow <port>)
[ ] Phone and server on same network, or port is public
[ ] SOCKS5 app configured with correct IP:PORT
[ ] If PSK: stunnel running on phone with correct key
EOF
_press_any_key
}
# ── Example Scenarios ──
show_scenarios_menu() {
while true; do
_menu_header "Example Scenarios"
printf " ${BOLD}── Basic SSH Tunnels ──${RESET}\n" >/dev/tty
printf " ${CYAN}1${RESET}) SOCKS5 Proxy — Browse privately\n" >/dev/tty
printf " ${CYAN}2${RESET}) Local Forward — Access remote database\n" >/dev/tty
printf " ${CYAN}3${RESET}) Remote Forward — Share local website\n" >/dev/tty
printf " ${CYAN}4${RESET}) Jump Host — Reach a hidden server\n" >/dev/tty
printf "\n ${BOLD}── TLS Obfuscation (Anti-Censorship) ──${RESET}\n" >/dev/tty
printf " ${CYAN}5${RESET}) Client → VPS (single server, bypass DPI)\n" >/dev/tty
printf " ${CYAN}6${RESET}) Client → VPS → VPS (double TLS, full chain)\n" >/dev/tty
printf " ${CYAN}7${RESET}) Share tunnel with others (client script)\n" >/dev/tty
printf "\n ${YELLOW}0${RESET}) Back\n\n" >/dev/tty
local _sc_choice
printf " ${BOLD}Select${RESET}: " >/dev/tty
read -rsn1 _sc_choice </dev/tty || true
_drain_esc _sc_choice
printf "\n" >/dev/tty
case "$_sc_choice" in
1) _scenario_socks5 || true ;;
2) _scenario_local_forward || true ;;
3) _scenario_remote_forward || true ;;
4) _scenario_jump_host || true ;;
5) _scenario_tls_single_vps || true ;;
6) _scenario_tls_double_vps || true ;;
7) _scenario_tls_share_tunnel || true ;;
0|q) return 0 ;;
*) true ;;
esac
done
}
_scenario_socks5() {
_menu_header "Scenario: Browse Privately via SOCKS5 Proxy"
cat >/dev/tty <<'EOF'
GOAL: Route your browser traffic through a VPS so websites
see the VPS IP instead of your real IP.
WHAT YOU NEED:
• A VPS or remote server with SSH access
• A browser (Firefox, Chrome, etc.)
NETWORK DIAGRAM:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Your PC │──SOCKS5──│ VPS │──────────│ Internet │
│ :1080 │ tunnel │ (proxy) │ │ │
└──────────┘ └──────────┘ └──────────┘
WIZARD SETTINGS:
Tunnel type ......... SOCKS5 Proxy
SSH host ............ Your VPS IP (e.g. 45.33.32.10)
SSH port ............ 22
SSH user ............ root
Bind address ........ 127.0.0.1 (local only)
0.0.0.0 (share with LAN)
SOCKS port .......... 1080
AFTER TUNNEL STARTS — CONFIGURE YOUR BROWSER:
Firefox:
1. Settings → search "proxy" → Manual proxy
2. SOCKS Host: 127.0.0.1 Port: 1080
3. Select "SOCKS v5"
4. Check "Proxy DNS when using SOCKS v5"
5. Click OK
Chrome (command line):
google-chrome --proxy-server="socks5://127.0.0.1:1080"
TEST IT WORKS:
From the command line:
curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me
→ Should show your VPS IP, not your real IP
If you used 0.0.0.0 as bind, other devices on your
LAN can use it too:
curl --socks5-hostname <server-lan-ip>:1080 https://ifconfig.me
EOF
_press_any_key
}
_scenario_local_forward() {
_menu_header "Scenario: Access a Remote Database Locally"
cat >/dev/tty <<'EOF'
GOAL: Access a MySQL database running on your VPS as if it
were on your local machine.
WHAT YOU NEED:
• A VPS with MySQL running on port 3306
• An SSH account on that VPS
NETWORK DIAGRAM:
┌──────────┐ ┌──────────┐
│ Your PC │──Local───│ VPS │
│ :3306 │ Forward │ MySQL │
│ │ │ :3306 │
└──────────┘ └──────────┘
WIZARD SETTINGS:
Tunnel type ......... Local Port Forward
SSH host ............ Your VPS IP (e.g. 45.33.32.10)
SSH port ............ 22
SSH user ............ root
Local bind .......... 127.0.0.1 (local only)
0.0.0.0 (share with LAN)
Local port .......... 3306 (port on YOUR PC)
Remote host ......... 127.0.0.1 (means "on the VPS")
Remote port ......... 3306 (MySQL on VPS)
AFTER TUNNEL STARTS — CONNECT:
MySQL client:
mysql -h 127.0.0.1 -P 3306 -u dbuser -p
Web app (e.g. phpMyAdmin, DBeaver):
Host: 127.0.0.1 Port: 3306
Web service (e.g. a web server on VPS port 8080):
Change remote port to 8080, then open:
http://127.0.0.1:8080
in your browser.
TEST IT WORKS:
If forwarding a web service:
curl http://127.0.0.1:<local-port>
If using 0.0.0.0 as bind, other LAN devices connect:
http://<server-lan-ip>:<local-port>
COMMON VARIATIONS:
• Forward port 5432 for PostgreSQL
• Forward port 6379 for Redis
• Forward port 8080 for a web admin panel
• Remote host can be another IP on the VPS network
(e.g. 10.0.0.5:3306 for a DB on a private subnet)
EOF
_press_any_key
}
_scenario_remote_forward() {
_menu_header "Scenario: Share a Local Website with the World"
cat >/dev/tty <<'EOF'
GOAL: You have a website running on your local machine
(e.g. port 3000) and want to make it accessible
from the internet through your VPS.
WHAT YOU NEED:
• A local service running (e.g. Node.js on port 3000)
• A VPS with SSH access and a public IP
NETWORK DIAGRAM:
┌──────────────┐ ┌──────────────┐
│ Your PC │──Reverse─│ VPS │
│ localhost │ Forward │ public IP │
│ :3000 │ │ :9090 │
└──────────────┘ └──────────────┘
Anyone can access
http://VPS-IP:9090
WIZARD SETTINGS:
Tunnel type ......... Remote/Reverse Forward
SSH host ............ Your VPS IP (e.g. 45.33.32.10)
SSH port ............ 22
SSH user ............ root
Remote bind ......... 127.0.0.1 (VPS localhost only)
0.0.0.0 (public, needs
GatewayPorts yes)
Remote port ......... 9090 (port on VPS)
Local host .......... 127.0.0.1 (your machine)
Local port .......... 3000 (your service)
BEFORE STARTING — MAKE SURE:
1. Your local service is running:
python3 -m http.server 3000
(or node app.js, etc.)
2. If using 0.0.0.0 bind, your VPS sshd_config
needs: GatewayPorts yes
Then restart sshd: systemctl restart sshd
AFTER TUNNEL STARTS — TEST FROM VPS:
ssh root@<vps-ip>
curl http://localhost:9090
→ Should show your local website content
TEST FROM ANYWHERE (if bind is 0.0.0.0):
curl http://<vps-public-ip>:9090
→ Same content, accessible from the internet
COMMON VARIATIONS:
• Share a dev server for client demos
• Receive webhooks from services like GitHub/Stripe
• Remote access to a home service behind NAT
• Expose port 22 to allow SSH into your home PC
EOF
_press_any_key
}
_scenario_jump_host() {
_menu_header "Scenario: Reach a Server Behind a Firewall"
cat >/dev/tty <<'EOF'
GOAL: You need to access a server that is NOT directly
reachable from the internet. You have SSH access
to an intermediate "jump" server that CAN reach it.
WHAT YOU NEED:
• A jump server (bastion) you can SSH into
• A target server the jump server can reach
• SSH credentials for both servers
NETWORK DIAGRAM:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Your PC │────>│ Jump │────>│ Target │
│ │ │ (bastion)│ │ (hidden)
└──────────┘ └──────────┘ └──────────┘
You can't reach Target directly,
but Jump can reach it.
WIZARD SETTINGS (example with SOCKS5 at target):
Tunnel type ......... Jump Host
SSH host ............ Target IP (e.g. 10.0.0.50)
SSH port ............ 22
SSH user ............ admin (user on TARGET)
SSH password ........ **** (for TARGET)
Jump hosts .......... root@bastion.example.com:22
Dest tunnel type .... SOCKS5 Proxy
Bind address ........ 127.0.0.1
SOCKS port .......... 1080
WIZARD SETTINGS (example with Local Forward):
Tunnel type ......... Jump Host
SSH host ............ 10.0.0.50 (target)
SSH user ............ admin
Jump hosts .......... root@45.33.32.10:22
Dest tunnel type .... Local Port Forward
Local bind .......... 0.0.0.0
Local port .......... 8080
Remote host ......... 127.0.0.1 (on the target)
Remote port ......... 80 (web server)
STEP-BY-STEP LOGIC:
1. TunnelForge connects to the JUMP server first
2. Through the jump server, it connects to TARGET
3. Then it sets up your chosen tunnel (SOCKS5 or
Local Forward) at the target
AFTER TUNNEL STARTS:
SOCKS5 mode:
Set browser proxy to 127.0.0.1:1080
curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me
Local Forward mode:
Open http://127.0.0.1:8080 in your browser
→ Shows the web server on the hidden target
MULTIPLE JUMP HOSTS:
You can chain through several servers:
Jump hosts: user1@hop1:22,user2@hop2:22
┌────┐ ┌──────┐ ┌──────┐ ┌────────┐
│ PC │─>│ Hop1 │─>│ Hop2 │─>│ Target │
└────┘ └──────┘ └──────┘ └────────┘
TIPS:
• The SSH host/user/password are for the TARGET
• Jump host credentials go in the jump hosts field
(e.g. root@bastion:22)
• Auth test may fail for the target — that's OK,
the connection goes through the jump host
EOF
_press_any_key
}
_scenario_tls_single_vps() {
_menu_header "Scenario: Bypass Censorship (Single VPS)"
printf >/dev/tty '%s\n' \
'' \
' GOAL: You are in a censored country (Iran, China, etc.)' \
' and your ISP blocks or detects SSH connections.' \
' You want to browse freely using a VPS outside.' \
'' \
' WHAT YOU NEED:' \
' - 1 VPS outside the censored country (e.g. US, Europe)' \
' - SSH access to that VPS' \
'' \
' HOW IT WORKS:' \
' SSH is wrapped in TLS so it looks like normal HTTPS.' \
' Your ISP sees encrypted HTTPS traffic — not SSH.' \
'' \
' NETWORK DIAGRAM:' \
'' \
' Your PC (Iran) VPS (Outside)' \
' ┌──────────┐ TLS:443 ┌─────────────┐' \
' │TunnelForge├──────────>│ stunnel │' \
' │ SOCKS5 │ (HTTPS) │ → SSH :22 │──> Internet' \
' │ :1080 │ └─────────────┘' \
' └──────────┘' \
' DPI sees: HTTPS traffic (allowed)' \
'' \
' STEP-BY-STEP SETUP:' \
' 1. Install TunnelForge on your PC (Linux/WSL)' \
' 2. Run: tunnelforge wizard' \
' 3. Enter VPS connection details (host, user, password)' \
' 4. Pick tunnel type: SOCKS5 Proxy' \
' 5. At "Connection Mode": choose TLS Encrypted' \
' - Port: 443 (or 8443 if 443 is busy)' \
' - Say YES to "Set up stunnel on server now?"' \
' - TunnelForge auto-installs stunnel on VPS' \
' 6. At "Inbound Protection": choose No (not needed,' \
' you are connecting directly from your own PC)' \
' 7. Save and start the tunnel' \
'' \
' AFTER TUNNEL STARTS:' \
' Set browser SOCKS5 proxy: 127.0.0.1:1080' \
' Test: curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me' \
' → Should show VPS IP' \
'' \
' WHAT DPI SEES:' \
' Your PC ──HTTPS:443──> VPS IP' \
' Looks like you are browsing a normal website.' \
''
_press_any_key
}
_scenario_tls_double_vps() {
_menu_header "Scenario: Double TLS Chain (Two VPS)"
printf >/dev/tty '%s\n' \
'' \
' GOAL: You run a shared proxy for multiple users.' \
' One VPS is inside the censored country (relay),' \
' one is outside (exit). Users connect to the relay' \
' and traffic exits through the outside VPS.' \
'' \
' WHAT YOU NEED:' \
' - VPS-A: Inside censored country (e.g. Iran datacenter)' \
' - VPS-B: Outside (e.g. US, Europe)' \
'' \
' HOW IT WORKS:' \
' Both legs are TLS-wrapped. Users connect with PSK.' \
'' \
' NETWORK DIAGRAM:' \
'' \
' Users VPS-A (Iran) VPS-B (Outside)' \
' ┌──────┐ TLS+PSK ┌──────────────┐ TLS:443 ┌──────────┐' \
' │ PC 1 ├────────>│ TunnelForge ├──────────>│ stunnel │' \
' ├──────┤ :1443 │ stunnel+PSK │ (HTTPS) │ → SSH:22 │─> Net' \
' │ PC 2 ├────────>│ → SOCKS5:1080│ └──────────┘' \
' └──────┘ └──────────────┘' \
' DPI sees: HTTPS DPI sees: HTTPS' \
'' \
' STEP-BY-STEP SETUP ON VPS-A:' \
' 1. Install TunnelForge on VPS-A' \
' 2. Run: tunnelforge wizard' \
' 3. SSH host = VPS-B IP, enter credentials' \
' 4. Tunnel type: SOCKS5 Proxy' \
' 5. Bind address: 0.0.0.0 (auto-forced to 127.0.0.1)' \
' 6. At "Connection Mode": choose TLS Encrypted' \
' - Port: 443 (auto-installs stunnel on VPS-B)' \
' 7. At "Inbound Protection": choose TLS + PSK' \
' - Port: 1443 (users connect here)' \
' - PSK auto-generated' \
' 8. Save and start' \
'' \
' AFTER TUNNEL STARTS:' \
' TunnelForge shows the client config + PSK.' \
' Generate a connect script for users:' \
' tunnelforge client-script <profile>' \
'' \
' USER SETUP (on each user PC):' \
' Option A: Run the generated script:' \
' ./tunnelforge-connect.sh' \
' → Auto-installs stunnel, connects, done' \
'' \
' Option B: Manual stunnel config:' \
' tunnelforge client-config <profile>' \
' → Shows stunnel.conf + psk.txt to copy' \
'' \
' Then set browser proxy: 127.0.0.1:1080' \
'' \
' WHAT DPI SEES:' \
' User PC ──HTTPS:1443──> VPS-A (normal TLS)' \
' VPS-A ──HTTPS:443───> VPS-B (normal TLS)' \
' No SSH protocol visible anywhere in the chain.' \
''
_press_any_key
}
_scenario_tls_share_tunnel() {
_menu_header "Scenario: Share Your Tunnel with Others"
printf >/dev/tty '%s\n' \
'' \
' GOAL: You have a working TLS tunnel on a VPS and want' \
' to let friends/family use it from their own PCs.' \
'' \
' WHAT YOU NEED:' \
' - A running TunnelForge tunnel with Inbound TLS+PSK' \
' - Friends who need to connect' \
'' \
' HOW IT WORKS:' \
' TunnelForge generates a one-file script. Users run it' \
' and it auto-installs stunnel, configures everything,' \
' and connects. No technical knowledge needed.' \
'' \
' STEP 1 — GENERATE THE SCRIPT (on the server):' \
'' \
' tunnelforge client-script <profile>' \
'' \
' This creates: tunnelforge-connect.sh' \
' The script contains the server address, port,' \
' and the PSK — everything needed to connect.' \
'' \
' STEP 2 — SEND IT TO USERS:' \
' Share tunnelforge-connect.sh via:' \
' - Telegram, WhatsApp, email, USB drive' \
' - Any method that can transfer a small file' \
'' \
' STEP 3 — USER RUNS THE SCRIPT:' \
'' \
' chmod +x tunnelforge-connect.sh' \
' ./tunnelforge-connect.sh' \
'' \
' The script will:' \
' 1. Install stunnel if not present (apt/dnf/brew)' \
' 2. Write config files to ~/.tunnelforge-client/' \
' 3. Start stunnel and create a local SOCKS5 proxy' \
' 4. Print browser setup instructions' \
'' \
' USER COMMANDS:' \
' ./tunnelforge-connect.sh Connect' \
' ./tunnelforge-connect.sh stop Disconnect' \
' ./tunnelforge-connect.sh status Check connection' \
'' \
' AFTER CONNECTING:' \
' Browser proxy: 127.0.0.1:<socks-port>' \
' All traffic routes through your tunnel.' \
'' \
' SECURITY NOTES:' \
' - The script contains the PSK (shared secret)' \
' - Only share with trusted people' \
' - To revoke access: change PSK in profile,' \
' regenerate script, and restart tunnel' \
' - Each user gets their own local SOCKS5 proxy' \
' - The server stunnel handles multiple connections' \
'' \
' OTHER USEFUL COMMANDS:' \
' tunnelforge client-config <profile>' \
' → Show connection details + PSK (for manual setup)' \
' tunnelforge client-script <profile> /path/to/output.sh' \
' → Save script to specific location' \
''
_press_any_key
}
# ── Menu helpers for start/stop ──
_menu_start_tunnel() {
local _ms_profiles
_ms_profiles=$(list_profiles)
if [[ -z "$_ms_profiles" ]]; then
log_info "No profiles found. Create one first."
return 0
fi
printf "\n${BOLD}Available tunnels:${RESET}\n" >/dev/tty
local _ms_names=() _ms_idx=0
while IFS= read -r _ms_pn; do
[[ -z "$_ms_pn" ]] && continue
(( ++_ms_idx ))
_ms_names+=("$_ms_pn")
local _ms_st
if is_tunnel_running "$_ms_pn"; then
_ms_st="${GREEN}● running${RESET}"
else
_ms_st="${DIM}■ stopped${RESET}"
fi
printf " ${CYAN}%d${RESET}) %-20s %b\n" "$_ms_idx" "$_ms_pn" "$_ms_st" >/dev/tty
done <<< "$_ms_profiles"
printf "\n" >/dev/tty
local _ms_choice
if ! _read_tty "Select tunnel # (or name)" _ms_choice ""; then return 0; fi
local _ms_target
if [[ "$_ms_choice" =~ ^[0-9]+$ ]] && (( _ms_choice >= 1 && _ms_choice <= ${#_ms_names[@]} )); then
_ms_target="${_ms_names[$((_ms_choice-1))]}"
else
_ms_target="$_ms_choice"
fi
if [[ -n "$_ms_target" ]]; then
start_tunnel "$_ms_target" || true
fi
}
_menu_stop_tunnel() {
local _mt_profiles
_mt_profiles=$(list_profiles)
if [[ -z "$_mt_profiles" ]]; then
log_info "No profiles found. Create one first."
return 0
fi
local _mt_names=() _mt_idx=0 _mt_header_shown=false
while IFS= read -r _mt_pn; do
[[ -z "$_mt_pn" ]] && continue
if is_tunnel_running "$_mt_pn"; then
if [[ "$_mt_header_shown" == false ]]; then
printf "\n${BOLD}Running tunnels:${RESET}\n" >/dev/tty
_mt_header_shown=true
fi
(( ++_mt_idx ))
_mt_names+=("$_mt_pn")
printf " ${CYAN}%d${RESET}) %s\n" "$_mt_idx" "$_mt_pn" >/dev/tty
fi
done <<< "$_mt_profiles"
if [[ ${#_mt_names[@]} -eq 0 ]]; then
log_info "No running tunnels."
return 0
fi
printf "\n" >/dev/tty
local _mt_choice
if ! _read_tty "Select tunnel # (or name)" _mt_choice ""; then return 0; fi
local _mt_target
if [[ "$_mt_choice" =~ ^[0-9]+$ ]] && (( _mt_choice >= 1 && _mt_choice <= ${#_mt_names[@]} )); then
_mt_target="${_mt_names[$((_mt_choice-1))]}"
else
_mt_target="$_mt_choice"
fi
if [[ -n "$_mt_target" ]]; then
stop_tunnel "$_mt_target" || true
fi
}
# ── Security sub-menus ──
_menu_ssh_keys() {
while true; do
clear >/dev/tty 2>/dev/null || true
printf "\n${BOLD_CYAN}═══ SSH Key Management ═══${RESET}\n\n" >/dev/tty
printf " ${CYAN}1${RESET}) Generate new SSH key\n" >/dev/tty
printf " ${CYAN}2${RESET}) Deploy key to server\n" >/dev/tty
printf " ${CYAN}3${RESET}) Check key permissions\n" >/dev/tty
printf " ${YELLOW}q${RESET}) Back\n\n" >/dev/tty
local _mk_choice
printf " ${BOLD}Select${RESET}: " >/dev/tty
read -rsn1 _mk_choice </dev/tty || true
_drain_esc _mk_choice
printf "\n" >/dev/tty
case "$_mk_choice" in
1)
local _mk_type _mk_path
_read_tty "Key type (ed25519/rsa/ecdsa)" _mk_type "ed25519" || true
case "$_mk_type" in
ed25519|rsa|ecdsa) ;;
*) log_error "Unsupported key type: ${_mk_type} (use ed25519, rsa, or ecdsa)" ;;
esac
if [[ "$_mk_type" =~ ^(ed25519|rsa|ecdsa)$ ]]; then
_mk_path="${HOME}/.ssh/id_${_mk_type}"
generate_ssh_key "$_mk_type" "$_mk_path" || true
fi
_press_any_key ;;
2)
local _mk_name
_read_tty "Profile name" _mk_name "" || true
if [[ -n "$_mk_name" ]] && validate_profile_name "$_mk_name"; then
deploy_ssh_key "$_mk_name" || true
elif [[ -n "$_mk_name" ]]; then
log_error "Invalid profile name: ${_mk_name}"
fi
_press_any_key ;;
3)
local _mk_kpath
_read_tty "Key path" _mk_kpath "${HOME}/.ssh/id_ed25519" || true
check_key_permissions "$_mk_kpath" || true
_press_any_key ;;
q|Q) return 0 ;;
*) true ;;
esac
done
}
_menu_service_select() {
local _msv_profiles
_msv_profiles=$(list_profiles)
if [[ -z "$_msv_profiles" ]]; then
log_info "No profiles found. Create one first."
return 0
fi
printf "\n${BOLD}Available profiles:${RESET}\n" >/dev/tty
local _msv_names=() _msv_idx=0
while IFS= read -r _msv_pn; do
[[ -z "$_msv_pn" ]] && continue
(( ++_msv_idx ))
_msv_names+=("$_msv_pn")
printf " ${CYAN}%d${RESET}) %s\n" "$_msv_idx" "$_msv_pn" >/dev/tty
done <<< "$_msv_profiles"
printf "\n" >/dev/tty
local _msv_choice
if ! _read_tty "Select profile # (or name)" _msv_choice ""; then return 0; fi
local _msv_target
if [[ "$_msv_choice" =~ ^[0-9]+$ ]] && (( _msv_choice >= 1 && _msv_choice <= ${#_msv_names[@]} )); then
_msv_target="${_msv_names[$((_msv_choice-1))]}"
else
_msv_target="$_msv_choice"
fi
if [[ -n "$_msv_target" ]]; then
_menu_service "$_msv_target" || true
fi
return 0
}
_menu_fingerprint() {
while true; do
clear >/dev/tty 2>/dev/null || true
printf "\n${BOLD_CYAN}═══ SSH Host Fingerprint Verification ═══${RESET}\n\n" >/dev/tty
local _mf_choice
printf " ${CYAN}1${RESET}) Enter host manually\n" >/dev/tty
printf " ${CYAN}2${RESET}) Check from profile\n" >/dev/tty
printf " ${YELLOW}q${RESET}) Back\n\n" >/dev/tty
printf " ${BOLD}Select${RESET}: " >/dev/tty
read -rsn1 _mf_choice </dev/tty || true
_drain_esc _mf_choice
printf "\n" >/dev/tty
case "$_mf_choice" in
1)
local _mf_host _mf_port
_read_tty "Host" _mf_host "" || true
_read_tty "Port" _mf_port "22" || true
if [[ -n "$_mf_host" ]]; then
if validate_port "$_mf_port"; then
verify_host_fingerprint "$_mf_host" "$_mf_port" || true
else
log_error "Invalid port: ${_mf_port}"
fi
else
log_error "Host cannot be empty"
fi
_press_any_key ;;
2)
local _mf_name
_read_tty "Profile name" _mf_name "" || true
if [[ -n "$_mf_name" ]]; then
local -A _mf_prof
if load_profile "$_mf_name" _mf_prof 2>/dev/null; then
local _mf_h="${_mf_prof[SSH_HOST]:-}"
local _mf_p="${_mf_prof[SSH_PORT]:-22}"
if [[ -n "$_mf_h" ]]; then
verify_host_fingerprint "$_mf_h" "$_mf_p" || true
else
log_error "No SSH host in profile '${_mf_name}'"
fi
else
log_error "Cannot load profile '${_mf_name}'"
fi
fi
_press_any_key ;;
q|Q) return 0 ;;
*) true ;;
esac
done
}
# ── Main interactive menu ──
show_menu() {
while true; do
_menu_header ""
# Quick status summary
local _mm_profiles _mm_total=0 _mm_running=0
_mm_profiles=$(list_profiles)
if [[ -n "$_mm_profiles" ]]; then
while IFS= read -r _mm_pn; do
[[ -z "$_mm_pn" ]] && continue
(( ++_mm_total ))
if is_tunnel_running "$_mm_pn"; then
(( ++_mm_running ))
fi
done <<< "$_mm_profiles"
fi
printf " ${DIM}Tunnels: ${RESET}${GREEN}%d running${RESET} ${DIM}/ %d total${RESET}\n\n" \
"$_mm_running" "$_mm_total" >/dev/tty
printf " ${BOLD}── Tunnel Operations ──${RESET}\n" >/dev/tty
printf " ${CYAN}1${RESET}) ${BOLD}Create${RESET} new tunnel ${DIM}Setup wizard${RESET}\n" >/dev/tty
printf " ${CYAN}2${RESET}) ${BOLD}Start${RESET} a tunnel ${DIM}Launch SSH tunnel${RESET}\n" >/dev/tty
printf " ${CYAN}3${RESET}) ${BOLD}Stop${RESET} a tunnel ${DIM}Terminate tunnel${RESET}\n" >/dev/tty
printf " ${CYAN}4${RESET}) ${BOLD}Start All${RESET} tunnels ${DIM}Launch autostart tunnels${RESET}\n" >/dev/tty
printf " ${CYAN}5${RESET}) ${BOLD}Stop All${RESET} tunnels ${DIM}Terminate all${RESET}\n" >/dev/tty
printf "\n ${BOLD}── Monitoring ──${RESET}\n" >/dev/tty
printf " ${CYAN}6${RESET}) ${BOLD}Status${RESET} ${DIM}Show tunnel statuses${RESET}\n" >/dev/tty
printf " ${CYAN}7${RESET}) ${BOLD}Dashboard${RESET} ${DIM}Live TUI dashboard${RESET}\n" >/dev/tty
printf "\n ${BOLD}── Management ──${RESET}\n" >/dev/tty
printf " ${CYAN}8${RESET}) ${BOLD}Profiles${RESET} ${DIM}Manage tunnel profiles${RESET}\n" >/dev/tty
printf " ${CYAN}9${RESET}) ${BOLD}Settings${RESET} ${DIM}Configure defaults${RESET}\n" >/dev/tty
printf " ${CYAN}s${RESET}) ${BOLD}Services${RESET} ${DIM}Systemd service manager${RESET}\n" >/dev/tty
printf " ${CYAN}b${RESET}) ${BOLD}Backup / Restore${RESET} ${DIM}Manage backups${RESET}\n" >/dev/tty
printf "\n ${BOLD}── Security & Notifications ──${RESET}\n" >/dev/tty
printf " ${CYAN}x${RESET}) ${BOLD}Security Audit${RESET} ${DIM}Check security posture${RESET}\n" >/dev/tty
printf " ${CYAN}k${RESET}) ${BOLD}SSH Key Management${RESET} ${DIM}Generate & deploy keys${RESET}\n" >/dev/tty
printf " ${CYAN}f${RESET}) ${BOLD}Fingerprint Check${RESET} ${DIM}Verify host fingerprints${RESET}\n" >/dev/tty
printf " ${CYAN}t${RESET}) ${BOLD}Telegram${RESET} ${DIM}Notification settings${RESET}\n" >/dev/tty
printf " ${CYAN}c${RESET}) ${BOLD}Client Configs${RESET} ${DIM}TLS+PSK connection info${RESET}\n" >/dev/tty
printf "\n ${BOLD}── Information ──${RESET}\n" >/dev/tty
printf " ${CYAN}e${RESET}) ${BOLD}Examples${RESET} ${DIM}Real-world scenarios${RESET}\n" >/dev/tty
printf " ${CYAN}l${RESET}) ${BOLD}Learn${RESET} ${DIM}SSH tunnel concepts${RESET}\n" >/dev/tty
printf " ${CYAN}a${RESET}) ${BOLD}About${RESET} ${DIM}Version & info${RESET}\n" >/dev/tty
printf " ${CYAN}h${RESET}) ${BOLD}Help${RESET} ${DIM}CLI reference${RESET}\n" >/dev/tty
printf "\n ${CYAN}w${RESET}) ${BOLD}Update${RESET} ${DIM}Check for updates${RESET}\n" >/dev/tty
printf " ${RED}u${RESET}) ${BOLD}Uninstall${RESET} ${DIM}Remove everything${RESET}\n" >/dev/tty
printf " ${YELLOW}q${RESET}) ${BOLD}Quit${RESET}\n\n" >/dev/tty
local _mm_choice
printf " ${BOLD}Select${RESET}: " >/dev/tty
read -rsn1 _mm_choice </dev/tty || true
_drain_esc _mm_choice
printf "\n" >/dev/tty
case "$_mm_choice" in
1) wizard_create_profile || true; _press_any_key ;;
2) _menu_start_tunnel || true; _press_any_key ;;
3) _menu_stop_tunnel || true; _press_any_key ;;
4) start_all_tunnels || true; _press_any_key ;;
5) stop_all_tunnels || true; _press_any_key ;;
6) show_status || true; _press_any_key ;;
7) show_dashboard || true ;;
8) show_profiles_menu || true ;;
9) show_settings_menu || true ;;
e|E) show_scenarios_menu || true ;;
l|L) show_learn_menu || true ;;
a|A) show_about || true ;;
h|H) show_help || true; _press_any_key ;;
x|X) security_audit || true; _press_any_key ;;
k|K) _menu_ssh_keys || true ;;
f|F) _menu_fingerprint || true ;;
t|T) _menu_telegram || true ;;
c|C) _menu_client_configs || true; _press_any_key ;;
s|S) _menu_service_select || true ;;
b|B) _menu_backup_restore || true ;;
w|W) update_tunnelforge || true; _press_any_key ;;
u|U) if confirm_action "Uninstall TunnelForge completely?"; then
clear >/dev/tty 2>/dev/null || true
uninstall_tunnelforge || true
return 0
fi ;;
q|Q) clear >/dev/tty 2>/dev/null || true
printf " ${DIM}Goodbye!${RESET}\n\n" >/dev/tty
return 0 ;;
*) true ;;
esac
done
}
# ============================================================================
# DASHBOARD & LIVE MONITORING (Phase 3)
# ============================================================================
# Sparkline block characters (8 levels)
readonly SPARK_CHARS=( "▁" "▂" "▃" "▄" "▅" "▆" "▇" "█" )
# Dashboard caches (avoid expensive re-computation each render)
declare -gA _DASH_LATENCY=() # cached quality string per tunnel
declare -gA _DASH_LATENCY_TS=() # epoch of last latency check
declare -gA _DASH_LAT_HOST=() # latency cache by host:port (shared across tunnels)
declare -gA _DASH_LAT_HOST_TS=() # epoch of last check per host:port
declare -g _DASH_SS_CACHE="" # cached ss -tn output per render cycle
declare -g _DASH_SYSRES="" # cached system resources line
declare -g _DASH_SYSRES_TS=0 # epoch of last sysres check
declare -g _DASH_LAST_SPEED="" # last speed test result string
declare -gi _DASH_PAGE=0 # current page (0-indexed)
declare -gi _DASH_PER_PAGE=4 # tunnels per page
declare -gi _DASH_TOTAL_PAGES=1 # computed per render
# Record a bandwidth sample to history (optimized: zero forks in hot path)
declare -gA _BW_WRITE_COUNT=() # per-tunnel write counter for throttled trim
_bw_record() {
local name="$1" rx="$2" tx="$3"
local bw_file="${BW_HISTORY_DIR}/${name}.dat"
[[ -d "$BW_HISTORY_DIR" ]] || mkdir -p "$BW_HISTORY_DIR" 2>/dev/null || true
# Light mkdir lock for append+trim
local _bw_lock="${bw_file}.lck"
local _bw_try=0
while ! mkdir "$_bw_lock" 2>/dev/null; do
local _bw_stale_pid=""
read -r _bw_stale_pid < "${_bw_lock}/pid" 2>/dev/null || true
if [[ -n "$_bw_stale_pid" ]] && ! kill -0 "$_bw_stale_pid" 2>/dev/null; then
rm -f "${_bw_lock}/pid" 2>/dev/null || true
rmdir "$_bw_lock" 2>/dev/null || true
continue
fi
if (( ++_bw_try >= 3 )); then return 0; fi
sleep 0.1
done
printf '%s' "$$" > "${_bw_lock}/pid" 2>/dev/null || true
# printf '%(%s)T' is a bash 4.2+ builtin — no fork needed for timestamp
local _now_epoch
printf -v _now_epoch '%(%s)T' -1
printf "%d %d %d\n" "$_now_epoch" "$rx" "$tx" >> "$bw_file" 2>/dev/null || true
# Throttle trimming: only check every 10 writes (not every single write)
local _wc=${_BW_WRITE_COUNT[$name]:-0}
(( ++_wc )) || true
_BW_WRITE_COUNT["$name"]=$_wc
if (( _wc >= 10 )); then
_BW_WRITE_COUNT["$name"]=0
# Count lines without forking wc
local _line_count=0
while IFS= read -r _; do
(( ++_line_count )) || true
done < "$bw_file" 2>/dev/null
if (( _line_count > 120 )); then
local tmp_bw_file="${bw_file}.tmp"
if tail -n 120 "$bw_file" > "$tmp_bw_file" 2>/dev/null; then
mv "$tmp_bw_file" "$bw_file" 2>/dev/null || rm -f "$tmp_bw_file" 2>/dev/null
else
rm -f "$tmp_bw_file" 2>/dev/null
fi
fi
fi
rm -f "${_bw_lock}/pid" 2>/dev/null || true
rmdir "$_bw_lock" 2>/dev/null || true
}
# Read last N bandwidth deltas from history (optimized: no regex per line)
_bw_read_deltas() {
local name="$1" count="${2:-30}"
local bw_file="${BW_HISTORY_DIR}/${name}.dat"
[[ -f "$bw_file" ]] || return 0
local -a timestamps rx_vals tx_vals
local ts rx tx
# Data file is our own trusted format (epoch rx tx per line) — skip regex
while read -r ts rx tx; do
[[ -z "$ts" ]] && continue
timestamps+=("$ts")
rx_vals+=("$rx")
tx_vals+=("$tx")
done < <(tail -n "$(( count + 1 ))" "$bw_file" 2>/dev/null)
local total=${#timestamps[@]}
if (( total < 2 )); then return 0; fi
local _i dt drx dtx
for (( _i=1; _i<total; _i++ )); do
dt=$(( timestamps[_i] - timestamps[_i-1] ))
if (( dt < 1 )); then dt=1; fi
drx=$(( (rx_vals[_i] - rx_vals[_i-1]) / dt ))
dtx=$(( (tx_vals[_i] - tx_vals[_i-1]) / dt ))
if (( drx < 0 )); then drx=0; fi
if (( dtx < 0 )); then dtx=0; fi
echo "$drx $dtx"
done
}
# Generate sparkline string from numeric values
_sparkline() {
local -a vals=("$@")
local count=${#vals[@]}
if (( count == 0 )); then return 0; fi
# Find max
local max_val=0 v
for v in "${vals[@]}"; do
if (( v > max_val )); then max_val="$v"; fi
done
local spark="" _si
if (( max_val == 0 )); then
for (( _si=0; _si<count; _si++ )); do spark+="${SPARK_CHARS[0]}"; done
else
for v in "${vals[@]}"; do
local idx
if (( v >= max_val )); then
idx=7
else
idx=$(( (v * 7 + max_val / 2) / max_val ))
fi
if (( idx > 7 )); then idx=7; fi
if (( idx < 0 )); then idx=0; fi
spark+="${SPARK_CHARS[$idx]}"
done
fi
printf '%s' "$spark"
}
# Get reconnect stats for a tunnel
_reconnect_stats() {
local name="$1"
local rlog="${RECONNECT_LOG_DIR}/${name}.log"
[[ -f "$rlog" ]] || { echo "0 -"; return 0; }
# Count lines + capture last line in one pass (no wc/tail/cut forks)
local _total=0 _last_line=""
while IFS= read -r _last_line; do
(( ++_total )) || true
done < "$rlog" 2>/dev/null
# Extract timestamp (before first '|')
local _last_ts="${_last_line%%|*}"
echo "${_total} ${_last_ts:--}"
return 0
}
# Simple speed test using curl (routes through SOCKS5 tunnel if available)
_speed_test() {
local -a _test_urls=(
"http://speedtest.tele2.net/1MB.zip"
"http://proof.ovh.net/files/1Mb.dat"
"http://ipv4.download.thinkbroadband.com/1MB.zip"
)
local test_size=1048576 # 1MB
printf "\n ${BOLD_CYAN}── Speed Test ──${RESET}\n\n" >/dev/tty
if ! command -v curl &>/dev/null; then
printf " ${RED}curl is required for speed test${RESET}\n" >/dev/tty
return 0
fi
# Route through SOCKS5 tunnel if available (measures tunnel throughput)
local -a _proxy_args=()
local _proxy_port
_proxy_port=$(_tg_find_proxy 2>/dev/null) || true
if [[ -n "$_proxy_port" ]]; then
_proxy_args=(--socks5-hostname "127.0.0.1:${_proxy_port}")
printf " ${DIM}Testing through SOCKS5 tunnel (port %s)...${RESET}\n" "$_proxy_port" >/dev/tty
else
printf " ${DIM}Testing direct connection...${RESET}\n" >/dev/tty
fi
printf " ${DIM}Downloading 1MB test file...${RESET}\n" >/dev/tty
local start_time end_time elapsed speed_bps speed_str
local _test_ok=false
for _turl in "${_test_urls[@]}"; do
start_time=$(_get_ns_timestamp)
if curl -s -o /dev/null --max-time 15 "${_proxy_args[@]}" "$_turl" 2>/dev/null; then
end_time=$(_get_ns_timestamp)
_test_ok=true
break
fi
done
if [[ "$_test_ok" == true ]] && (( start_time > 0 && end_time > 0 )); then
elapsed=$(( (end_time - start_time) / 1000000 )) # milliseconds
if (( elapsed < 1 )); then elapsed=1; fi
speed_bps=$(( test_size * 1000 / elapsed )) # bytes/sec
speed_str=$(format_bytes "$speed_bps")
printf " ${GREEN}${RESET} Download speed: ${BOLD}%s/s${RESET}\n" "$speed_str" >/dev/tty
printf " ${DIM}Time: %d.%03ds${RESET}\n" "$((elapsed/1000))" "$((elapsed%1000))" >/dev/tty
_DASH_LAST_SPEED="${speed_str}/s"
else
printf " ${RED}${RESET} Speed test failed (check connection)\n" >/dev/tty
fi
return 0
}
# ── Dashboard renderer ──
_dash_box_top() {
local width="$1"
local line="╔"
local _i
for (( _i=0; _i<width-2; _i++ )); do line+="═"; done
line+="╗"
printf '%s' "$line"
}
_dash_box_bottom() {
local width="$1"
local line="╚"
local _i
for (( _i=0; _i<width-2; _i++ )); do line+="═"; done
line+="╝"
printf '%s' "$line"
}
_dash_box_mid() {
local width="$1"
local line="╠"
local _i
for (( _i=0; _i<width-2; _i++ )); do line+="═"; done
line+="╣"
printf '%s' "$line"
}
# Pure-bash ANSI escape sequence stripping (avoids sed fork per call)
# Store ANSI-stripped string directly into caller's variable (no subshell fork)
# Usage: _strip_ansi_v RESULT_VAR "string with \e[31mcolors\e[0m"
_strip_ansi_v() {
local -n _sav_ref="$1"
local _sav_s="$2" _sav_out="" _sav_esc=$'\033'
while [[ -n "$_sav_s" ]]; do
if [[ "$_sav_s" == "${_sav_esc}["* ]]; then
_sav_s="${_sav_s#"${_sav_esc}["}"
while [[ -n "$_sav_s" ]] && [[ "$_sav_s" != [a-zA-Z]* ]]; do
_sav_s="${_sav_s:1}"
done
_sav_s="${_sav_s:1}"
elif [[ "$_sav_s" == "${_sav_esc}("* ]]; then
_sav_s="${_sav_s:3}"
elif [[ "$_sav_s" == "${_sav_esc}"* ]]; then
_sav_s="${_sav_s:1}"
else
_sav_out+="${_sav_s:0:1}"
_sav_s="${_sav_s:1}"
fi
done
_sav_ref="$_sav_out"
}
# Legacy wrapper for non-hot-path callers (uses subshell)
_strip_ansi() {
local _sa_result=""
_strip_ansi_v _sa_result "$1"
printf '%s' "$_sa_result"
}
# ── Dashboard helper: system resources (cached 5s) ──
_dash_system_resources() {
local now
printf -v now '%(%s)T' -1
if [[ -n "$_DASH_SYSRES" ]] && (( now - _DASH_SYSRES_TS < 5 )); then
return 0
fi
# Load averages (single read, no forks)
local load_1="" load_5="" load_15=""
if [[ -f /proc/loadavg ]]; then
read -r load_1 load_5 load_15 _ _ < /proc/loadavg 2>/dev/null || true
fi
# Memory from /proc/meminfo (pure bash, no grep/awk forks)
local mem_total=0 mem_avail=0 mem_used=0 mem_pct=0
if [[ -f /proc/meminfo ]]; then
local _key _val _unit
while read -r _key _val _unit; do
case "$_key" in
MemTotal:) mem_total=$(( _val / 1024 )) ;;
MemAvailable:) mem_avail=$(( _val / 1024 )); break ;;
esac
done < /proc/meminfo 2>/dev/null
if (( mem_total > 0 )); then
mem_used=$(( mem_total - mem_avail ))
mem_pct=$(( mem_used * 100 / mem_total ))
fi
fi
local mem_str
if (( mem_total >= 1024 )); then
# Integer GB with one decimal via bash math (no awk fork)
local _mu_whole=$(( mem_used / 1024 )) _mu_frac=$(( (mem_used % 1024) * 10 / 1024 ))
local _mt_whole=$(( mem_total / 1024 )) _mt_frac=$(( (mem_total % 1024) * 10 / 1024 ))
mem_str="${_mu_whole}.${_mu_frac}G/${_mt_whole}.${_mt_frac}G (${mem_pct}%)"
elif (( mem_total > 0 )); then
mem_str="${mem_used}M/${mem_total}M (${mem_pct}%)"
else
mem_str="N/A"
fi
_DASH_SYSRES="MEM: ${mem_str} │ Load: ${load_1:-?} ${load_5:-?} ${load_15:-?}"
_DASH_SYSRES_TS=$now
return 0
}
# ── Dashboard helper: active connections on a port (optimized: no awk forks) ──
_dash_active_conns() {
local port="$1"
[[ "$port" =~ ^[0-9]+$ ]] || { echo "0 clients"; return 0; }
local -A _seen=()
local -a _unique=()
local _line
# Use cached ss output if available (set by _dash_render), else run ss
local _ss_data="${_DASH_SS_CACHE:-}"
if [[ -z "$_ss_data" ]]; then
_ss_data=$(ss -tn 2>/dev/null) || true
fi
while IFS= read -r _line; do
[[ -z "$_line" ]] && continue
# Extract peer address via bash string ops (no awk fork)
# ss output: ESTAB 0 0 local:port peer:port
local _rest="${_line#*ESTAB}"
[[ "$_rest" == "$_line" ]] && continue # not ESTAB
# Split on whitespace: skip recv-q, send-q, local addr; get peer addr
read -r _ _ _ _src <<< "$_rest" || continue
# Strip port: 192.168.1.5:43210 → 192.168.1.5
_src="${_src%:*}"
if [[ -n "$_src" ]] && [[ -z "${_seen[$_src]:-}" ]]; then
_seen["$_src"]=1
_unique+=("$_src")
fi
done < <(echo "$_ss_data" | grep -F ":${port}" || true)
local count=${#_unique[@]}
if (( count == 0 )); then
echo "0 clients"
elif (( count <= 5 )); then
local _joined
_joined=$(IFS=, ; echo "${_unique[*]}")
echo "${count} clients: ${_joined// /}"
else
local _first5
_first5=$(IFS=, ; echo "${_unique[*]:0:5}")
echo "${count} clients: ${_first5// /} (+$((count-5)))"
fi
return 0
}
# ── Dashboard helper: cached latency check (30s TTL) ──
# Result stored in _DASH_LATENCY[name] — caller reads directly (no subshell)
_dash_latency_cached() {
local name="$1" host="$2" port="${3:-22}"
local now
printf -v now '%(%s)T' -1
# Cache by host:port (not tunnel name) so multiple tunnels to same server share one check
local _cache_key="${host}:${port}"
if [[ -n "${_DASH_LAT_HOST[$_cache_key]:-}" ]] && [[ -n "${_DASH_LAT_HOST_TS[$_cache_key]:-}" ]] \
&& (( now - ${_DASH_LAT_HOST_TS[$_cache_key]} < 30 )); then
_DASH_LATENCY["$name"]="${_DASH_LAT_HOST[$_cache_key]}"
return 0
fi
local rating icon
rating=$(_connection_quality "$host" "$port" 2>/dev/null) || true
: "${rating:=unknown}"
icon=$(_quality_icon "$rating" 2>/dev/null) || true
: "${icon:=?}"
local _result="${rating} ${icon}"
_DASH_LAT_HOST["$_cache_key"]="$_result"
_DASH_LAT_HOST_TS["$_cache_key"]=$now
_DASH_LATENCY["$name"]="$_result"
return 0
}
# Render the complete dashboard frame
_dash_render() {
local LC_CTYPE=C.UTF-8
local width=72
# Cache ss output once per render cycle (used by get_tunnel_connections + _dash_active_conns)
_DASH_SS_CACHE=$(ss -tn 2>/dev/null) || true
# Load profiles + compute pagination (before header, so subtitle can show page)
local profiles
profiles=$(list_profiles)
local has_tunnels=false
local -A _dash_alive=() # cache: name→1 for running tunnels
local -A _dash_port=() # cache: name→LOCAL_PORT for running tunnels
local -A _dash_tls_port=() # cache: name→OBFS_LOCAL_PORT for running tunnels
local -a _dash_page_names=() # tunnels on current page (for active conns)
local -a _all_profiles=()
if [[ -n "$profiles" ]]; then
while IFS= read -r _pname; do
[[ -z "$_pname" ]] && continue
_all_profiles+=("$_pname")
done <<< "$profiles"
fi
local _total=${#_all_profiles[@]}
# Auto-calculate tunnels per page based on terminal height
# Fixed overhead: header(8) + colhdr(3) + sysres(3) + active_conn_hdr(2) + log(4)
# + reconnect(4) + sysinfo(2) + footer(3) = ~29 lines
# Per tunnel on page: ~5 lines (status + sparkline + route + auth + active_conn_row)
local _term_h="${_DASH_TERM_H:-40}"
local _overhead=29
_DASH_PER_PAGE=$(( (_term_h - _overhead) / 5 ))
if (( _DASH_PER_PAGE < 2 )); then _DASH_PER_PAGE=2; fi
if (( _DASH_PER_PAGE > 8 )); then _DASH_PER_PAGE=8; fi
if (( _total > 0 )); then
_DASH_TOTAL_PAGES=$(( (_total + _DASH_PER_PAGE - 1) / _DASH_PER_PAGE ))
else
_DASH_TOTAL_PAGES=1
fi
if (( _DASH_PAGE >= _DASH_TOTAL_PAGES )); then _DASH_PAGE=$(( _DASH_TOTAL_PAGES - 1 )); fi
if (( _DASH_PAGE < 0 )); then _DASH_PAGE=0; fi
local _pg_start=$(( _DASH_PAGE * _DASH_PER_PAGE ))
local _pg_end=$(( _pg_start + _DASH_PER_PAGE ))
if (( _pg_end > _total )); then _pg_end=$_total; fi
# Header
printf "${BOLD_GREEN}"
_dash_box_top "$width"
printf "${RESET}\n"
# Title bar (3-row ASCII art)
local _tr1=" ▀▀█▀▀ █ █ █▄ █ █▄ █ █▀▀ █ █▀▀ █▀█ █▀█ █▀▀ █▀▀"
local _tr2=" █ █ █ █ ▀█ █ ▀█ █▀▀ █ █▀ █ █ █▀█ █ █ █▀▀"
local _tr3=" █ ▀▀ █ █ █ █ ▀▀▀ ▀▀▀ █ ▀▀▀ █ █ ▀▀▀ ▀▀▀"
local _tpad _tr
for _tr in "$_tr1" "$_tr2" "$_tr3"; do
_tpad=$(( width - ${#_tr} - 4 ))
if (( _tpad < 0 )); then _tpad=0; fi
printf "${BOLD_GREEN}${RESET} ${BOLD_CYAN}%s${RESET}%*s ${BOLD_GREEN}${RESET}\n" "$_tr" "$_tpad" ""
done
# Subtitle
local now_ts
printf -v now_ts '%(%Y-%m-%d %H:%M:%S)T' -1
local _page_ind=""
if (( _DASH_TOTAL_PAGES > 1 )); then
_page_ind=" │ Page $(( _DASH_PAGE + 1 ))/${_DASH_TOTAL_PAGES}"
fi
local sub_text=" Dashboard v${VERSION}${_page_ind}${now_ts}"
printf "${BOLD_GREEN}${RESET} ${DIM}%s${RESET}" "$sub_text"
local sub_len=${#sub_text}
local _sub_pad=$(( width - sub_len - 4 ))
if (( _sub_pad < 0 )); then _sub_pad=0; fi
printf '%*s' "$_sub_pad" ""
printf " ${BOLD_GREEN}${RESET}\n"
printf "${BOLD_GREEN}"
_dash_box_mid "$width"
printf "${RESET}\n"
# Column headers — fixed 70 inner width layout
local hdr
hdr=$(printf " ${BOLD}%-12s %-5s %-8s %-13s %-8s %-5s %-8s${RESET}" \
"TUNNEL" "TYPE" "STATUS" "LOCAL" "TRAFFIC" "CONNS" "UPTIME")
printf "${BOLD_GREEN}${RESET}%s" "$hdr"
local hdr_stripped
_strip_ansi_v hdr_stripped "$hdr"
local hdr_len=${#hdr_stripped}
local _hdr_pad=$(( width - hdr_len - 2 ))
if (( _hdr_pad < 0 )); then _hdr_pad=0; fi
printf '%*s' "$_hdr_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
# Separator
printf "${BOLD_GREEN}${RESET}${DIM}"
local _i
for (( _i=0; _i<width-2; _i++ )); do printf "─"; done
printf "${RESET}${BOLD_GREEN}${RESET}\n"
# Tunnel rows
if (( _total > 0 )); then
local _pidx
for (( _pidx=0; _pidx<_total; _pidx++ )); do
local _dname="${_all_profiles[$_pidx]}"
has_tunnels=true
# For ALL tunnels: track running status (needed by logs section)
if is_tunnel_running "$_dname"; then
_dash_alive["$_dname"]=1
fi
# Skip rendering + heavy work for tunnels not on current page
if (( _pidx < _pg_start || _pidx >= _pg_end )); then
continue
fi
_dash_page_names+=("$_dname")
# Truncate long names for column alignment
local _dname_display="$_dname"
if (( ${#_dname_display} > 12 )); then
_dname_display="${_dname_display:0:11}~"
fi
unset _dp 2>/dev/null || true
local -A _dp=()
load_profile "$_dname" _dp 2>/dev/null || true
local dtype="${_dp[TUNNEL_TYPE]:-?}"
local daddr="${_dp[LOCAL_BIND_ADDR]:-}:${_dp[LOCAL_PORT]:-}"
# Populate port caches (for active connections on this page)
local _is_running=false
if [[ -n "${_dash_alive[$_dname]:-}" ]]; then
_is_running=true
_dash_port["$_dname"]="${_dp[LOCAL_PORT]:-}"
local _olp_cache="${_dp[OBFS_LOCAL_PORT]:-0}"
[[ "$_olp_cache" == "0" ]] && _olp_cache=""
_dash_tls_port["$_dname"]="$_olp_cache"
fi
if [[ "$_is_running" == true ]]; then
# Gather live stats
local up_s up_str traffic rchar wchar total traf_str conns
up_s=$(get_tunnel_uptime "$_dname" 2>/dev/null || true)
: "${up_s:=0}"
up_str=$(format_duration "$up_s")
traffic=$(get_tunnel_traffic "$_dname" 2>/dev/null || true)
: "${traffic:=0 0}"
read -r rchar wchar <<< "$traffic"
[[ "$rchar" =~ ^[0-9]+$ ]] || rchar=0
[[ "$wchar" =~ ^[0-9]+$ ]] || wchar=0
total=$(( rchar + wchar ))
traf_str=$(format_bytes "$total")
# Inline connection count — profile already loaded, skip redundant load_profile
local _conn_port="${_dp[LOCAL_PORT]:-}"
local _conn_olp="${_dp[OBFS_LOCAL_PORT]:-0}"
if [[ "$_conn_olp" =~ ^[0-9]+$ ]] && (( _conn_olp > 0 )); then
_conn_port="$_conn_olp"
fi
conns=$(_count_port_conns "$_conn_port" 2>/dev/null || true)
: "${conns:=0}"
# Record bandwidth for sparkline
_bw_record "$_dname" "$rchar" "$wchar"
local row
row=$(printf " %-12s %-5s ${GREEN}● %-6s${RESET} %-13s %-8s %-5s %-8s" \
"$_dname_display" "${dtype^^}" "ALIVE" "$daddr" "$traf_str" "$conns" "$up_str")
printf "${BOLD_GREEN}${RESET}%s" "$row"
local row_stripped
_strip_ansi_v row_stripped "$row"
local row_len=${#row_stripped}
local _row_pad=$(( width - row_len - 2 ))
if (( _row_pad < 0 )); then _row_pad=0; fi
printf '%*s' "$_row_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
# Sparkline row
local -a rx_deltas=()
local drx dtx
while read -r drx dtx; do
rx_deltas+=("$drx")
done < <(_bw_read_deltas "$_dname" 30)
if [[ ${#rx_deltas[@]} -gt 2 ]]; then
local spark_str
spark_str=$(_sparkline "${rx_deltas[@]}")
local last_rate="${rx_deltas[-1]:-0}"
local rate_str
rate_str=$(format_bytes "$last_rate")
# [1] Peak speed from deltas
local _peak=0 _dv
for _dv in "${rx_deltas[@]}"; do
if (( _dv > _peak )); then _peak=$_dv; fi
done
local peak_str
peak_str=$(format_bytes "$_peak")
local spark_row
spark_row=$(printf " ${DIM}%-12s${RESET} ${CYAN}%s${RESET} ${DIM}%s/s${RESET} ${DIM}${RESET} ${DIM}peak: %s/s${RESET}" \
"" "$spark_str" "$rate_str" "$peak_str")
printf "${BOLD_GREEN}${RESET}%s" "$spark_row"
local spark_stripped
_strip_ansi_v spark_stripped "$spark_row"
local spark_len=${#spark_stripped}
local _sp_pad=$(( width - spark_len - 2 ))
if (( _sp_pad < 0 )); then _sp_pad=0; fi
printf '%*s' "$_sp_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
fi
# [2] Route row — show hop chain
local _route_str=""
local _ssh_host="${_dp[SSH_HOST]:-}"
local _jump="${_dp[JUMP_HOSTS]:-}"
if [[ -n "$_jump" ]]; then
# Extract jump host IP (user@host:port → host)
local _jh="${_jump%%,*}" # first jump host
_jh="${_jh#*@}" # strip user@
_jh="${_jh%%:*}" # strip :port
_route_str="route: → ${_jh}${_ssh_host}"
elif [[ -n "$_ssh_host" ]]; then
_route_str="route: → ${_ssh_host}"
fi
if [[ -n "$_route_str" ]]; then
local _rr
_rr=$(printf " ${DIM}%-13s${RESET}${DIM}%s${RESET}" "" "$_route_str")
printf "${BOLD_GREEN}${RESET}%s" "$_rr"
local _rr_s
_strip_ansi_v _rr_s "$_rr"
local _rr_pad=$(( width - ${#_rr_s} - 2 ))
if (( _rr_pad < 0 )); then _rr_pad=0; fi
printf '%*s' "$_rr_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
fi
# [3] Auth + latency row
local _auth_method="interactive"
local _has_key=false _has_pass=false
if [[ -n "${_dp[IDENTITY_KEY]:-}" ]] && [[ -f "${_dp[IDENTITY_KEY]:-}" ]]; then _has_key=true; fi
if [[ -n "${_dp[SSH_PASSWORD]:-}" ]]; then _has_pass=true; fi
if [[ "$_has_key" == true ]] && [[ "$_has_pass" == true ]]; then _auth_method="key+pass"
elif [[ "$_has_key" == true ]]; then _auth_method="key"
elif [[ "$_has_pass" == true ]]; then _auth_method="password"
fi
local _lat_rating="" _lat_icon=""
if [[ "$_is_running" == true ]]; then
# Tunnel alive — skip slow TCP probe, show "active" directly
_lat_rating="active"
_lat_icon="${GREEN}▁▃▅▇${RESET}"
else
_dash_latency_cached "$_dname" "${_dp[SSH_HOST]:-}" "${_dp[SSH_PORT]:-22}"
local _lat_info="${_DASH_LATENCY[$_dname]:-unknown ?}"
_lat_rating="${_lat_info%% *}"
_lat_icon="${_lat_info#* }"
fi
local _obfs_ind=""
if [[ "${_dp[OBFS_MODE]:-none}" != "none" ]]; then
_obfs_ind="${DIM}${RESET}${GREEN}tls${RESET}"
if [[ -n "${_dp[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_dp[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then
_obfs_ind="${_obfs_ind}${DIM}+psk:${RESET}${GREEN}${_dp[OBFS_LOCAL_PORT]}${RESET}"
fi
elif [[ -n "${_dp[OBFS_LOCAL_PORT]:-}" ]] && [[ "${_dp[OBFS_LOCAL_PORT]:-0}" != "0" ]]; then
_obfs_ind="${DIM}${RESET}${GREEN}psk:${_dp[OBFS_LOCAL_PORT]}${RESET}"
fi
local _ar
_ar=$(printf " ${DIM}%-13s${RESET}${DIM}auth:${RESET}${BOLD}%s${RESET} ${DIM}${RESET}${DIM}lat:${RESET}%s${CYAN}%s${RESET} %s" \
"" "$_auth_method" "$_lat_rating" "$_lat_icon" "$_obfs_ind")
printf "${BOLD_GREEN}${RESET}%s" "$_ar"
local _ar_s
_strip_ansi_v _ar_s "$_ar"
local _ar_pad=$(( width - ${#_ar_s} - 2 ))
if (( _ar_pad < 0 )); then _ar_pad=0; fi
printf '%*s' "$_ar_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
# [4] Security row (only if dns or kill configured)
local _show_sec=false
if [[ "${_dp[DNS_LEAK_PROTECTION]:-}" == "true" ]] || [[ "${_dp[KILL_SWITCH]:-}" == "true" ]]; then
_show_sec=true
fi
if [[ "$_show_sec" == true ]]; then
local _dns_ind _kill_ind
if [[ "${_dp[DNS_LEAK_PROTECTION]:-}" == "true" ]]; then
_dns_ind="${GREEN}${RESET}"
else
_dns_ind="${DIM}${RESET}"
fi
if [[ "${_dp[KILL_SWITCH]:-}" == "true" ]]; then
_kill_ind="${GREEN}${RESET}"
else
_kill_ind="${DIM}${RESET}"
fi
local _sr
_sr=$(printf " ${DIM}%-13s${RESET}${DIM}security:${RESET} dns %s kill %s" "" "$_dns_ind" "$_kill_ind")
printf "${BOLD_GREEN}${RESET}%s" "$_sr"
local _sr_s
_strip_ansi_v _sr_s "$_sr"
local _sr_pad=$(( width - ${#_sr_s} - 2 ))
if (( _sr_pad < 0 )); then _sr_pad=0; fi
printf '%*s' "$_sr_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
fi
else
local row
row=$(printf " ${DIM}%-12s %-5s ■ %-6s %-13s %-8s %-5s %-8s${RESET}" \
"$_dname_display" "${dtype^^}" "STOP" "$daddr" "-" "-" "-")
printf "${BOLD_GREEN}${RESET}%s" "$row"
local row_stripped
_strip_ansi_v row_stripped "$row"
local row_len=${#row_stripped}
local _row_pad=$(( width - row_len - 2 ))
if (( _row_pad < 0 )); then _row_pad=0; fi
printf '%*s' "$_row_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
fi
done
fi
if [[ "$has_tunnels" != true ]]; then
local empty_msg=" No tunnels configured. Press 'c' to create one."
printf "${BOLD_GREEN}${RESET}${DIM}%s${RESET}" "$empty_msg"
printf '%*s' "$(( width - ${#empty_msg} - 2 ))" ""
printf "${BOLD_GREEN}${RESET}\n"
fi
# [5] System Resources section
printf "${BOLD_GREEN}"
_dash_box_mid "$width"
printf "${RESET}\n"
local _sysres_hdr
_sysres_hdr=$(printf " ${BOLD}System Resources${RESET}")
printf "${BOLD_GREEN}${RESET}%s" "$_sysres_hdr"
local _sysres_hdr_s
_strip_ansi_v _sysres_hdr_s "$_sysres_hdr"
local _sysres_hdr_pad=$(( width - ${#_sysres_hdr_s} - 2 ))
if (( _sysres_hdr_pad < 0 )); then _sysres_hdr_pad=0; fi
printf '%*s' "$_sysres_hdr_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
_dash_system_resources
local _sysres_data="$_DASH_SYSRES"
local _sysres_row
_sysres_row=$(printf " ${DIM}%s${RESET}" "$_sysres_data")
printf "${BOLD_GREEN}${RESET}%s" "$_sysres_row"
local _sysres_row_s
_strip_ansi_v _sysres_row_s "$_sysres_row"
local _sysres_row_pad=$(( width - ${#_sysres_row_s} - 2 ))
if (( _sysres_row_pad < 0 )); then _sysres_row_pad=0; fi
printf '%*s' "$_sysres_row_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
# [6] Active Connections section (current page only)
if [[ "$has_tunnels" == true ]]; then
local _any_alive=false _ac_lines=""
local _acname
for _acname in "${_dash_page_names[@]}"; do
[[ -z "${_dash_alive[$_acname]:-}" ]] && continue
_any_alive=true
local _ac_port="${_dash_port[$_acname]:-}"
if [[ -n "$_ac_port" ]]; then
# Use cached TLS port from tunnel row loop (no re-load)
local _ac_tls_port="${_dash_tls_port[$_acname]:-}"
local _ac_data _ac_label
if [[ -n "$_ac_tls_port" ]]; then
_ac_data=$(_dash_active_conns "$_ac_tls_port")
_ac_label=":${_ac_tls_port}"
else
_ac_data=$(_dash_active_conns "$_ac_port")
_ac_label=":${_ac_port}"
fi
_ac_lines+="${_acname}|${_ac_label}|${_ac_data}"$'\n'
fi
done
if [[ "$_any_alive" == true ]] && [[ -n "$_ac_lines" ]]; then
printf "${BOLD_GREEN}"
_dash_box_mid "$width"
printf "${RESET}\n"
local _ac_hdr
_ac_hdr=$(printf " ${BOLD}Active Connections${RESET}")
printf "${BOLD_GREEN}${RESET}%s" "$_ac_hdr"
local _ac_hdr_s
_strip_ansi_v _ac_hdr_s "$_ac_hdr"
local _ac_hdr_pad=$(( width - ${#_ac_hdr_s} - 2 ))
if (( _ac_hdr_pad < 0 )); then _ac_hdr_pad=0; fi
printf '%*s' "$_ac_hdr_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
while IFS='|' read -r _ac_n _ac_p _ac_d; do
[[ -z "$_ac_n" ]] && continue
local _ac_row
_ac_row=$(printf " ${DIM}%s %s${RESET} %s" "$_ac_n" "$_ac_p" "$_ac_d")
printf "${BOLD_GREEN}${RESET}%s" "$_ac_row"
local _ac_row_s
_strip_ansi_v _ac_row_s "$_ac_row"
local _ac_pad=$(( width - ${#_ac_row_s} - 2 ))
if (( _ac_pad < 0 )); then _ac_pad=0; fi
printf '%*s' "$_ac_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
done <<< "$_ac_lines"
fi
fi
# [7] Recent Log section (last lines per active tunnel, max 2 total)
if [[ "$has_tunnels" == true ]]; then
local _log_lines="" _log_count=0
local _lgname
for _lgname in "${!_dash_alive[@]}"; do
(( _log_count >= 2 )) && break
local _lf="${LOG_DIR}/${_lgname}.log"
if [[ -f "$_lf" ]]; then
local _ltail
_ltail=$(tail -20 "$_lf" 2>/dev/null) || true
while IFS= read -r _ll; do
[[ -z "$_ll" ]] && continue
(( _log_count >= 2 )) && break
# Skip normal SSH/SOCKS5 proxy noise
[[ "$_ll" == *"channel"*"open failed"* ]] && continue
[[ "$_ll" == *"Connection refused"* ]] && continue
[[ "$_ll" == *"Name or service not known"* ]] && continue
[[ "$_ll" == *"bind"*"Address already in use"* ]] && continue
[[ "$_ll" == *"cannot listen to"* ]] && continue
[[ "$_ll" == *"not request local forwarding"* ]] && continue
# Truncate long lines
local _ldisp="[${_lgname}] ${_ll}"
if (( ${#_ldisp} > width - 5 )); then
_ldisp="${_ldisp:0:$(( width - 8 ))}..."
fi
_log_lines+="${_ldisp}"$'\n'
((++_log_count))
done <<< "$_ltail"
fi
done
if (( _log_count > 0 )); then
printf "${BOLD_GREEN}"
_dash_box_mid "$width"
printf "${RESET}\n"
local _lg_hdr
_lg_hdr=$(printf " ${BOLD}Recent Log${RESET}")
printf "${BOLD_GREEN}${RESET}%s" "$_lg_hdr"
local _lg_hdr_s
_strip_ansi_v _lg_hdr_s "$_lg_hdr"
local _lg_hdr_pad=$(( width - ${#_lg_hdr_s} - 2 ))
if (( _lg_hdr_pad < 0 )); then _lg_hdr_pad=0; fi
printf '%*s' "$_lg_hdr_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
while IFS= read -r _lg_row; do
[[ -z "$_lg_row" ]] && continue
local _lgr
_lgr=$(printf " ${DIM}%s${RESET}" "$_lg_row")
printf "${BOLD_GREEN}${RESET}%s" "$_lgr"
local _lgr_s
_strip_ansi_v _lgr_s "$_lgr"
local _lgr_pad=$(( width - ${#_lgr_s} - 2 ))
if (( _lgr_pad < 0 )); then _lgr_pad=0; fi
printf '%*s' "$_lgr_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
done <<< "$_log_lines"
fi
fi
# Reconnect summary section
printf "${BOLD_GREEN}"
_dash_box_mid "$width"
printf "${RESET}\n"
local rc_header
rc_header=$(printf " ${BOLD}Reconnect Log${RESET}")
printf "${BOLD_GREEN}${RESET}%s" "$rc_header"
local rc_hdr_stripped
_strip_ansi_v rc_hdr_stripped "$rc_header"
printf '%*s' "$(( width - ${#rc_hdr_stripped} - 2 ))" ""
printf "${BOLD_GREEN}${RESET}\n"
if [[ -n "$profiles" ]]; then
local _any_rc=false _rc_shown=0
while IFS= read -r _rcname; do
[[ -z "$_rcname" ]] && continue
(( _rc_shown >= 2 )) && break
local rc_total rc_last
read -r rc_total rc_last <<< "$(_reconnect_stats "$_rcname")"
if (( rc_total > 0 )); then
_any_rc=true
(( ++_rc_shown )) || true
local rc_row
local _rc_display="$_rcname"
if (( ${#_rc_display} > 12 )); then _rc_display="${_rc_display:0:11}~"; fi
rc_row=$(printf " ${DIM}%-12s${RESET} reconnects: ${YELLOW}%d${RESET} last: ${DIM}%s${RESET}" \
"$_rc_display" "$rc_total" "$rc_last")
printf "${BOLD_GREEN}${RESET}%s" "$rc_row"
local rc_stripped
_strip_ansi_v rc_stripped "$rc_row"
local _rc_pad=$(( width - ${#rc_stripped} - 2 ))
if (( _rc_pad < 0 )); then _rc_pad=0; fi
printf '%*s' "$_rc_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
fi
done <<< "$profiles"
if [[ "$_any_rc" != true ]]; then
local no_rc=" ${DIM}No reconnections recorded${RESET}"
printf "${BOLD_GREEN}${RESET}%s" "$no_rc"
local no_rc_stripped
_strip_ansi_v no_rc_stripped "$no_rc"
printf '%*s' "$(( width - ${#no_rc_stripped} - 2 ))" ""
printf "${BOLD_GREEN}${RESET}\n"
fi
fi
# System info row
printf "${BOLD_GREEN}"
_dash_box_mid "$width"
printf "${RESET}\n"
local pub_ip="${_DASH_PUB_IP:-unknown}"
local _tg_indicator=""
if _telegram_enabled; then
_tg_indicator=" ${DIM}${RESET} ${DIM}TG:${RESET} ${GREEN}${RESET}"
fi
local _speed_indicator=""
if [[ -n "${_DASH_LAST_SPEED:-}" ]]; then
_speed_indicator=" ${DIM}${RESET} ${CYAN}${_DASH_LAST_SPEED}${RESET}"
fi
local _dash_ref_rate
_dash_ref_rate=$(config_get DASHBOARD_REFRESH 5)
local _dash_time_str
printf -v _dash_time_str '%(%H:%M:%S)T' -1
local sys_row
sys_row=$(printf " ${DIM}IP:${RESET} ${BOLD}%s${RESET} ${DIM}${RESET} ${DIM}Refresh:${RESET} %ss%s%s ${DIM}${RESET} ${DIM}%s${RESET}" \
"$pub_ip" "$_dash_ref_rate" "$_tg_indicator" "$_speed_indicator" "$_dash_time_str")
printf "${BOLD_GREEN}${RESET}%s" "$sys_row"
local sys_stripped
_strip_ansi_v sys_stripped "$sys_row"
local _sys_pad=$(( width - ${#sys_stripped} - 2 ))
if (( _sys_pad < 0 )); then _sys_pad=0; fi
printf '%*s' "$_sys_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
# Footer with controls
printf "${BOLD_GREEN}"
_dash_box_mid "$width"
printf "${RESET}\n"
local ctrl_row
local _pg_hint=""
if (( _DASH_TOTAL_PAGES > 1 )); then
_pg_hint=" ${DIM}${RESET} ${CYAN}1${RESET}-${CYAN}${_DASH_TOTAL_PAGES}${RESET}${DIM}=page${RESET}"
fi
ctrl_row=$(printf " ${CYAN}s${RESET}=start ${CYAN}t${RESET}=stop ${CYAN}r${RESET}=restart ${CYAN}c${RESET}=create ${CYAN}p${RESET}=speed ${CYAN}g${RESET}=qlty ${CYAN}q${RESET}=quit%s" "$_pg_hint")
printf "${BOLD_GREEN}${RESET}%s" "$ctrl_row"
local ctrl_stripped
_strip_ansi_v ctrl_stripped "$ctrl_row"
local _ctrl_pad=$(( width - ${#ctrl_stripped} - 2 ))
if (( _ctrl_pad < 0 )); then _ctrl_pad=0; fi
printf '%*s' "$_ctrl_pad" ""
printf "${BOLD_GREEN}${RESET}\n"
printf "${BOLD_GREEN}"
_dash_box_bottom "$width"
printf "${RESET}\n"
}
# ── Main dashboard loop ──
show_dashboard() {
local refresh
refresh=$(config_get DASHBOARD_REFRESH 5)
if (( refresh < 1 )); then refresh=1; fi
# Enter alternate screen buffer
tput smcup 2>/dev/null || true
# Hide cursor
tput civis 2>/dev/null || true
# Frame buffer for flicker-free rendering
local _frame_file="${TMPDIR:-/tmp}/tf-dash-$$"
: > "$_frame_file" 2>/dev/null || _frame_file="/tmp/tf-dash-$$"
: > "$_frame_file"
# Restore terminal on exit (normal return, Ctrl+C, or TERM)
local _dash_cleanup_done=false
_dash_exit() {
if [[ "$_dash_cleanup_done" == true ]]; then return 0; fi
_dash_cleanup_done=true
rm -f "$_frame_file" "${TMP_DIR}/tg_cmd.lock" 2>/dev/null || true
tput cnorm 2>/dev/null || true # show cursor
tput rmcup 2>/dev/null || true # leave alternate screen
trap - TSTP CONT
trap cleanup INT TERM HUP QUIT # restore global traps
}
trap '_dash_exit' RETURN
local _dash_interrupted=false
trap '_dash_exit; _dash_interrupted=true' INT
trap '_dash_exit; _dash_interrupted=true' TERM
trap '_dash_exit; _dash_interrupted=true' HUP
trap '_dash_exit; _dash_interrupted=true' QUIT
trap 'tput cnorm 2>/dev/null || true; tput rmcup 2>/dev/null || true' TSTP
trap 'tput smcup 2>/dev/null || true; tput civis 2>/dev/null || true' CONT
# Get local IP once (instant, no network call)
local _DASH_PUB_IP=""
_DASH_PUB_IP=$(hostname -I 2>/dev/null | awk '{print $1}') || true
if [[ -z "$_DASH_PUB_IP" ]]; then
_DASH_PUB_IP=$(ip -4 route get 1 2>/dev/null | grep -oE 'src [0-9.]+' | awk '{print $2}') || true
fi
: "${_DASH_PUB_IP:=unknown}"
# Cache terminal height — updated on SIGWINCH, not every frame
declare -g _DASH_TERM_H
_DASH_TERM_H=$(tput lines 2>/dev/null) || _DASH_TERM_H=40
trap '_DASH_TERM_H=$(tput lines 2>/dev/null) || _DASH_TERM_H=40' WINCH
local _dash_rot_count=0
local _dash_tg_count=0
while true; do
if [[ "$_dash_interrupted" == true ]]; then break; fi
# Periodic log rotation (~5 min at default 3s refresh)
if (( ++_dash_rot_count >= 100 )); then
rotate_logs 2>/dev/null || true
_dash_rot_count=0
fi
# Poll Telegram bot commands in background (~every 3 refreshes ≈ 9s)
if (( ++_dash_tg_count >= 3 )); then
_tg_process_commands_bg || true
_dash_tg_count=0
fi
# Buffered render: compute frame to file, then flush to screen in one shot
_dash_render > "$_frame_file" 2>/dev/null || true
tput cup 0 0 2>/dev/null || printf '\033[H'
cat "$_frame_file" 2>/dev/null
tput ed 2>/dev/null || true # clear stale content below frame
# Non-blocking read with timeout for refresh
local key=""
read -rsn1 -t "$refresh" key </dev/tty || true
_drain_esc key
case "$key" in
q|Q)
return 0 ;;
s|S)
# Start a tunnel (mini-selector)
tput cnorm 2>/dev/null || true
tput rmcup 2>/dev/null || true
_menu_start_tunnel || true
_press_any_key || true
tput smcup 2>/dev/null || true
tput civis 2>/dev/null || true
;;
t|T)
tput cnorm 2>/dev/null || true
tput rmcup 2>/dev/null || true
_menu_stop_tunnel || true
_press_any_key || true
tput smcup 2>/dev/null || true
tput civis 2>/dev/null || true
;;
r|R)
# Restart all running tunnels
tput cnorm 2>/dev/null || true
tput rmcup 2>/dev/null || true
local _dr_profiles
_dr_profiles=$(list_profiles)
if [[ -n "$_dr_profiles" ]]; then
while IFS= read -r _dr_name; do
[[ -z "$_dr_name" ]] && continue
if is_tunnel_running "$_dr_name"; then
restart_tunnel "$_dr_name" || true
fi
done <<< "$_dr_profiles"
fi
_press_any_key || true
tput smcup 2>/dev/null || true
tput civis 2>/dev/null || true
;;
c|C)
tput cnorm 2>/dev/null || true
tput rmcup 2>/dev/null || true
wizard_create_profile || true
_press_any_key || true
tput smcup 2>/dev/null || true
tput civis 2>/dev/null || true
;;
p|P)
tput cnorm 2>/dev/null || true
tput rmcup 2>/dev/null || true
_speed_test || true
_press_any_key || true
tput smcup 2>/dev/null || true
tput civis 2>/dev/null || true
;;
g|G)
tput cnorm 2>/dev/null || true
tput rmcup 2>/dev/null || true
printf "\n ${BOLD}Connection Quality Check${RESET}\n\n" >/dev/tty
local _gq_profiles
_gq_profiles=$(list_profiles)
if [[ -n "$_gq_profiles" ]]; then
while IFS= read -r _gq_name; do
[[ -z "$_gq_name" ]] && continue
local _gq_host
_gq_host=$(get_profile_field "$_gq_name" "SSH_HOST" 2>/dev/null) || true
local _gq_port
_gq_port=$(get_profile_field "$_gq_name" "SSH_PORT" 2>/dev/null) || true
: "${_gq_port:=22}"
if [[ -n "$_gq_host" ]]; then
local _gq_rating
_gq_rating=$(_connection_quality "$_gq_host" "$_gq_port" 2>/dev/null) || true
: "${_gq_rating:=unknown}"
printf " %-16s %s %s → %s:%s\n" "$_gq_name" "$(_quality_icon "$_gq_rating")" "$_gq_rating" "$_gq_host" "$_gq_port" >/dev/tty
fi
done <<< "$_gq_profiles"
else
printf " ${DIM}No profiles configured${RESET}\n" >/dev/tty
fi
printf "\n" >/dev/tty
_press_any_key || true
tput smcup 2>/dev/null || true
tput civis 2>/dev/null || true
;;
\[|,)
# Previous page
if (( _DASH_PAGE > 0 )); then _DASH_PAGE=$(( _DASH_PAGE - 1 )); fi
;;
\]|.)
# Next page
if (( _DASH_PAGE < _DASH_TOTAL_PAGES - 1 )); then _DASH_PAGE=$(( _DASH_PAGE + 1 )); fi
;;
[1-9])
# Jump to page N
local _target_pg=$(( key - 1 ))
if (( _target_pg < _DASH_TOTAL_PAGES )); then
_DASH_PAGE=$_target_pg
fi
;;
*) true ;;
esac
done
}
# ============================================================================
# INSTALLER
# ============================================================================
install_tunnelforge() {
show_banner >/dev/tty
log_info "Installing ${APP_NAME} v${VERSION}..."
check_root "install" || return 1
detect_os
init_directories || { log_error "Failed to create directories"; return 1; }
# Copy script
local script_path dest
script_path="$(cd "$(dirname "$0")" 2>/dev/null && pwd || pwd)/$(basename "$0")"
dest="${INSTALL_DIR}/tunnelforge.sh"
if [[ "$script_path" != "$dest" ]]; then
cp "$script_path" "$dest"
chmod +x "$dest"
log_success "Installed to ${dest}"
fi
if ln -sf "$dest" "$BIN_LINK" 2>/dev/null; then
log_success "Created symlink: ${BIN_LINK}"
else
log_warn "Could not create symlink: ${BIN_LINK}"
fi
check_dependencies || log_warn "Some dependencies could not be installed"
if [[ ! -f "$MAIN_CONFIG" ]]; then
if save_settings; then
log_success "Created config: ${MAIN_CONFIG}"
else
log_warn "Could not create config file — using defaults"
fi
fi
printf "\n"
printf "${BOLD_GREEN}"
printf " ╔══════════════════════════════════════════════════════════╗\n"
printf " ║ %s installed successfully! ║\n" "$APP_NAME"
printf " ╠══════════════════════════════════════════════════════════╣\n"
printf " ║ ║\n"
printf " ║ Commands: ║\n"
printf " ║ tunnelforge menu Interactive menu ║\n"
printf " ║ tunnelforge create Create a tunnel ║\n"
printf " ║ tunnelforge help Show all commands ║\n"
printf " ║ ║\n"
printf " ╚══════════════════════════════════════════════════════════╝\n"
printf "${RESET}\n"
# Offer to launch interactive menu
local _ans=""
printf " Launch interactive menu now? [y/N] " >/dev/tty
read -rsn1 _ans </dev/tty || true
printf "\n" >/dev/tty
if [[ "$_ans" == "y" || "$_ans" == "Y" ]]; then
detect_os; load_settings
show_menu || true
fi
}
# ============================================================================
# CLI ENTRY POINT
# ============================================================================
is_installed() {
[[ -f "${INSTALL_DIR}/tunnelforge.sh" && -f "$MAIN_CONFIG" ]]
}
cli_main() {
local command="${1:-}"
shift 2>/dev/null || true
# Ensure runtime directories exist for all commands
init_directories 2>/dev/null || true
case "$command" in
# ── Tunnel commands ──
start)
[[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge start <name>"; return 1; }
validate_profile_name "$1" || { log_error "Invalid profile name"; return 1; }
detect_os; load_settings; start_tunnel "$1" ;;
stop)
[[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge stop <name>"; return 1; }
validate_profile_name "$1" || { log_error "Invalid profile name"; return 1; }
load_settings; stop_tunnel "$1" || true ;;
restart)
[[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge restart <name>"; return 1; }
validate_profile_name "$1" || { log_error "Invalid profile name"; return 1; }
detect_os; load_settings; restart_tunnel "$1" || true ;;
start-all)
detect_os; load_settings; start_all_tunnels || true ;;
stop-all)
load_settings; stop_all_tunnels || true ;;
status)
load_settings; show_status || true ;;
# ── Profile commands ──
list|ls)
load_settings
local profiles
profiles=$(list_profiles)
if [[ -z "$profiles" ]]; then
log_info "No profiles found. Run 'tunnelforge create' to get started."
else
printf "\n${BOLD}Tunnel Profiles:${RESET}\n"
print_line "─" 50
while IFS= read -r _ls_name; do
local _ls_ptype _ls_status
_ls_ptype=$(get_profile_field "$_ls_name" "TUNNEL_TYPE" 2>/dev/null) || true
if is_tunnel_running "$_ls_name"; then
_ls_status="${GREEN}● running${RESET}"
else
_ls_status="${DIM}■ stopped${RESET}"
fi
printf " %-20s %-10s %b\n" "$_ls_name" "${_ls_ptype:-?}" "$_ls_status"
done <<< "$profiles"
printf "\n"
fi ;;
create|new)
detect_os; load_settings
setup_wizard || true ;;
delete)
[[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge delete <name>"; return 1; }
validate_profile_name "$1" || { log_error "Invalid profile name"; return 1; }
load_settings
if confirm_action "Delete profile '${1}'?"; then
delete_profile "$1" || true
fi ;;
# ── Display commands ──
dashboard|dash)
if ! : >/dev/tty 2>/dev/null; then
log_error "Dashboard requires an interactive terminal (/dev/tty not available)"
return 1
fi
load_settings
show_dashboard || true ;;
menu)
detect_os; load_settings
show_menu ;;
logs)
load_settings
local target="${1:-}"
if [[ -n "$target" ]]; then
validate_profile_name "$target" || { log_error "Invalid profile name"; return 1; }
local lf; lf=$(_log_file "$target")
if [[ -f "$lf" ]]; then
tail -f "$lf" || true
else
log_error "No logs for '${target}'"
fi
else
local ml="${LOG_DIR}/${APP_NAME_LOWER}.log"
if [[ -f "$ml" ]]; then
tail -f "$ml" || true
else
log_info "No logs found"
fi
fi ;;
# ── Security commands ──
audit|security)
load_settings; security_audit || true ;;
key-gen)
load_settings
local ktype="${1:-ed25519}"
case "$ktype" in
ed25519|rsa|ecdsa) ;;
*) log_error "Unsupported key type '${ktype}' (use: ed25519, rsa, ecdsa)"; return 1 ;;
esac
generate_ssh_key "$ktype" "${HOME}/.ssh/id_${ktype}" || true ;;
key-deploy)
[[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge key-deploy <name>"; return 1; }
validate_profile_name "$1" || { log_error "Invalid profile name"; return 1; }
load_settings; deploy_ssh_key "$1" || true ;;
fingerprint)
[[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge fingerprint <host> [port]"; return 1; }
load_settings; verify_host_fingerprint "$1" "${2:-22}" || true ;;
# ── Telegram commands ──
telegram|tg)
load_settings
local tg_action="${1:-status}"
case "$tg_action" in
setup) telegram_setup || true ;;
test) telegram_test || true ;;
status) telegram_status || true ;;
send) shift; [[ -z "$*" ]] && { log_error "Usage: tunnelforge telegram send <message>"; return 1; }; _telegram_send "$*" || { log_error "Send failed (is Telegram configured?)"; return 1; } ;;
report) telegram_send_status || true ;;
share) shift; telegram_share_client "${1:-}" || true ;;
*) log_error "Usage: tunnelforge telegram [setup|test|status|send|report|share]"; return 1 ;;
esac ;;
# ── System commands ──
health)
load_settings; security_audit || true ;;
server-setup)
detect_os; load_settings
server_setup "${1:-}" || true ;;
obfs-setup|obfuscate)
[[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge obfs-setup <profile>"; return 1; }
detect_os; load_settings
_obfs_setup_stunnel "$1" || true ;;
client-config)
[[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge client-config <profile>"; return 1; }
detect_os; load_settings
local -A _cc_prof=()
load_profile "$1" _cc_prof || { log_error "Cannot load profile '$1'"; return 1; }
if [[ -z "${_cc_prof[OBFS_LOCAL_PORT]:-}" ]] || [[ "${_cc_prof[OBFS_LOCAL_PORT]:-0}" == "0" ]]; then
log_error "Profile '$1' has no inbound TLS configured"
printf "${DIM}Enable it in the wizard or set OBFS_LOCAL_PORT and OBFS_PSK in the profile.${RESET}\n"
return 1
fi
_obfs_show_client_config "$1" _cc_prof || true ;;
client-script)
[[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge client-script <profile> [output-file]"; return 1; }
detect_os; load_settings
local -A _cs_prof=()
load_profile "$1" _cs_prof || { log_error "Cannot load profile '$1'"; return 1; }
_obfs_generate_client_script "$1" _cs_prof "${2:-}" || true
_obfs_generate_client_script_win "$1" _cs_prof "" || true ;;
service)
[[ -z "${1:-}" ]] && { log_error "Usage: tunnelforge service <name> [enable|disable|status|remove]"; return 1; }
validate_profile_name "$1" || { log_error "Invalid profile name"; return 1; }
detect_os; load_settings
local svc_name="$1" svc_action="${2:-}"
case "$svc_action" in
enable) enable_service "$svc_name" || true ;;
disable) disable_service "$svc_name" || true ;;
status) service_status "$svc_name" || true ;;
remove) remove_service "$svc_name" || true ;;
"") generate_service "$svc_name" || true ;;
*) log_error "Unknown action: ${svc_action}"; return 1 ;;
esac ;;
backup)
load_settings
backup_tunnelforge || true ;;
restore)
load_settings
restore_tunnelforge "${1:-}" || true ;;
uninstall)
detect_os; load_settings
uninstall_tunnelforge ;;
install)
install_tunnelforge ;;
update)
detect_os; load_settings
update_tunnelforge ;;
# ── Info commands ──
version|-v|--version)
show_version ;;
help|-h|--help)
show_help ;;
# ── Default: first run or menu ──
"")
if is_installed; then
detect_os; load_settings
show_menu
else
install_tunnelforge
if is_installed; then
_press_any_key
detect_os; load_settings
show_menu
fi
fi ;;
*)
log_error "Unknown command: ${command}"
log_info "Run 'tunnelforge help' for available commands"
return 1 ;;
esac
}
# ============================================================================
# MAIN
# ============================================================================
main() { cli_main "$@"; }
main "$@"