#!/bin/bash # goTelegram Pro v2.5.0 — common utilities # Colors, logging, spinner, system helpers, v1 compat, i18n-aware # ── Version ─────────────────────────────────────────────────────────────────── GOTELEGRAM_VERSION="2.5.0" GOTELEGRAM_NAME="goTelegram Pro" # ── Пути ────────────────────────────────────────────────────────────────────── GOTELEGRAM_DIR="/opt/gotelegram" GOTELEGRAM_CONFIG="$GOTELEGRAM_DIR/config.json" TELEMT_CONFIG="/etc/telemt/config.toml" TELEMT_BIN="/usr/local/bin/telemt" TELEMT_SERVICE="telemt" NGINX_SITE_CONF="/etc/nginx/sites-available/gotelegram" NGINX_SITE_LINK="/etc/nginx/sites-enabled/gotelegram" WEBSITE_ROOT="/var/www/gotelegram-site" BACKUP_DIR="$GOTELEGRAM_DIR/backups" LOG_FILE="/var/log/gotelegram.log" BOT_DIR="/opt/gotelegram-bot" ADMIN_WEB_DIR="/opt/gotelegram-admin" ADMIN_WEB_SERVICE="gotelegram-admin" ADMIN_WEB_HOST="127.0.0.1" ADMIN_WEB_PORT="1984" # ── V1 совместимость ───────────────────────────────────────────────────────── V1_CONTAINER_NAME="mtproto-proxy" V1_CONFIG_FILE="/opt/gotelegram-bot/proxy.json" V1_SERVICE_NAME="gotelegram-bot" # ── Цвета ──────────────────────────────────────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' CYAN='\033[0;36m' YELLOW='\033[1;33m' MAGENTA='\033[0;35m' BLUE='\033[0;34m' WHITE='\033[1;37m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' # ── Логирование ────────────────────────────────────────────────────────────── log_info() { echo -e " ${CYAN}ℹ${NC} $*" >&2; } log_success() { echo -e " ${GREEN}✓${NC} $*" >&2; } log_warning() { echo -e " ${YELLOW}⚠${NC} $*" >&2; } log_error() { echo -e " ${RED}✗${NC} $*" >&2; } log_step() { echo -e "\n${BOLD}${WHITE} $*${NC}" >&2; } log_dim() { echo -e " ${DIM}$*${NC}" >&2; } log_to_file() { local ts; ts=$(date '+%Y-%m-%d %H:%M:%S') echo "[$ts] $*" >> "$LOG_FILE" 2>/dev/null } # ── Spinner ────────────────────────────────────────────────────────────────── _spin_pid="" spinner_start() { local default_msg default_msg=$(type t &>/dev/null && t wait || echo "Please wait...") local msg="${1:-$default_msg}" ( local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') local i=0 while true; do printf "\r ${CYAN}${frames[$i]}${NC} ${msg}" >&2 i=$(( (i+1) % ${#frames[@]} )) sleep 0.1 done ) & _spin_pid=$! } spinner_stop() { [ -n "$_spin_pid" ] && kill "$_spin_pid" 2>/dev/null && wait "$_spin_pid" 2>/dev/null _spin_pid="" printf "\r\033[K" >&2 } # ── Прогресс-бар ───────────────────────────────────────────────────────────── progress_bar() { local current="$1" total="$2" label="${3:-}" local pct=$(( current * 100 / total )) local filled=$(( pct / 2 )) local empty=$(( 50 - filled )) local bar="" for ((i=0; i&2 [ "$current" -eq "$total" ] && echo "" >&2 } # ── Выполнение с индикатором ───────────────────────────────────────────────── run_with_spinner() { local label="$1"; shift local err_file="/tmp/.gotelegram_spinner_err_$$" spinner_start "$label" "$@" >/dev/null 2>"$err_file" local rc=$? spinner_stop if [ $rc -eq 0 ]; then log_success "$label" else local err_label err_label=$(type t &>/dev/null && t error || echo "error") log_error "$label ${RED}(${err_label}, code: $rc)${NC}" if [ -s "$err_file" ]; then log_dim " $(head -3 "$err_file")" fi fi rm -f "$err_file" return $rc } # ── Banner ─────────────────────────────────────────────────────────────────── show_banner() { local line line=$(printf '━%.0s' $(seq 1 60)) echo "" echo -e "${CYAN}${line}${NC}" if type tf &>/dev/null; then echo -e " ${BOLD}${WHITE}🚀 $(tf banner_title "$GOTELEGRAM_VERSION")${NC}" echo -e " ${DIM}$(t banner_subtitle)${NC}" echo -e " ${DIM}$(t banner_features)${NC}" else echo -e " ${BOLD}${WHITE}🚀 goTelegram Pro v${GOTELEGRAM_VERSION}${NC}" echo -e " ${DIM}MTProxy powered by telemt (Rust + Tokio)${NC}" echo -e " ${DIM}Anti-DPI • Fake TLS • TCP Splice • JA3/JA4${NC}" fi echo -e "${CYAN}${line}${NC}" echo "" } # ── Credits ────────────────────────────────────────────────────────────────── show_credits() { local line line=$(printf '─%.0s' $(seq 1 60)) echo "" echo -e "${MAGENTA}${line}${NC}" echo -e " ${BOLD}$(type t &>/dev/null && t credits_title || echo 'Credits')${NC}" echo -e "${MAGENTA}${line}${NC}" echo -e " ${WHITE}telemt${NC} — MTProxy engine (Rust)" echo -e " ${DIM}github.com/telemt/telemt${NC}" echo "" echo -e " ${WHITE}HTML5 UP${NC} — responsive HTML/CSS templates" echo -e " ${DIM}html5up.net • CC BY 3.0 • @ajlkn${NC}" echo "" echo -e " ${WHITE}learning-zone${NC} — 150+ HTML5 templates" echo -e " ${DIM}github.com/learning-zone/website-templates${NC}" echo "" echo -e " ${WHITE}Start Bootstrap${NC} — MIT license" echo -e " ${DIM}startbootstrap.com${NC}" echo -e "${MAGENTA}${line}${NC}" echo "" } # ── Системные утилиты ──────────────────────────────────────────────────────── _valid_ip() { # Validate that each octet is 0-255 local ip="$1" local IFS='.' read -ra octets <<< "$ip" [ ${#octets[@]} -ne 4 ] && return 1 for octet in "${octets[@]}"; do [[ "$octet" =~ ^[0-9]+$ ]] || return 1 [ "$octet" -gt 255 ] && return 1 done return 0 } get_server_ip() { local ip raw for url in "https://api.ipify.org" "https://icanhazip.com" "https://ifconfig.me"; do raw=$(curl -s -4 --max-time 5 "$url" 2>/dev/null) ip=$(echo "$raw" | grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -1) if [ -n "$ip" ] && _valid_ip "$ip"; then echo "$ip" return 0 fi done echo "0.0.0.0" return 1 } _t_or() { # Helper: translate if i18n available, otherwise return fallback local key="$1" fallback="$2" if type t &>/dev/null; then t "$key" else echo "$fallback" fi } check_root() { if [ "$EUID" -ne 0 ]; then log_error "$(_t_or err_need_root 'Run the script with sudo / as root')" exit 1 fi } check_os() { if [ ! -f /etc/os-release ]; then log_error "$(_t_or err_os_unknown 'Failed to detect OS. Linux is required.')" return 1 fi # Validate os-release before sourcing (reject command injection: ;, backticks, $()) if grep -qE '(;|`|\$\(|\$\{)' /etc/os-release 2>/dev/null; then log_warning "/etc/os-release contains suspicious strings, skipping" return 0 fi . /etc/os-release case "$ID" in ubuntu|debian|centos|rocky|almalinux|fedora|rhel) log_dim "OS: $PRETTY_NAME" return 0 ;; *) log_warning "OS $ID may be incompatible. Supported: Ubuntu, Debian, CentOS, Rocky." return 0 ;; esac } get_arch() { local arch arch=$(uname -m) case "$arch" in x86_64|amd64) echo "amd64" ;; aarch64|arm64) echo "arm64" ;; armv7*|armhf) echo "armv7" ;; *) echo "$arch" ;; esac } get_pkg_manager() { if command -v apt-get &>/dev/null; then echo "apt" elif command -v dnf &>/dev/null; then echo "dnf" elif command -v yum &>/dev/null; then echo "yum" else echo "unknown" fi } install_pkg() { local pkg="$1" case "$(get_pkg_manager)" in apt) apt_install "$pkg" ;; dnf) dnf install -y -q "$pkg" ;; yum) yum install -y -q "$pkg" ;; *) log_error "$(_t_or err_bad_pkg_mgr 'Unknown package manager')"; return 1 ;; esac } # ── apt lock wait + install ───────────────────────────────────────────────── # На свежих Ubuntu/Debian unattended-upgrades часто держит dpkg lock на старте # → любой apt-get install падает с "Could not get lock /var/lib/dpkg/lock-frontend". # Эти функции ждут освобождения лока до 300с, потом запускают apt с нативным # таймаутом DPkg::Lock::Timeout. Использовать везде, где раньше был # "apt-get install ...". apt_lock_wait() { local max_wait="${1:-300}" local waited=0 local warned=0 while fuser /var/lib/dpkg/lock-frontend &>/dev/null \ || fuser /var/lib/dpkg/lock &>/dev/null \ || fuser /var/lib/apt/lists/lock &>/dev/null \ || pgrep -f '^/usr/bin/unattended-upgrade' &>/dev/null; do if [ "$warned" = "0" ]; then log_warning "apt/dpkg locked by unattended-upgrades, waiting up to ${max_wait}s..." warned=1 fi sleep 3 waited=$((waited + 3)) if [ "$waited" -ge "$max_wait" ]; then log_error "apt lock not released after ${max_wait}s" log_dim "Manual fix: systemctl stop unattended-upgrades && killall -9 unattended-upgr 2>/dev/null; dpkg --configure -a" return 1 fi done [ "$warned" = "1" ] && log_success "apt lock released (waited ${waited}s)" return 0 } # apt_install [pkg2 ...] — ждёт lock + ставит пакеты + показывает ошибку apt_install() { [ $# -eq 0 ] && return 0 apt_lock_wait || return 1 export DEBIAN_FRONTEND=noninteractive local opts="-o DPkg::Lock::Timeout=120" local err_file; err_file=$(mktemp 2>/dev/null || echo /tmp/apt_err.$$) if ! apt-get $opts install -y -qq "$@" 2>"$err_file"; then log_error "apt-get install failed: $*" [ -s "$err_file" ] && tail -n 5 "$err_file" | sed 's/^/ /' >&2 rm -f "$err_file" return 1 fi rm -f "$err_file" return 0 } # apt_update — тихий update с ожиданием лока apt_update() { apt_lock_wait || return 1 export DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=120 update -qq 2>/dev/null || true return 0 } # ── Зависимости GoTelegram ────────────────────────────────────────────────── # Полный список внешних команд, которые скрипт использует. Для каждой команды # указан пакет на apt и dnf/yum (имена различаются: например dig = dnsutils на # Debian, bind-utils на RHEL). # # КРИТИЧЕСКИЕ (без них скрипт просто не работает): # jq — парсинг config.json, templates_catalog.json # curl — скачивание telemt и проверки HTTPS # openssl — генерация секретов, шифрование бекапов, SSL проверка # git — клонирование шаблонов через download_template # xxd — hex-encode домена для fake-TLS секрета (ee-prefix) # tar — распаковка telemt архива и бекапы # dig — DNS-проверка домена в Pro-режиме # # ЖЕЛАТЕЛЬНЫЕ (есть fallback, но с ними лучше): # qrencode — QR-коды для прокси-ссылок # bc — красивое форматирование чисел в статистике # # Pro-режим доустанавливает nginx/certbot через install_nginx/install_certbot # (они большие и нужны только если пользователь выбрал Pro). # Маппинг команды -> (apt_pkg, dnf_pkg). apt_pkg_for_cmd apt_pkg_for_cmd() { case "$1" in dig) echo "dnsutils" ;; xxd) echo "xxd" ;; # Ubuntu 22+: отдельный пакет, fallback ниже nslookup) echo "dnsutils" ;; host) echo "dnsutils" ;; ss) echo "iproute2" ;; netstat) echo "net-tools" ;; flock) echo "util-linux" ;; iptables) echo "iptables" ;; *) echo "$1" ;; # команда == имя пакета esac } dnf_pkg_for_cmd() { case "$1" in dig|nslookup|host) echo "bind-utils" ;; xxd) echo "vim-common" ;; ss) echo "iproute" ;; netstat) echo "net-tools" ;; flock) echo "util-linux" ;; iptables) echo "iptables" ;; *) echo "$1" ;; esac } ensure_deps() { # Критические зависимости — без них скрипт не работает. # flock используется bot_action_dispatch для сериализации параллельных # вызовов (иначе гонка на config.json при одновременных change-template / # change-lite-domain из бота). local critical=(curl jq openssl git xxd tar dig flock) # Желательные — есть fallback, устанавливать всё равно, но не падать если не смогли local optional=(qrencode bc iptables) local missing_critical=() missing_optional=() cmd for cmd in "${critical[@]}"; do command -v "$cmd" &>/dev/null || missing_critical+=("$cmd") done for cmd in "${optional[@]}"; do command -v "$cmd" &>/dev/null || missing_optional+=("$cmd") done local all_missing=("${missing_critical[@]}" "${missing_optional[@]}") [ ${#all_missing[@]} -eq 0 ] && return 0 # Собираем список пакетов для выбранного менеджера local pkg_mgr pkg pkgs=() pkg_mgr=$(get_pkg_manager) for cmd in "${all_missing[@]}"; do case "$pkg_mgr" in apt) pkg=$(apt_pkg_for_cmd "$cmd") ;; dnf|yum) pkg=$(dnf_pkg_for_cmd "$cmd") ;; *) pkg="$cmd" ;; esac pkgs+=("$pkg") done # Убираем дубликаты (например dig+nslookup оба = dnsutils) local uniq_pkgs=() for pkg in "${pkgs[@]}"; do local found=0 p for p in "${uniq_pkgs[@]}"; do [ "$p" = "$pkg" ] && { found=1; break; } done [ "$found" = "0" ] && uniq_pkgs+=("$pkg") done if type tf &>/dev/null; then log_step "$(tf deps_installing "${all_missing[*]}")" else log_step "Installing dependencies: ${all_missing[*]} (packages: ${uniq_pkgs[*]})" fi case "$pkg_mgr" in apt) apt_update apt_install "${uniq_pkgs[@]}" || true ;; dnf) dnf install -y -q "${uniq_pkgs[@]}" 2>/dev/null ;; yum) yum install -y -q "${uniq_pkgs[@]}" 2>/dev/null ;; *) log_error "Unknown package manager — install manually: ${uniq_pkgs[*]}" return 1 ;; esac # Фолбэки для xxd: на некоторых системах нужен vim-common вместо xxd if ! command -v xxd &>/dev/null && [ "$pkg_mgr" = "apt" ]; then apt_install vim-common || true fi # Повторная проверка критических команд local still_missing=() for cmd in "${critical[@]}"; do command -v "$cmd" &>/dev/null || still_missing+=("$cmd") done if [ ${#still_missing[@]} -gt 0 ]; then log_error "Critical dependencies still missing: ${still_missing[*]}" log_error "Install manually and re-run gotelegram" return 1 fi # Опциональные — только предупреждение local still_missing_opt=() for cmd in "${optional[@]}"; do command -v "$cmd" &>/dev/null || still_missing_opt+=("$cmd") done if [ ${#still_missing_opt[@]} -gt 0 ]; then log_warning "Optional deps missing (features degraded): ${still_missing_opt[*]}" fi log_success "Dependencies ready" return 0 } # Быстрая проверка — только смотрит что критические установлены, ничего не ставит. # Возвращает 0 если всё ок, 1 если что-то отсутствует. Используется на старте # main() чтобы не дёргать apt-get update при каждом запуске меню. check_deps_present() { local cmd for cmd in curl jq openssl git xxd tar dig flock; do command -v "$cmd" &>/dev/null || return 1 done return 0 } check_port() { local port="$1" local line line=$(ss -tlnp 2>/dev/null | grep -E ":${port}\b" | head -1) [ -z "$line" ] && line=$(netstat -tlnp 2>/dev/null | grep -E ":${port}\b" | head -1) if [ -n "$line" ]; then echo "$line" return 0 # порт занят fi return 1 # свободен } detect_3xui() { if systemctl list-unit-files 2>/dev/null | grep -Eq '^(x-ui|3x-ui)\.service'; then return 0 fi [ -d /etc/x-ui ] || [ -d /usr/local/x-ui ] || [ -f /etc/x-ui/x-ui.db ] } detect_3xui_443_listener() { ss -ltnp 2>/dev/null | grep -E '(:|])443[[:space:]]' | grep -Eiq '(xray|x-ui|3x-ui)' } warn_3xui_443_conflict() { detect_3xui_443_listener || return 1 log_warning "Обнаружен 3x-ui/Xray, который уже слушает TCP/443." log_warning "goTelegram Pro не будет молча останавливать или переписывать 3x-ui." log_dim "Для настоящего shared-443 нужен один фронтовой TLS/SNI-диспетчер и разные SNI-домены для Xray и goTelegram Pro." mkdir -p "$GOTELEGRAM_DIR" 2>/dev/null cat > "$GOTELEGRAM_DIR/shared-443-3xui.md" <<'EOF' 2>/dev/null || true # goTelegram Pro + 3x-ui on one TCP/443 goTelegram Pro detected that 3x-ui/Xray already owns TCP/443. Two independent processes cannot bind the same IP:port at the same time. A safe shared setup needs one front TLS/SNI dispatcher on 443 and internal backends, for example: - dispatcher: 0.0.0.0:443 - goTelegram Pro telemt: 127.0.0.1:7443 - 3x-ui/Xray inbound: 127.0.0.1:9443 - goTelegram Pro nginx mask site: 127.0.0.1:8443 The dispatcher must route Xray SNI domains to Xray and route the goTelegram Pro SNI domain to telemt. If Xray and goTelegram Pro use the same SNI domain, automatic sharing is not reliable: the first TLS ClientHello is intentionally identical. goTelegram Pro intentionally does not rewrite the 3x-ui SQLite database or generated Xray config without explicit operator confirmation, because 3x-ui can overwrite manual JSON edits on the next panel change. EOF return 0 } check_disk_space() { local min_mb="${1:-500}" local avail_mb avail_mb=$(df -m / | awk 'NR==2 {print $4}') if [ "$avail_mb" -lt "$min_mb" ]; then if type tf &>/dev/null; then log_error "$(tf err_low_disk "$avail_mb" "$min_mb")" else log_error "Low disk space: ${avail_mb}MB (need ${min_mb}MB+)" fi return 1 fi return 0 } # ── Конфигурация GoTelegram (JSON) ────────────────────────────────────────── save_gotelegram_config() { mkdir -p "$(dirname "$GOTELEGRAM_CONFIG")" local cur_lang cur_lang=$(type get_language &>/dev/null && get_language || echo en) cat > "$GOTELEGRAM_CONFIG" << EOJSON { "version": "$GOTELEGRAM_VERSION", "engine": "${1:-telemt}", "mode": "${2:-lite}", "port": ${3:-443}, "secret": "${4:-}", "mask_host": "${5:-google.com}", "domain": "${6:-}", "template_id": "${7:-}", "language": "${cur_lang}", "installed_at": "$(date -Iseconds)", "updated_at": "$(date -Iseconds)" } EOJSON chmod 600 "$GOTELEGRAM_CONFIG" } load_gotelegram_config() { if [ -f "$GOTELEGRAM_CONFIG" ]; then cat "$GOTELEGRAM_CONFIG" return 0 fi echo "{}" return 1 } config_get() { local key="$1" if [ ! -f "$GOTELEGRAM_CONFIG" ]; then return 2 # file missing fi local val val=$(jq -r ".$key // empty" "$GOTELEGRAM_CONFIG" 2>/dev/null) if [ $? -ne 0 ]; then return 3 # invalid JSON fi if [ -z "$val" ]; then return 1 # key missing or empty fi echo "$val" return 0 } # ── V1 совместимость ───────────────────────────────────────────────────────── detect_v1_installation() { # Проверяем наличие mtg Docker контейнера (v1) if command -v docker &>/dev/null; then if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^${V1_CONTAINER_NAME}$"; then return 0 # v1 обнаружена fi fi # Проверяем наличие конфига v1 if [ -f "$V1_CONFIG_FILE" ]; then return 0 fi return 1 } get_v1_config() { # Извлекаем данные из работающего v1 контейнера if ! command -v docker &>/dev/null; then echo "{}" return 1 fi local running running=$(docker ps --format '{{.Names}}' 2>/dev/null | grep "^${V1_CONTAINER_NAME}$") if [ -z "$running" ]; then # Пробуем из сохранённого конфига if [ -f "$V1_CONFIG_FILE" ]; then cat "$V1_CONFIG_FILE" return 0 fi echo "{}" return 1 fi # Достаём из Docker local cmd_str port secret ip cmd_str=$(docker inspect "$V1_CONTAINER_NAME" --format='{{range .Config.Cmd}}{{.}} {{end}}' 2>/dev/null) secret=$(echo "$cmd_str" | awk '{print $NF}') port=$(docker inspect "$V1_CONTAINER_NAME" --format='{{range $p,$c := .HostConfig.PortBindings}}{{(index $c 0).HostPort}}{{end}}' 2>/dev/null) ip=$(get_server_ip) jq -n \ --arg secret "$secret" \ --arg port "${port:-443}" \ --arg ip "$ip" \ '{secret: $secret, port: ($port | tonumber), ip: $ip, engine: "mtg"}' } migrate_v1_to_v2() { log_step "$(_t_or v1_migration_step 'Migrating from v1 (mtg) to v2 (telemt)')" local v1_config v1_config=$(get_v1_config) local old_port old_secret old_port=$(echo "$v1_config" | jq -r '.port // 443') old_secret=$(echo "$v1_config" | jq -r '.secret // empty') if [ -z "$old_secret" ]; then log_warning "Failed to extract secret from v1. A new one will be generated." return 1 fi echo "" echo -e " ${WHITE}$(_t_or v1_found_title 'Found v1 (mtg) installation:')${NC}" if type tf &>/dev/null; then echo -e " $(tf v1_port "$old_port")" echo -e " $(tf v1_secret "${old_secret:0:16}")" else echo -e " Port: ${CYAN}${old_port}${NC}" echo -e " Secret: ${CYAN}${old_secret:0:16}...${NC}" fi echo "" echo -e " ${YELLOW}$(_t_or warning 'Warning'):${NC} $(_t_or v1_incompatible 'mtg secret is NOT directly compatible with telemt.')" echo -e " $(_t_or v1_new_link 'Clients will need a new link.')" echo "" echo -ne " $(_t_or v1_stop_migrate 'Stop v1 container and migrate to v2? [Y/n]:') " read -r ans if [[ "$ans" =~ ^[Nn] ]]; then log_info "$(_t_or v1_migration_cancelled 'Migration cancelled. v1 left intact.')" return 1 fi # Stop v1 log_info "$(_t_or v1_stopping 'Stopping v1 container...')" docker stop "$V1_CONTAINER_NAME" 2>/dev/null docker rm "$V1_CONTAINER_NAME" 2>/dev/null # Backup v1 config if [ -f "$V1_CONFIG_FILE" ]; then mkdir -p "$GOTELEGRAM_DIR" cp "$V1_CONFIG_FILE" "$GOTELEGRAM_DIR/v1_backup_proxy.json" 2>/dev/null if type tf &>/dev/null; then log_success "$(tf v1_config_saved "$GOTELEGRAM_DIR/v1_backup_proxy.json")" else log_success "v1 config saved to $GOTELEGRAM_DIR/v1_backup_proxy.json" fi fi if type tf &>/dev/null; then log_success "$(tf v1_port_freed "$old_port")" else log_success "v1 stopped. Port $old_port freed." fi return 0 } # ── Confirm prompt ─────────────────────────────────────────────────────────── confirm() { local default_msg default_msg=$(_t_or install_continue_anyway 'Continue?') local msg="${1:-$default_msg}" echo -ne " ${msg} [Y/n]: " >&2 read -r ans [[ ! "$ans" =~ ^[Nn] ]] } # ── Выбор из списка ────────────────────────────────────────────────────────── select_option() { local title="$1" shift local options=("$@") echo "" >&2 echo -e " ${BOLD}${WHITE}${title}${NC}" >&2 echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" >&2 local i=1 for opt in "${options[@]}"; do echo -e " ${CYAN}${i})${NC} ${opt}" >&2 ((i++)) done echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" >&2 echo -ne " ${WHITE}$(_t_or choose 'Choose'):${NC} " >&2 read -r choice echo "$choice" } # ── Генерация случайного hex ───────────────────────────────────────────────── generate_hex() { local len="${1:-32}" openssl rand -hex "$((len/2))" 2>/dev/null || head -c "$((len/2))" /dev/urandom | xxd -p | tr -d '\n' } # ── Проверка домена ────────────────────────────────────────────────────────── validate_domain() { local domain="$1" if echo "$domain" | grep -qE '^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'; then return 0 fi return 1 } # ── Init: создание директорий ──────────────────────────────────────────────── init_dirs() { mkdir -p "$GOTELEGRAM_DIR" "$BACKUP_DIR" /etc/telemt 2>/dev/null touch "$LOG_FILE" 2>/dev/null }