#!/bin/bash # ═══════════════════════════════════════════════════════════════ # MTProxyMax v1.0 — The Ultimate Telegram Proxy Manager # Copyright (c) 2026 SamNet Technologies # https://github.com/SamNet-dev/MTProxyMax # # Engine: telemt 3.x (Rust+Tokio) — https://github.com/telemt/telemt # License: MIT # ═══════════════════════════════════════════════════════════════ set -eo pipefail export LC_NUMERIC=C # ── Section 1: Initialization ──────────────────────────────── VERSION="1.0.0" SCRIPT_NAME="mtproxymax" INSTALL_DIR="/opt/mtproxymax" CONFIG_DIR="${INSTALL_DIR}/mtproxy" SETTINGS_FILE="${INSTALL_DIR}/settings.conf" SECRETS_FILE="${INSTALL_DIR}/secrets.conf" STATS_DIR="${INSTALL_DIR}/relay_stats" UPSTREAMS_FILE="${INSTALL_DIR}/upstreams.conf" BACKUP_DIR="${INSTALL_DIR}/backups" CONTAINER_NAME="mtproxymax" DOCKER_IMAGE_BASE="mtproxymax-telemt" TELEMT_REPO="telemt/telemt" TELEMT_MIN_VERSION="3.0.3" TELEMT_COMMIT="cf71703" # Pinned: v3.0.3 — ME autofallback, flush optimization, IPv6 parser GITHUB_REPO="SamNet-dev/MTProxyMax" REGISTRY_IMAGE="ghcr.io/samnet-dev/mtproxymax-telemt" # Bash version check if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ]; then echo "ERROR: MTProxyMax requires bash 4.2+. Current: ${BASH_VERSION:-unknown}" >&2 exit 1 fi # Temp file tracking declare -a _TEMP_FILES=() _cleanup() { for f in "${_TEMP_FILES[@]}"; do rm -f "$f" 2>/dev/null done } trap _cleanup EXIT _mktemp() { local dir="${1:-${TMPDIR:-/tmp}}" local tmp tmp=$(mktemp "${dir}/.mtproxymax.XXXXXX") || return 1 chmod 600 "$tmp" _TEMP_FILES+=("$tmp") echo "$tmp" } # ── Section 2: Constants & Defaults ────────────────────────── # Colors readonly RED='\033[0;31m' readonly GREEN='\033[0;32m' readonly YELLOW='\033[0;33m' readonly BLUE='\033[0;34m' readonly MAGENTA='\033[0;35m' readonly CYAN='\033[0;36m' readonly WHITE='\033[1;37m' readonly BOLD='\033[1m' readonly DIM='\033[2m' readonly ITALIC='\033[3m' readonly UNDERLINE='\033[4m' readonly BLINK='\033[5m' readonly REVERSE='\033[7m' readonly NC='\033[0m' # Bright colors for retro feel readonly BRIGHT_GREEN='\033[1;32m' readonly BRIGHT_CYAN='\033[1;36m' readonly BRIGHT_YELLOW='\033[1;33m' readonly BRIGHT_RED='\033[1;31m' readonly BRIGHT_MAGENTA='\033[1;35m' readonly BRIGHT_WHITE='\033[1;37m' readonly BG_BLACK='\033[40m' readonly BG_BLUE='\033[44m' # Box drawing readonly BOX_TL='┌' BOX_TR='┐' BOX_BL='└' BOX_BR='┘' readonly BOX_H='─' BOX_V='│' BOX_LT='├' BOX_RT='┤' readonly BOX_DTL='╔' BOX_DTR='╗' BOX_DBL='╚' BOX_DBR='╝' readonly BOX_DH='═' BOX_DV='║' BOX_DLT='╠' BOX_DRT='╣' # Status symbols readonly SYM_OK='●' readonly SYM_ARROW='►' readonly SYM_UP='↑' readonly SYM_DOWN='↓' readonly SYM_CHECK='✓' readonly SYM_CROSS='✗' readonly SYM_WARN='!' readonly SYM_STAR='★' # Default configuration PROXY_PORT=443 PROXY_METRICS_PORT=9090 PROXY_DOMAIN="cloudflare.com" PROXY_CONCURRENCY=8192 PROXY_CPUS="" PROXY_MEMORY="" AD_TAG="" BLOCKLIST_COUNTRIES="" MASKING_ENABLED="true" MASKING_HOST="" MASKING_PORT=443 TELEGRAM_ENABLED="false" TELEGRAM_BOT_TOKEN="" TELEGRAM_CHAT_ID="" TELEGRAM_INTERVAL=6 TELEGRAM_ALERTS_ENABLED="true" TELEGRAM_SERVER_LABEL="MTProxyMax" AUTO_UPDATE_ENABLED="true" # Terminal width TERM_WIDTH=$(tput cols 2>/dev/null || echo 60) [ "$TERM_WIDTH" -gt 80 ] && TERM_WIDTH=80 [ "$TERM_WIDTH" -lt 40 ] && TERM_WIDTH=60 # ── Section 3: TUI Drawing Functions ──────────────────────── # Get string display length (strips ANSI escape codes) _strlen() { local clean="$1" local esc=$'\033' # Normalize literal \033 (from single-quoted color vars) to real ESC byte clean="${clean//$'\\033'/$esc}" # Strip ANSI escape sequences in pure bash (no subprocesses) while [[ "$clean" == *"${esc}["* ]]; do local before="${clean%%${esc}\[*}" local rest="${clean#*${esc}\[}" local after="${rest#*m}" [ "$rest" = "$after" ] && break clean="${before}${after}" done echo "${#clean}" } # Repeat a character n times (pure bash, no subprocesses) _repeat() { local char="$1" count="$2" str printf -v str '%*s' "$count" '' printf '%s' "${str// /$char}" } # Draw a horizontal line draw_line() { local width="${1:-$TERM_WIDTH}" char="${2:-$BOX_H}" color="${3:-$DIM}" echo -e "${color}$(_repeat "$char" "$width")${NC}" } # Draw top border of a box draw_box_top() { local width="${1:-$TERM_WIDTH}" local inner=$((width - 2)) echo -e "${CYAN}${BOX_TL}$(_repeat "$BOX_H" "$inner")${BOX_TR}${NC}" } # Draw bottom border of a box draw_box_bottom() { local width="${1:-$TERM_WIDTH}" local inner=$((width - 2)) echo -e "${CYAN}${BOX_BL}$(_repeat "$BOX_H" "$inner")${BOX_BR}${NC}" } # Draw separator in a box draw_box_sep() { local width="${1:-$TERM_WIDTH}" local inner=$((width - 2)) echo -e "${CYAN}${BOX_LT}$(_repeat "$BOX_H" "$inner")${BOX_RT}${NC}" } # Draw a line inside a box with auto-padding draw_box_line() { local text="$1" width="${2:-$TERM_WIDTH}" local inner=$((width - 2)) local text_len text_len=$(_strlen "$text") local padding=$((inner - text_len - 1)) [ "$padding" -lt 0 ] && padding=0 echo -e "${CYAN}${BOX_V}${NC} ${text}$(_repeat ' ' "$padding")${CYAN}${BOX_V}${NC}" } # Draw an empty line inside a box draw_box_empty() { local width="${1:-$TERM_WIDTH}" draw_box_line "" "$width" } # Draw a centered line inside a box draw_box_center() { local text="$1" width="${2:-$TERM_WIDTH}" local inner=$((width - 2)) local text_len text_len=$(_strlen "$text") local left_pad=$(( (inner - text_len) / 2 )) local right_pad=$((inner - text_len - left_pad)) [ "$left_pad" -lt 0 ] && left_pad=0 [ "$right_pad" -lt 0 ] && right_pad=0 echo -e "${CYAN}${BOX_V}${NC}$(_repeat ' ' "$left_pad")${text}$(_repeat ' ' "$right_pad")${CYAN}${BOX_V}${NC}" } # Draw section header with retro styling draw_header() { local title="$1" echo "" echo -e " ${BRIGHT_CYAN}${SYM_ARROW} ${BOLD}${title}${NC}" echo -e " ${DIM}$(_repeat '─' $((${#title} + 2)))${NC}" } # Draw a status indicator draw_status() { local status="$1" label="${2:-}" case "$status" in running|up|true|enabled|active) echo -e "${BRIGHT_GREEN}${SYM_OK}${NC} ${GREEN}${label:-RUNNING}${NC}" ;; stopped|down|false|disabled|inactive) echo -e "${BRIGHT_RED}${SYM_OK}${NC} ${RED}${label:-STOPPED}${NC}" ;; starting|pending|warning) echo -e "${BRIGHT_YELLOW}${SYM_OK}${NC} ${YELLOW}${label:-STARTING}${NC}" ;; *) echo -e "${DIM}${SYM_OK}${NC} ${DIM}${label:-UNKNOWN}${NC}" ;; esac } # Draw a progress bar draw_progress() { local current="$1" total="$2" width="${3:-20}" label="${4:-}" local filled empty pct if [ "$total" -gt 0 ] 2>/dev/null; then pct=$(( (current * 100) / total )) filled=$(( (current * width) / total )) else pct=0 filled=0 fi [ "$filled" -gt "$width" ] && filled=$width empty=$((width - filled)) local bar_color="$GREEN" [ "$pct" -ge 70 ] && bar_color="$YELLOW" [ "$pct" -ge 90 ] && bar_color="$RED" local bar="${bar_color}$(_repeat '█' "$filled")${DIM}$(_repeat '░' "$empty")${NC}" if [ -n "$label" ]; then echo -e " ${label} [${bar}] ${pct}%" else echo -e " [${bar}] ${pct}%" fi } # Draw a sparkline from array of values draw_sparkline() { local -a values=("$@") local chars=('▁' '▂' '▃' '▄' '▅' '▆' '▇' '█') local max=0 for v in "${values[@]}"; do [ "$v" -gt "$max" ] 2>/dev/null && max=$v done [ "$max" -eq 0 ] && max=1 local result="" for v in "${values[@]}"; do local idx=$(( (v * 7) / max )) [ "$idx" -gt 7 ] && idx=7 result+="${chars[$idx]}" done echo -e "${BRIGHT_CYAN}${result}${NC}" } # Prompt for menu choice with retro styling read_choice() { local prompt="${1:-choice}" local default="${2:-}" echo -en "\n ${DIM}Enter ${prompt,,}${NC}" >&2 [ -n "$default" ] && echo -en " ${DIM}[${default}]${NC}" >&2 echo -en "${DIM}:${NC} " >&2 local choice read -r choice [ -z "$choice" ] && choice="$default" echo "$choice" } # Typing effect for retro banner typing_effect() { local text="$1" delay="${2:-0.01}" local i for (( i=0; i<${#text}; i++ )); do echo -n "${text:$i:1}" sleep "$delay" 2>/dev/null || true done echo "" } # Press any key prompt press_any_key() { echo "" echo -en " ${DIM}Press any key to continue...${NC}" read -rsn1 echo "" } # Clear screen and show mini header clear_screen() { clear 2>/dev/null || printf '\033[2J\033[H' echo -e "${BRIGHT_CYAN}${BOLD} MTProxyMax${NC} ${DIM}v${VERSION}${NC}" echo -e " ${DIM}$(_repeat '─' 30)${NC}" } # Show the big ASCII banner show_banner() { echo -e "${BRIGHT_CYAN}" cat << 'BANNER_ART' ███╗ ███╗████████╗██████╗ ██████╗ ██████╗ ████╗ ████║╚══██╔══╝██╔══██╗██╔══██╗██╔═══██╗ ██╔████╔██║ ██║ ██████╔╝██████╔╝██║ ██║ ██║╚██╔╝██║ ██║ ██╔═══╝ ██╔══██╗██║ ██║ ██║ ╚═╝ ██║ ██║ ██║ ██║ ██║╚██████╔╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ BANNER_ART cat << BANNER ╔═════════════════ M A X ══════════════════════╗ ║ The Ultimate Telegram Proxy Manager v${VERSION}$(printf '%*s' $((7 - ${#VERSION})) '')║ ║ SamNet Technologies ║ ╚══════════════════════════════════════════════╝ BANNER echo -e "${NC}" } # ── Section 4: Utility Functions ───────────────────────────── log_info() { echo -e " ${BLUE}[i]${NC} $1"; } log_success() { echo -e " ${GREEN}[${SYM_CHECK}]${NC} $1"; } log_warn() { echo -e " ${YELLOW}[${SYM_WARN}]${NC} $1" >&2; } log_error() { echo -e " ${RED}[${SYM_CROSS}]${NC} $1" >&2; } # Format bytes to human-readable format_bytes() { local bytes=$1 [[ "$bytes" =~ ^[0-9]+$ ]] || bytes=0 if [ -z "$bytes" ] || [ "$bytes" = "0" ]; then echo "0 B" return fi if [ "$bytes" -lt 1024 ] 2>/dev/null; then echo "${bytes} B" elif [ "$bytes" -lt 1048576 ] 2>/dev/null; then echo "$(awk -v b="$bytes" 'BEGIN {printf "%.1f", b/1024}') KB" elif [ "$bytes" -lt 1073741824 ] 2>/dev/null; then echo "$(awk -v b="$bytes" 'BEGIN {printf "%.2f", b/1048576}') MB" else echo "$(awk -v b="$bytes" 'BEGIN {printf "%.2f", b/1073741824}') GB" fi } # Format seconds to human-readable duration format_duration() { local secs=$1 [[ "$secs" =~ ^-?[0-9]+$ ]] || secs=0 [ "$secs" -lt 1 ] && { echo "0s"; return; } local days=$((secs / 86400)) local hours=$(( (secs % 86400) / 3600 )) local mins=$(( (secs % 3600) / 60 )) if [ "$days" -gt 0 ]; then echo "${days}d ${hours}h ${mins}m" elif [ "$hours" -gt 0 ]; then echo "${hours}h ${mins}m" elif [ "$mins" -gt 0 ]; then echo "${mins}m" else echo "${secs}s" fi } # Format large numbers format_number() { local num=$1 [ -z "$num" ] || [ "$num" = "0" ] && { echo "0"; return; } if [ "$num" -ge 1000000 ] 2>/dev/null; then echo "$(awk -v n="$num" 'BEGIN {printf "%.1f", n/1000000}')M" elif [ "$num" -ge 1000 ] 2>/dev/null; then echo "$(awk -v n="$num" 'BEGIN {printf "%.1f", n/1000}')K" else echo "$num" fi } # Escape markdown special characters escape_md() { local text="$1" text="${text//\\/\\\\}" text="${text//\*/\\*}" text="${text//_/\\_}" text="${text//\`/\\\`}" text="${text//\[/\\[}" text="${text//\]/\\]}" echo "$text" } # Get public IP address _PUBLIC_IP_CACHE="" _PUBLIC_IP_CACHE_AGE=0 get_public_ip() { local now; now=$(date +%s) # Return cached IP if less than 5 minutes old if [ -n "$_PUBLIC_IP_CACHE" ] && [ $(( now - _PUBLIC_IP_CACHE_AGE )) -lt 300 ]; then echo "$_PUBLIC_IP_CACHE" return 0 fi local ip="" ip=$(curl -s --max-time 3 https://api.ipify.org 2>/dev/null) || ip=$(curl -s --max-time 3 https://ifconfig.me 2>/dev/null) || ip=$(curl -s --max-time 3 https://icanhazip.com 2>/dev/null) || ip="" if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || [[ "$ip" =~ : ]]; then _PUBLIC_IP_CACHE="$ip" _PUBLIC_IP_CACHE_AGE=$now echo "$ip" fi } # Validate port number validate_port() { local port="$1" [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ] } # Check if port is available is_port_available() { local port="$1" if command -v ss &>/dev/null; then ! ss -tln 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${port}$" elif command -v netstat &>/dev/null; then ! netstat -tln 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${port}$" else return 0 fi } # Check if running as root check_root() { if [ "$(id -u)" -ne 0 ]; then log_error "MTProxyMax must be run as root" echo -e " ${DIM}Try: sudo $0 $*${NC}" exit 1 fi } # Detect OS detect_os() { if [ -f /etc/os-release ]; then . /etc/os-release case "$ID" in ubuntu|debian|pop|linuxmint|kali) echo "debian" ;; centos|rhel|fedora|rocky|alma|oracle) echo "rhel" ;; alpine) echo "alpine" ;; *) echo "unknown" ;; esac elif [ -f /etc/debian_version ]; then echo "debian" elif [ -f /etc/redhat-release ]; then echo "rhel" else echo "unknown" fi } # Check dependencies check_dependencies() { local missing=() command -v curl &>/dev/null || missing+=("curl") command -v awk &>/dev/null || missing+=("awk") command -v openssl &>/dev/null || missing+=("openssl") if [ ${#missing[@]} -gt 0 ]; then log_warn "Missing dependencies: ${missing[*]}" log_info "Installing..." local os os=$(detect_os) case "$os" in debian) apt-get update -qq && apt-get install -y -qq "${missing[@]}" ;; rhel) yum install -y -q "${missing[@]}" ;; alpine) apk add --no-cache "${missing[@]}" ;; esac fi } # Parse human-readable byte sizes (e.g., 5G, 500M, 1T) to raw bytes parse_human_bytes() { local input="${1:-0}" input="${input^^}" # uppercase local num unit if [[ "$input" =~ ^([0-9]+(\.[0-9]+)?)[[:space:]]*(B|K|KB|M|MB|G|GB|T|TB)?$ ]]; then num="${BASH_REMATCH[1]}" unit="${BASH_REMATCH[3]:-B}" elif [[ "$input" =~ ^[0-9]+$ ]]; then echo "$input" return 0 else echo "0" return 1 fi case "$unit" in B) awk -v n="$num" 'BEGIN {printf "%d", n}' ;; K|KB) awk -v n="$num" 'BEGIN {printf "%d", n * 1024}' ;; M|MB) awk -v n="$num" 'BEGIN {printf "%d", n * 1048576}' ;; G|GB) awk -v n="$num" 'BEGIN {printf "%d", n * 1073741824}' ;; T|TB) awk -v n="$num" 'BEGIN {printf "%d", n * 1099511627776}' ;; *) echo "0"; return 1 ;; esac } # Compare version strings (returns 0 if $1 >= $2) version_gte() { [ "$(printf '%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ] } # Validate a domain name (reject TOML/shell-unsafe characters) validate_domain() { local d="$1" [ -z "$d" ] && return 1 # Only allow valid hostname chars: letters, digits, dots, hyphens [[ "$d" =~ ^[a-zA-Z0-9.-]+$ ]] && [[ "$d" =~ \. ]] } # ── Section 5: Settings Persistence ────────────────────────── save_settings() { mkdir -p "$INSTALL_DIR" local tmp tmp=$(_mktemp) || { log_error "Cannot create temp file"; return 1; } cat > "$tmp" << SETTINGS_EOF # MTProxyMax Settings — v${VERSION} # Generated: $(date -u '+%Y-%m-%d %H:%M:%S UTC') # DO NOT EDIT MANUALLY — use 'mtproxymax' to change settings # Proxy Configuration PROXY_PORT='${PROXY_PORT}' PROXY_METRICS_PORT='${PROXY_METRICS_PORT}' PROXY_DOMAIN='${PROXY_DOMAIN}' PROXY_CONCURRENCY='${PROXY_CONCURRENCY}' PROXY_CPUS='${PROXY_CPUS}' PROXY_MEMORY='${PROXY_MEMORY}' # Ad-Tag (from @MTProxyBot) AD_TAG='${AD_TAG}' # Geo-Blocking BLOCKLIST_COUNTRIES='${BLOCKLIST_COUNTRIES}' # Traffic Masking MASKING_ENABLED='${MASKING_ENABLED}' MASKING_HOST='${MASKING_HOST}' MASKING_PORT='${MASKING_PORT}' # Telegram Integration TELEGRAM_ENABLED='${TELEGRAM_ENABLED}' TELEGRAM_BOT_TOKEN='${TELEGRAM_BOT_TOKEN}' TELEGRAM_CHAT_ID='${TELEGRAM_CHAT_ID}' TELEGRAM_INTERVAL='${TELEGRAM_INTERVAL}' TELEGRAM_ALERTS_ENABLED='${TELEGRAM_ALERTS_ENABLED}' TELEGRAM_SERVER_LABEL='${TELEGRAM_SERVER_LABEL}' # Auto-Update AUTO_UPDATE_ENABLED='${AUTO_UPDATE_ENABLED}' SETTINGS_EOF chmod 600 "$tmp" mv "$tmp" "$SETTINGS_FILE" } load_settings() { [ -f "$SETTINGS_FILE" ] || return 0 # Safe whitelist-based parsing (no source/eval) while IFS= read -r line; do # Skip comments and empty lines [[ "$line" =~ ^[[:space:]]*# ]] && continue [[ "$line" =~ ^[[:space:]]*$ ]] && continue # Match KEY='VALUE' or KEY="VALUE" or KEY=VALUE if [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)=\'([^\']*)\'$ ]]; then local key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}" elif [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)=\"([^\"]*)\"$ ]]; then local key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}" elif [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)=([^[:space:]]*)$ ]]; then local key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}" else continue fi # Whitelist of allowed keys case "$key" in PROXY_PORT|PROXY_METRICS_PORT|PROXY_DOMAIN|PROXY_CONCURRENCY|\ PROXY_CPUS|PROXY_MEMORY|AD_TAG|BLOCKLIST_COUNTRIES|\ MASKING_ENABLED|MASKING_HOST|MASKING_PORT|\ TELEGRAM_ENABLED|TELEGRAM_BOT_TOKEN|TELEGRAM_CHAT_ID|\ TELEGRAM_INTERVAL|TELEGRAM_ALERTS_ENABLED|TELEGRAM_SERVER_LABEL|\ AUTO_UPDATE_ENABLED) printf -v "$key" '%s' "$val" ;; esac done < "$SETTINGS_FILE" # Post-load validation for numeric fields [[ "$PROXY_PORT" =~ ^[0-9]+$ ]] && [ "$PROXY_PORT" -ge 1 ] && [ "$PROXY_PORT" -le 65535 ] || PROXY_PORT=443 [[ "$PROXY_METRICS_PORT" =~ ^[0-9]+$ ]] && [ "$PROXY_METRICS_PORT" -ge 1 ] && [ "$PROXY_METRICS_PORT" -le 65535 ] || PROXY_METRICS_PORT=9090 [[ "$MASKING_PORT" =~ ^[0-9]+$ ]] && [ "$MASKING_PORT" -ge 1 ] && [ "$MASKING_PORT" -le 65535 ] || MASKING_PORT=443 [[ "$PROXY_CONCURRENCY" =~ ^[0-9]+$ ]] || PROXY_CONCURRENCY=8192 [[ "$TELEGRAM_INTERVAL" =~ ^[0-9]+$ ]] || TELEGRAM_INTERVAL=6 [[ "$TELEGRAM_CHAT_ID" =~ ^-?[0-9]+$ ]] || TELEGRAM_CHAT_ID="" } # Save secrets database save_secrets() { mkdir -p "$INSTALL_DIR" local tmp tmp=$(_mktemp) || { log_error "Cannot create temp file"; return 1; } echo "# MTProxyMax Secrets Database — v${VERSION}" > "$tmp" echo "# Format: LABEL|SECRET|CREATED_TS|ENABLED|MAX_CONNS|MAX_IPS|QUOTA_BYTES|EXPIRES" >> "$tmp" echo "# DO NOT EDIT MANUALLY — use 'mtproxymax secret' commands" >> "$tmp" if [ ${#SECRETS_LABELS[@]} -gt 0 ]; then local i for i in "${!SECRETS_LABELS[@]}"; do echo "${SECRETS_LABELS[$i]}|${SECRETS_KEYS[$i]}|${SECRETS_CREATED[$i]}|${SECRETS_ENABLED[$i]}|${SECRETS_MAX_CONNS[$i]:-0}|${SECRETS_MAX_IPS[$i]:-0}|${SECRETS_QUOTA[$i]:-0}|${SECRETS_EXPIRES[$i]:-0}" >> "$tmp" done fi chmod 600 "$tmp" mv "$tmp" "$SECRETS_FILE" } # Arrays for secret management declare -a SECRETS_LABELS=() declare -a SECRETS_KEYS=() declare -a SECRETS_CREATED=() declare -a SECRETS_ENABLED=() declare -a SECRETS_MAX_CONNS=() declare -a SECRETS_MAX_IPS=() declare -a SECRETS_QUOTA=() declare -a SECRETS_EXPIRES=() # Load secrets database load_secrets() { SECRETS_LABELS=() SECRETS_KEYS=() SECRETS_CREATED=() SECRETS_ENABLED=() SECRETS_MAX_CONNS=() SECRETS_MAX_IPS=() SECRETS_QUOTA=() SECRETS_EXPIRES=() if [ -f "$SECRETS_FILE" ]; then while IFS='|' read -r label secret created enabled max_conns max_ips quota expires; do [[ "$label" =~ ^[[:space:]]*# ]] && continue [[ "$label" =~ ^[[:space:]]*$ ]] && continue [ -z "$secret" ] && continue # Validate label and secret format on load [[ "$label" =~ ^[a-zA-Z0-9_-]+$ ]] || continue [[ "$secret" =~ ^[0-9a-fA-F]{32}$ ]] || continue # Validate numeric fields on load local _mc="${max_conns:-0}" _mi="${max_ips:-0}" _q="${quota:-0}" _en="${enabled:-true}" [[ "$_mc" =~ ^[0-9]+$ ]] || _mc="0" [[ "$_mi" =~ ^[0-9]+$ ]] || _mi="0" [[ "$_q" =~ ^[0-9]+$ ]] || _q="0" [ "$_en" != "true" ] && [ "$_en" != "false" ] && _en="true" SECRETS_LABELS+=("$label") SECRETS_KEYS+=("$secret") local _cr="${created:-$(date +%s)}" [[ "$_cr" =~ ^[0-9]+$ ]] || _cr=$(date +%s) SECRETS_CREATED+=("$_cr") SECRETS_ENABLED+=("$_en") SECRETS_MAX_CONNS+=("$_mc") SECRETS_MAX_IPS+=("$_mi") SECRETS_QUOTA+=("$_q") local _ex="${expires:-0}" if [ "$_ex" != "0" ] && ! [[ "$_ex" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}(T[0-9:Z+.-]+)?$ ]]; then _ex="0" fi SECRETS_EXPIRES+=("$_ex") done < "$SECRETS_FILE" fi # Always load upstreams alongside secrets (both feed into config) load_upstreams } # Arrays for upstream management declare -a UPSTREAM_NAMES=() declare -a UPSTREAM_TYPES=() declare -a UPSTREAM_ADDRS=() declare -a UPSTREAM_USERS=() declare -a UPSTREAM_PASSES=() declare -a UPSTREAM_WEIGHTS=() declare -a UPSTREAM_IFACES=() declare -a UPSTREAM_ENABLED=() # Save upstreams database save_upstreams() { mkdir -p "$INSTALL_DIR" local tmp tmp=$(_mktemp) || { log_error "Cannot create temp file"; return 1; } echo "# MTProxyMax Upstreams Database — v${VERSION}" > "$tmp" echo "# Format: NAME|TYPE|ADDR|USER|PASS|WEIGHT|IFACE|ENABLED" >> "$tmp" echo "# DO NOT EDIT MANUALLY — use 'mtproxymax upstream' commands" >> "$tmp" if [ ${#UPSTREAM_NAMES[@]} -gt 0 ]; then local i for i in "${!UPSTREAM_NAMES[@]}"; do echo "${UPSTREAM_NAMES[$i]}|${UPSTREAM_TYPES[$i]}|${UPSTREAM_ADDRS[$i]}|${UPSTREAM_USERS[$i]}|${UPSTREAM_PASSES[$i]}|${UPSTREAM_WEIGHTS[$i]}|${UPSTREAM_IFACES[$i]}|${UPSTREAM_ENABLED[$i]}" >> "$tmp" done fi chmod 600 "$tmp" mv "$tmp" "$UPSTREAMS_FILE" } # Load upstreams database load_upstreams() { UPSTREAM_NAMES=() UPSTREAM_TYPES=() UPSTREAM_ADDRS=() UPSTREAM_USERS=() UPSTREAM_PASSES=() UPSTREAM_WEIGHTS=() UPSTREAM_IFACES=() UPSTREAM_ENABLED=() if [ ! -f "$UPSTREAMS_FILE" ]; then # Default: single direct upstream UPSTREAM_NAMES+=("direct") UPSTREAM_TYPES+=("direct") UPSTREAM_ADDRS+=("") UPSTREAM_USERS+=("") UPSTREAM_PASSES+=("") UPSTREAM_WEIGHTS+=("10") UPSTREAM_IFACES+=("") UPSTREAM_ENABLED+=("true") return 0 fi while IFS='|' read -r name type addr user pass weight iface enabled; do [[ "$name" =~ ^[[:space:]]*# ]] && continue [[ "$name" =~ ^[[:space:]]*$ ]] && continue # Validate name format on load [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]] || continue # Backward compat: old 7-col format has enabled in col7 (no iface) if [ "$iface" = "true" ] || [ "$iface" = "false" ]; then enabled="$iface" iface="" fi # Validate type, weight, and enabled on load local _type="${type:-direct}" case "$_type" in direct|socks5|socks4) ;; *) _type="direct" ;; esac local _weight="${weight:-10}" [[ "$_weight" =~ ^[0-9]+$ ]] && [ "$_weight" -ge 1 ] && [ "$_weight" -le 100 ] || _weight="10" local _enabled="${enabled:-true}" [ "$_enabled" != "true" ] && [ "$_enabled" != "false" ] && _enabled="true" # Skip socks entries with no address [ "$_type" != "direct" ] && [ -z "${addr:-}" ] && continue UPSTREAM_NAMES+=("$name") UPSTREAM_TYPES+=("$_type") UPSTREAM_ADDRS+=("${addr:-}") UPSTREAM_USERS+=("${user:-}") UPSTREAM_PASSES+=("${pass:-}") UPSTREAM_WEIGHTS+=("$_weight") UPSTREAM_IFACES+=("${iface:-}") UPSTREAM_ENABLED+=("$_enabled") done < "$UPSTREAMS_FILE" # Ensure at least one entry exists if [ ${#UPSTREAM_NAMES[@]} -eq 0 ]; then UPSTREAM_NAMES+=("direct") UPSTREAM_TYPES+=("direct") UPSTREAM_ADDRS+=("") UPSTREAM_USERS+=("") UPSTREAM_PASSES+=("") UPSTREAM_WEIGHTS+=("10") UPSTREAM_IFACES+=("") UPSTREAM_ENABLED+=("true") fi } # ── Section 6: Docker Management ───────────────────────────── install_docker() { if command -v docker &>/dev/null; then log_success "Docker is already installed" return 0 fi log_info "Installing Docker..." local os os=$(detect_os) case "$os" in debian) curl -fsSL https://get.docker.com | sh ;; rhel) if command -v dnf &>/dev/null; then dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null || yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo dnf install -y docker-ce docker-ce-cli containerd.io else yum install -y yum-utils yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo yum install -y docker-ce docker-ce-cli containerd.io fi ;; alpine) apk add --no-cache docker docker-compose ;; *) log_error "Unsupported OS. Please install Docker manually." return 1 ;; esac systemctl enable docker 2>/dev/null || rc-update add docker default 2>/dev/null || true systemctl start docker 2>/dev/null || service docker start 2>/dev/null || true if command -v docker &>/dev/null; then log_success "Docker installed successfully" else log_error "Docker installation failed" return 1 fi } wait_for_docker() { local retries=10 while [ $retries -gt 0 ]; do docker info &>/dev/null && return 0 sleep 1 retries=$((retries - 1)) done log_error "Docker is not responding" return 1 } # Build telemt Docker image from latest GitHub release binary build_telemt_image() { local force="${1:-false}" local commit="${TELEMT_COMMIT}" local version="3.0.3-${commit}" # Skip if image already exists (unless forced) if [ "$force" != "true" ] && docker image inspect "${DOCKER_IMAGE_BASE}:${version}" &>/dev/null; then return 0 fi # Strategy 1: Pull pre-built image from registry (fast — seconds) log_info "Pulling pre-built telemt v${version}..." if docker pull "${REGISTRY_IMAGE}:${version}" 2>/dev/null; then docker tag "${REGISTRY_IMAGE}:${version}" "${DOCKER_IMAGE_BASE}:${version}" docker tag "${DOCKER_IMAGE_BASE}:${version}" "${DOCKER_IMAGE_BASE}:latest" 2>/dev/null || true log_success "Pulled telemt v${version}" mkdir -p "$INSTALL_DIR" echo "$version" > "${INSTALL_DIR}/.telemt_version" return 0 fi # Strategy 2: Pull latest from registry if exact version not found if [ "$force" != "source" ]; then log_info "Exact version not in registry, trying latest..." if docker pull "${REGISTRY_IMAGE}:latest" 2>/dev/null; then docker tag "${REGISTRY_IMAGE}:latest" "${DOCKER_IMAGE_BASE}:${version}" docker tag "${DOCKER_IMAGE_BASE}:${version}" "${DOCKER_IMAGE_BASE}:latest" 2>/dev/null || true log_success "Pulled telemt (latest)" mkdir -p "$INSTALL_DIR" echo "$version" > "${INSTALL_DIR}/.telemt_version" return 0 fi fi # Strategy 3: Build from source (slow first time, cached after) log_warn "Pre-built image not available, compiling from source..." log_info "Includes: Prometheus metrics, ME perf fixes, critical ME bug fixes" local build_dir build_dir=$(mktemp -d "${TMPDIR:-/tmp}/mtproxymax-build.XXXXXX") cat > "${build_dir}/Dockerfile" << 'DOCKERFILE_EOF' FROM rust:1-bookworm AS builder ARG TELEMT_REPO ARG TELEMT_COMMIT RUN apt-get update && apt-get install -y --no-install-recommends git && \ rm -rf /var/lib/apt/lists/* RUN git clone "https://github.com/${TELEMT_REPO}.git" /build WORKDIR /build RUN git checkout "${TELEMT_COMMIT}" RUN cargo build --release && \ strip target/release/telemt 2>/dev/null || true && \ cp target/release/telemt /telemt FROM debian:bookworm-slim RUN apt-get update && \ apt-get install -y --no-install-recommends ca-certificates && \ rm -rf /var/lib/apt/lists/* COPY --from=builder /telemt /usr/local/bin/telemt RUN chmod +x /usr/local/bin/telemt STOPSIGNAL SIGINT ENTRYPOINT ["telemt"] DOCKERFILE_EOF log_info "Compiling from source (first build takes a few minutes)..." if docker build \ --build-arg "TELEMT_REPO=${TELEMT_REPO}" \ --build-arg "TELEMT_COMMIT=${commit}" \ -t "${DOCKER_IMAGE_BASE}:${version}" "$build_dir"; then docker tag "${DOCKER_IMAGE_BASE}:${version}" "${DOCKER_IMAGE_BASE}:latest" 2>/dev/null || true log_success "Built telemt v${version} from source" mkdir -p "$INSTALL_DIR" echo "$version" > "${INSTALL_DIR}/.telemt_version" else log_error "Source build failed — ensure Docker has enough memory (2GB+)" rm -rf "$build_dir" return 1 fi rm -rf "$build_dir" return 0 } # Get installed telemt version get_telemt_version() { # Try saved version file first local ver ver=$(cat "${INSTALL_DIR}/.telemt_version" 2>/dev/null) if [ -n "$ver" ]; then echo "$ver"; return; fi # Fallback: check Docker image tags ver=$(docker images --format '{{.Tag}}' "${DOCKER_IMAGE_BASE}" 2>/dev/null | grep -E '^[0-9]+\.' | head -1) if [ -n "$ver" ]; then echo "$ver"; return; fi echo "unknown" } # Get the versioned Docker image tag for telemt get_docker_image() { local ver ver=$(get_telemt_version) if [ "$ver" = "unknown" ]; then echo "${DOCKER_IMAGE_BASE}:latest" else echo "${DOCKER_IMAGE_BASE}:${ver}" fi } # Check if telemt has a newer release available check_telemt_update() { local current current=$(get_telemt_version) # Strip commit suffix for comparison (3.0.3-cf71703 → 3.0.3) local current_base="${current%%-*}" local latest latest=$(curl -sL --max-time 10 \ "https://api.github.com/repos/${TELEMT_REPO}/releases/latest" \ 2>/dev/null | grep '"tag_name"' | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+[^"]*') # Only notify if release is strictly newer than our base version if [ -n "$latest" ] && [ "$latest" != "$current_base" ] && version_gte "$latest" "$current_base"; then echo "$latest" return 0 fi return 1 } # ── Section 7: Telemt Engine ───────────────────────────────── # Generate a random 32-char hex secret generate_secret() { openssl rand -hex 16 2>/dev/null || { # Fallback head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n' | head -c 32 } } # Convert domain to hex for ee-prefixed FakeTLS secret domain_to_hex() { printf '%s' "$1" | od -An -tx1 | tr -d ' \n' } # Build the full FakeTLS secret for sharing (ee + raw_secret + domain_hex) build_faketls_secret() { local raw_secret="$1" domain="${2:-$PROXY_DOMAIN}" local domain_hex domain_hex=$(domain_to_hex "$domain") echo "ee${raw_secret}${domain_hex}" } # Generate telemt config.toml generate_telemt_config() { mkdir -p "$CONFIG_DIR" chmod 700 "$CONFIG_DIR" local domain="${PROXY_DOMAIN:-cloudflare.com}" local mask_enabled="${MASKING_ENABLED:-true}" local mask_host="${MASKING_HOST:-$domain}" local mask_port="${MASKING_PORT:-443}" local ad_tag="${AD_TAG:-}" local port="${PROXY_PORT:-443}" local metrics_port="${PROXY_METRICS_PORT:-9090}" # Build config in a temp file for atomic write (same-dir for atomic mv) local tmp tmp=$(_mktemp "$CONFIG_DIR") || { log_error "Cannot create temp file for config"; return 1; } cat > "$tmp" << TOML_EOF # MTProxyMax — telemt configuration # Generated: $(date -u '+%Y-%m-%d %H:%M:%S UTC') [general] prefer_ipv6 = false fast_mode = true use_middle_proxy = true log_level = "normal" $([ -n "$ad_tag" ] && echo "ad_tag = \"$ad_tag\"" || echo "# ad_tag = \"\" # Get from @MTProxyBot") [general.modes] classic = false secure = false tls = true [general.links] show = [$(get_enabled_labels_quoted)] # public_host = "" # public_port = ${port} [server] port = ${port} listen_addr_ipv4 = "0.0.0.0" listen_addr_ipv6 = "::" metrics_port = ${metrics_port} metrics_whitelist = ["127.0.0.1", "::1"] [timeouts] client_handshake = 15 tg_connect = 10 client_keepalive = 60 client_ack = 300 [censorship] tls_domain = "${domain}" mask = ${mask_enabled} mask_port = ${mask_port} $([ "$mask_enabled" = "true" ] && [ -n "$mask_host" ] && echo "mask_host = \"${mask_host}\"") fake_cert_len = 2048 # Note: geo-blocking is enforced at the host firewall level (iptables/nftables), # not via telemt config. See: mtproxymax info -> Geo-Blocking [access] replay_check_len = 65536 replay_window_secs = 1800 ignore_time_skew = false [access.users] TOML_EOF # Append enabled secrets local i for i in "${!SECRETS_LABELS[@]}"; do [ "${SECRETS_ENABLED[$i]}" = "true" ] || continue echo "${SECRETS_LABELS[$i]} = \"${SECRETS_KEYS[$i]}\"" >> "$tmp" done # Append per-user limits (only sections with non-zero values) local has_conns=false has_ips=false has_quota=false has_expires=false for i in "${!SECRETS_LABELS[@]}"; do [ "${SECRETS_ENABLED[$i]}" = "true" ] || continue [ "${SECRETS_MAX_CONNS[$i]:-0}" != "0" ] && has_conns=true [ "${SECRETS_MAX_IPS[$i]:-0}" != "0" ] && has_ips=true [ "${SECRETS_QUOTA[$i]:-0}" != "0" ] && has_quota=true [ "${SECRETS_EXPIRES[$i]:-0}" != "0" ] && has_expires=true done if $has_conns; then echo "" >> "$tmp" echo "[access.user_max_tcp_conns]" >> "$tmp" for i in "${!SECRETS_LABELS[@]}"; do [ "${SECRETS_ENABLED[$i]}" = "true" ] || continue [ "${SECRETS_MAX_CONNS[$i]:-0}" != "0" ] || continue echo "${SECRETS_LABELS[$i]} = ${SECRETS_MAX_CONNS[$i]}" >> "$tmp" done fi if $has_ips; then echo "" >> "$tmp" echo "[access.user_max_unique_ips]" >> "$tmp" for i in "${!SECRETS_LABELS[@]}"; do [ "${SECRETS_ENABLED[$i]}" = "true" ] || continue [ "${SECRETS_MAX_IPS[$i]:-0}" != "0" ] || continue echo "${SECRETS_LABELS[$i]} = ${SECRETS_MAX_IPS[$i]}" >> "$tmp" done fi if $has_quota; then echo "" >> "$tmp" echo "[access.user_data_quota]" >> "$tmp" for i in "${!SECRETS_LABELS[@]}"; do [ "${SECRETS_ENABLED[$i]}" = "true" ] || continue [ "${SECRETS_QUOTA[$i]:-0}" != "0" ] || continue echo "${SECRETS_LABELS[$i]} = ${SECRETS_QUOTA[$i]}" >> "$tmp" done fi if $has_expires; then echo "" >> "$tmp" echo "[access.user_expirations]" >> "$tmp" for i in "${!SECRETS_LABELS[@]}"; do [ "${SECRETS_ENABLED[$i]}" = "true" ] || continue [ "${SECRETS_EXPIRES[$i]:-0}" != "0" ] || continue echo "${SECRETS_LABELS[$i]} = \"${SECRETS_EXPIRES[$i]}\"" >> "$tmp" done fi # Append enabled upstream entries for i in "${!UPSTREAM_NAMES[@]}"; do [ "${UPSTREAM_ENABLED[$i]}" = "true" ] || continue echo "" >> "$tmp" echo "[[upstreams]]" >> "$tmp" echo "type = \"${UPSTREAM_TYPES[$i]}\"" >> "$tmp" echo "weight = ${UPSTREAM_WEIGHTS[$i]}" >> "$tmp" if [ "${UPSTREAM_TYPES[$i]}" != "direct" ] && [ -n "${UPSTREAM_ADDRS[$i]}" ]; then echo "address = \"${UPSTREAM_ADDRS[$i]}\"" >> "$tmp" fi # SOCKS5 uses username/password; SOCKS4 uses user_id if [ "${UPSTREAM_TYPES[$i]}" = "socks5" ]; then [ -n "${UPSTREAM_USERS[$i]}" ] && echo "username = \"${UPSTREAM_USERS[$i]}\"" >> "$tmp" [ -n "${UPSTREAM_PASSES[$i]}" ] && echo "password = \"${UPSTREAM_PASSES[$i]}\"" >> "$tmp" elif [ "${UPSTREAM_TYPES[$i]}" = "socks4" ] && [ -n "${UPSTREAM_USERS[$i]}" ]; then echo "user_id = \"${UPSTREAM_USERS[$i]}\"" >> "$tmp" fi # Bind outbound to specific IP if [ -n "${UPSTREAM_IFACES[$i]}" ]; then echo "interface = \"${UPSTREAM_IFACES[$i]}\"" >> "$tmp" fi done chmod 644 "$tmp" mv "$tmp" "${CONFIG_DIR}/config.toml" } # Get comma-separated quoted list of enabled labels for config get_enabled_labels_quoted() { local result="" first=true local i for i in "${!SECRETS_LABELS[@]}"; do [ "${SECRETS_ENABLED[$i]}" = "true" ] || continue if $first; then result="\"${SECRETS_LABELS[$i]}\"" first=false else result+=", \"${SECRETS_LABELS[$i]}\"" fi done echo "$result" } # ── Traffic Tracking ────────────────────────────────────────── # Primary: Prometheus /metrics endpoint (telemt built from HEAD) # Fallback: iptables byte counters (if metrics unavailable) IPTABLES_CHAIN="MTPROXY_STATS" _TRACKED_PORT="" _METRICS_CACHE="" _METRICS_CACHE_AGE=0 # Fetch Prometheus metrics (cached for 2 seconds to avoid hammering) _fetch_metrics() { local now now=$(date +%s) if [ -n "$_METRICS_CACHE" ] && [ $((now - _METRICS_CACHE_AGE)) -lt 2 ]; then echo "$_METRICS_CACHE" return 0 fi _METRICS_CACHE=$(curl -s --max-time 2 "http://127.0.0.1:${PROXY_METRICS_PORT:-9090}/metrics" 2>/dev/null) _METRICS_CACHE_AGE=$now [ -n "$_METRICS_CACHE" ] && echo "$_METRICS_CACHE" && return 0 return 1 } # Set up iptables tracking rules (fallback when Prometheus unavailable) # Idempotent — safe to call repeatedly, auto-handles port changes traffic_tracking_setup() { local port="${PROXY_PORT:-443}" if [ "$_TRACKED_PORT" = "$port" ] && \ iptables -C "$IPTABLES_CHAIN" -p tcp --dport "$port" -m comment --comment "mtproxymax-in" 2>/dev/null; then return 0 fi iptables -N "$IPTABLES_CHAIN" 2>/dev/null || true iptables -F "$IPTABLES_CHAIN" 2>/dev/null iptables -A "$IPTABLES_CHAIN" -p tcp --dport "$port" -m comment --comment "mtproxymax-in" 2>/dev/null iptables -A "$IPTABLES_CHAIN" -p tcp --sport "$port" -m comment --comment "mtproxymax-out" 2>/dev/null iptables -C INPUT -j "$IPTABLES_CHAIN" -m comment --comment "mtproxymax" 2>/dev/null || \ iptables -I INPUT -j "$IPTABLES_CHAIN" -m comment --comment "mtproxymax" 2>/dev/null iptables -C OUTPUT -j "$IPTABLES_CHAIN" -m comment --comment "mtproxymax" 2>/dev/null || \ iptables -I OUTPUT -j "$IPTABLES_CHAIN" -m comment --comment "mtproxymax" 2>/dev/null _TRACKED_PORT="$port" } # Remove all iptables tracking rules traffic_tracking_teardown() { local i for i in 1 2 3; do iptables -D INPUT -j "$IPTABLES_CHAIN" -m comment --comment "mtproxymax" 2>/dev/null || true iptables -D OUTPUT -j "$IPTABLES_CHAIN" -m comment --comment "mtproxymax" 2>/dev/null || true iptables -D INPUT -j "$IPTABLES_CHAIN" 2>/dev/null || true iptables -D OUTPUT -j "$IPTABLES_CHAIN" 2>/dev/null || true done iptables -F "$IPTABLES_CHAIN" 2>/dev/null || true iptables -X "$IPTABLES_CHAIN" 2>/dev/null || true _TRACKED_PORT="" } # Read current traffic counters # Returns: bytes_in bytes_out connections get_proxy_stats() { if ! is_proxy_running; then echo "0 0 0" return fi # Try Prometheus first local m if m=$(_fetch_metrics); then local bi bo conns bi=$(echo "$m" | awk '/^telemt_user_octets_from_client\{/{s+=$NF}END{printf "%.0f",s}') bo=$(echo "$m" | awk '/^telemt_user_octets_to_client\{/{s+=$NF}END{printf "%.0f",s}') conns=$(echo "$m" | awk '/^telemt_user_connections_current\{/{s+=$NF}END{printf "%.0f",s}') echo "${bi:-0} ${bo:-0} ${conns:-0}" return fi # Fallback: iptables local port="${PROXY_PORT:-443}" if [ "$_TRACKED_PORT" != "$port" ] || \ ! iptables -C "$IPTABLES_CHAIN" -p tcp --dport "$port" -m comment --comment "mtproxymax-in" 2>/dev/null; then traffic_tracking_setup fi local stats stats=$(iptables -L "$IPTABLES_CHAIN" -v -n -x 2>/dev/null) local bytes_in bytes_out bytes_in=$(echo "$stats" | awk '/mtproxymax-in/ {print $2; exit}') bytes_out=$(echo "$stats" | awk '/mtproxymax-out/ {print $2; exit}') local connections connections=$(ss -tn state established 2>/dev/null | grep -c ":${port} " || echo "0") echo "${bytes_in:-0} ${bytes_out:-0} ${connections:-0}" } # Get per-user stats from Prometheus # Returns: bytes_in bytes_out connections get_user_stats() { local user="$1" local m if m=$(_fetch_metrics); then local i o c i=$(echo "$m" | awk -v u="$user" '$0 ~ "^telemt_user_octets_from_client\\{.*user=\"" u "\"" {print $NF}') o=$(echo "$m" | awk -v u="$user" '$0 ~ "^telemt_user_octets_to_client\\{.*user=\"" u "\"" {print $NF}') c=$(echo "$m" | awk -v u="$user" '$0 ~ "^telemt_user_connections_current\\{.*user=\"" u "\"" {print $NF}') echo "${i:-0} ${o:-0} ${c:-0}" return fi echo "0 0 0" } # ── Section 8: Secret Management ───────────────────────────── # Add a new secret secret_add() { local label="$1" custom_secret="${2:-}" # Validate label if [ -z "$label" ]; then log_error "Label is required" return 1 fi if ! [[ "$label" =~ ^[a-zA-Z0-9_-]+$ ]]; then log_error "Label must be alphanumeric (a-z, 0-9, _, -)" return 1 fi if [ ${#label} -gt 32 ]; then log_error "Label must be 32 characters or less" return 1 fi # Check for duplicate local i for i in "${!SECRETS_LABELS[@]}"; do if [ "${SECRETS_LABELS[$i]}" = "$label" ]; then log_error "Secret with label '${label}' already exists" return 1 fi done # Generate or use provided secret local raw_secret if [ -n "$custom_secret" ]; then raw_secret="$custom_secret" else raw_secret=$(generate_secret) fi if [ -z "$raw_secret" ] || ! [[ "$raw_secret" =~ ^[0-9a-fA-F]{32}$ ]]; then log_error "Secret must be exactly 32 hex characters" return 1 fi # Add to arrays SECRETS_LABELS+=("$label") SECRETS_KEYS+=("$raw_secret") SECRETS_CREATED+=("$(date +%s)") SECRETS_ENABLED+=("true") SECRETS_MAX_CONNS+=("0") SECRETS_MAX_IPS+=("0") SECRETS_QUOTA+=("0") SECRETS_EXPIRES+=("0") # Save save_secrets # Restart if running (run_proxy_container regenerates config) if is_proxy_running; then restart_proxy_container fi local full_secret full_secret=$(build_faketls_secret "$raw_secret") local server_ip server_ip=$(get_public_ip) log_success "Secret '${label}' created" echo "" echo -e " ${BOLD}Proxy Link:${NC}" echo -e " ${CYAN}tg://proxy?server=${server_ip}&port=${PROXY_PORT}&secret=${full_secret}${NC}" echo "" echo -e " ${BOLD}Web Link:${NC}" echo -e " ${CYAN}https://t.me/proxy?server=${server_ip}&port=${PROXY_PORT}&secret=${full_secret}${NC}" echo "" } # Remove a secret secret_remove() { local label="$1" force="${2:-false}" local idx=-1 local i for i in "${!SECRETS_LABELS[@]}"; do if [ "${SECRETS_LABELS[$i]}" = "$label" ]; then idx=$i break fi done if [ $idx -eq -1 ]; then log_error "Secret '${label}' not found" return 1 fi # Prevent removing the last secret if [ ${#SECRETS_LABELS[@]} -le 1 ]; then log_error "Cannot remove the last secret — proxy needs at least one" return 1 fi # Confirm unless forced or non-interactive if [ "$force" != "true" ]; then if [ ! -t 0 ]; then force="true" else echo -e " ${YELLOW}Remove secret '${label}'? Users with this key will be disconnected.${NC}" echo -en " ${BOLD}Type 'yes' to confirm:${NC} " local confirm read -r confirm [ "$confirm" != "yes" ] && { log_info "Cancelled"; return 0; } fi fi # Remove from arrays (rebuild without the index) local -a new_labels=() new_keys=() new_created=() new_enabled=() local -a new_max_conns=() new_max_ips=() new_quota=() new_expires=() for i in "${!SECRETS_LABELS[@]}"; do [ "$i" -eq "$idx" ] && continue new_labels+=("${SECRETS_LABELS[$i]}") new_keys+=("${SECRETS_KEYS[$i]}") new_created+=("${SECRETS_CREATED[$i]}") new_enabled+=("${SECRETS_ENABLED[$i]}") new_max_conns+=("${SECRETS_MAX_CONNS[$i]:-0}") new_max_ips+=("${SECRETS_MAX_IPS[$i]:-0}") new_quota+=("${SECRETS_QUOTA[$i]:-0}") new_expires+=("${SECRETS_EXPIRES[$i]:-0}") done SECRETS_LABELS=("${new_labels[@]}") SECRETS_KEYS=("${new_keys[@]}") SECRETS_CREATED=("${new_created[@]}") SECRETS_ENABLED=("${new_enabled[@]}") SECRETS_MAX_CONNS=("${new_max_conns[@]}") SECRETS_MAX_IPS=("${new_max_ips[@]}") SECRETS_QUOTA=("${new_quota[@]}") SECRETS_EXPIRES=("${new_expires[@]}") save_secrets if is_proxy_running; then restart_proxy_container fi log_success "Secret '${label}' removed" } # List all secrets secret_list() { load_secrets if [ ${#SECRETS_LABELS[@]} -eq 0 ]; then log_info "No secrets configured" echo -e " ${DIM}Run: mtproxymax secret add