From 5ee6e0fdf9c7e906ba12ab0d044a6a93cb2e1b35 Mon Sep 17 00:00:00 2001 From: SamNet-dev Date: Tue, 17 Feb 2026 01:06:48 -0600 Subject: [PATCH] =?UTF-8?q?Initial=20release=20=E2=80=94=20MTProxyMax=20v1?= =?UTF-8?q?.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 3 + .github/workflows/build-engine.yml | 76 + LICENSE | 21 + install.sh | 7 + mtproxymax.sh | 5757 ++++++++++++++++++++++++++++ 5 files changed, 5864 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/build-engine.yml create mode 100644 LICENSE create mode 100644 install.sh create mode 100644 mtproxymax.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5db39a8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto +*.sh text eol=lf +LICENSE text eol=lf diff --git a/.github/workflows/build-engine.yml b/.github/workflows/build-engine.yml new file mode 100644 index 0000000..aa2f9d1 --- /dev/null +++ b/.github/workflows/build-engine.yml @@ -0,0 +1,76 @@ +name: Build Telemt Engine + +on: + workflow_dispatch: + inputs: + telemt_commit: + description: 'Telemt commit hash to build from' + required: true + default: '43990c9' + version_tag: + description: 'Version tag (e.g. 3.0.0-43990c9)' + required: true + default: '3.0.0-43990c9' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: mtproxymax-telemt + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Set up QEMU (for ARM builds) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Dockerfile + run: | + cat > Dockerfile <<'DEOF' + FROM rust:1-bookworm AS builder + ARG TELEMT_REPO=telemt/telemt + 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"] + DEOF + sed -i 's/^ //' Dockerfile + + - name: Lowercase owner + id: owner + run: echo "name=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + - name: Build and push multi-arch image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + TELEMT_COMMIT=${{ github.event.inputs.telemt_commit }} + tags: | + ${{ env.REGISTRY }}/${{ steps.owner.outputs.name }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.version_tag }} + ${{ env.REGISTRY }}/${{ steps.owner.outputs.name }}/${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..959aafb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 SamNet Technologies + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..39dac49 --- /dev/null +++ b/install.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# MTProxyMax Quick Installer — SamNet Technologies +# Usage: curl -sL https://raw.githubusercontent.com/SamNet-dev/MTProxyMax/main/install.sh | sudo bash +set -e +SCRIPT_URL="https://raw.githubusercontent.com/SamNet-dev/MTProxyMax/main/mtproxymax.sh" +if [ "$(id -u)" -ne 0 ]; then echo "Run as root: curl -sL $SCRIPT_URL | sudo bash" >&2; exit 1; fi +curl -fsSL "$SCRIPT_URL" -o /tmp/mtproxymax.sh && bash /tmp/mtproxymax.sh install && rm -f /tmp/mtproxymax.sh diff --git a/mtproxymax.sh b/mtproxymax.sh new file mode 100644 index 0000000..efe76c7 --- /dev/null +++ b/mtproxymax.sh @@ -0,0 +1,5757 @@ +#!/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.0" +TELEMT_COMMIT="43990c9" # Pinned: ME fixes + Prometheus metrics (post-v3.0.0) +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="1" +PROXY_MEMORY="256m" +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 str="$1" + str=$(echo -e "$str" | sed 's/\x1b\[[0-9;]*m//g') + echo ${#str} +} + +# Repeat a character n times +_repeat() { + local char="$1" count="$2" + printf '%0.s'"$char" $(seq 1 "$count") +} + +# 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 +get_public_ip() { + local ip="" + ip=$(curl -s --max-time 5 https://api.ipify.org 2>/dev/null) || + ip=$(curl -s --max-time 5 https://ifconfig.me 2>/dev/null) || + ip=$(curl -s --max-time 5 https://icanhazip.com 2>/dev/null) || + ip="" + if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || [[ "$ip" =~ : ]]; then + 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.0-${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.0-43990c9 → 3.0.0) + 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