diff --git a/docs/deploy.md b/docs/deploy.md index 9a5dcac..f663d0b 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -10,23 +10,23 @@ **Интерактивный режим** (задаст вопросы в терминале): ```bash -curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install-server.sh | sudo bash +curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install.sh | sudo bash ``` > Скрипт идемпотентен: при повторном запуске он предложит обновить, переконфигурировать или удалить сервер. **Неинтерактивный режим** (для автоматизации): ```bash # Установка через Docker (UDP, порт бэкенда 51820) -curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install-server.sh | \ +curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install.sh | \ sudo bash -s -- -y --method docker --mode udp --backend-port 51820 # Обновление до конкретной версии -sudo bash install-server.sh -y --update --version v1.2.3 +sudo bash install.sh -y --update --version v1.2.3 # Полное удаление -sudo bash install-server.sh -y --uninstall --purge +sudo bash install.sh -y --uninstall --purge ``` -*Все доступные флаги:* `sudo bash install-server.sh --help` +*Все доступные флаги:* `sudo bash install.sh --help` --- diff --git a/docs/quickstart.md b/docs/quickstart.md index 118560e..51e84f2 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -11,7 +11,7 @@ Интерактивный скрипт установит Docker или systemd, настроит файрвол и запустит сервер. Запускать от root: ```bash -curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install-server.sh | sudo bash +curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install.sh | sudo bash ``` > Скопируйте параметры клиента, которые скрипт выдаст в конце. Для ручной установки или запуска скрипта без вопросов (non-interactive) см. [Развёртывание (deploy.md)](deploy.md). diff --git a/scripts/install-server.sh b/scripts/install-server.sh deleted file mode 100644 index dc0fcb6..0000000 --- a/scripts/install-server.sh +++ /dev/null @@ -1,615 +0,0 @@ -#!/usr/bin/env bash -# Free Turn Proxy Server — установщик. Интерактивный TUI + non-interactive (-y). -# Подробности: bash install-server.sh --help - -set -Eeuo pipefail -trap 'rc=$?; printf "\n\033[0;31m[x]\033[0m строка %s (код %s): %s\n" "${LINENO}" "${rc}" "${BASH_COMMAND}" >&2; exit "${rc}"' ERR - -REPO="samosvalishe/free-turn-proxy" -IMAGE="ghcr.io/${REPO}" -APP_DIR="/opt/free-turn-proxy" -CONF_FILE="${APP_DIR}/install.conf" -SERVICE="free-turn-proxy.service" -UNIT_FILE="/etc/systemd/system/${SERVICE}" -COMPOSE_FILE="${APP_DIR}/docker-compose.yml" -CONTAINER="free-turn-proxy" -WT_BACK="Free Turn Proxy • установщик сервера" - -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' -info() { echo -e "${CYAN}[*]${NC} $1"; } -success() { echo -e "${GREEN}[+]${NC} $1"; } -warn() { echo -e "${YELLOW}[!]${NC} $1"; } -error() { echo -e "${RED}[x]${NC} $1" >&2; exit 1; } - -# Конфиг (перекрывается load_config / мастером / CLI) -INSTALL_METHOD="1" # 1=docker 2=systemd -VERSION="latest" -PROXY_MODE="udp" -BACKEND_PORT="" -LISTEN_PORT="56000" -OBF_PROFILE="rtpopus" -OBF_KEY="" -CLIENTS_FILE_CONF="" - -GOARCH="" -WG_PORT="" -HAS_WHIPTAIL=0 -NONINTERACTIVE=0 -OPEN_FIREWALL="" # ""=спросить 1=да 0=нет -PURGE=0 -ACTION="" -OVERRIDES=() - -valid_port() { [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ]; } -valid_hex64() { [[ "$1" =~ ^[0-9a-fA-F]{64}$ ]]; } - -# --------------------------------------------------------------------------- -# UI: whiptail | plain (/dev/tty — работает через `curl | bash`) -# --------------------------------------------------------------------------- -wt() { whiptail --backtitle "$WT_BACK" "$@"; } -ui_abort() { info "Отменено."; exit 0; } - -ui_msg() { # TITLE TEXT - if [ "$HAS_WHIPTAIL" = 1 ]; then wt --title "$1" --msgbox "$2" 12 72; else echo; warn "$1: $2"; fi -} - -ui_input() { # VAR PROMPT DEFAULT - local __v="$1" __p="$2" __d="${3:-}" __a - if [ "$HAS_WHIPTAIL" = 1 ]; then - __a=$(wt --title "Free Turn Proxy" --inputbox "$__p" 10 72 "$__d" 3>&1 1>&2 2>&3) || ui_abort - else - if [ -n "$__d" ]; then read -r -p "$__p [$__d]: " __a 0/1 - local __p="$1" __def="${2:-Y}" __a - if [ "$HAS_WHIPTAIL" = 1 ]; then - if [ "$__def" = "Y" ]; then wt --title "Free Turn Proxy" --yesno "$__p" 10 72 - else wt --title "Free Turn Proxy" --defaultno --yesno "$__p" 10 72; fi - return $? - fi - local hint; [ "$__def" = "Y" ] && hint="Y/n" || hint="y/N" - read -r -p "$__p [$hint]: " __a &1 1>&2 2>&3) || ui_abort - printf -v "$__v" '%s' "$sel" - else - local i sel - echo; info "$__p" - for i in "${!tags[@]}"; do - [ "${tags[$i]}" = "$__def" ] && echo -e " ${GREEN}${tags[$i]}${NC}) ${descs[$i]}" \ - || echo " ${tags[$i]}) ${descs[$i]}" - done - while :; do - read -r -p "Выбор [${__def}]: " sel "$log" 2>&1 ) & local pid=$! - { - local p=0 - while kill -0 "$pid" 2>/dev/null; do - echo "$p"; p=$((p+2)); if [ "$p" -gt 95 ]; then p=95; fi; sleep 0.25 - done - echo 100 - } | wt --gauge "$title" 8 72 0 - wait "$pid" && rc=0 || rc=$? - [ "$rc" -ne 0 ] && wt --title "Ошибка" --scrolltext --msgbox "$(tail -n 40 "$log")" 20 76 - elif [ -t 1 ]; then - ( trap - ERR; "$@" >"$log" 2>&1 ) & local pid=$! ch='|/-\' i=0 - while kill -0 "$pid" 2>/dev/null; do - i=$(((i+1)%4)); printf "\r${CYAN}[*]${NC} %s %s" "$title" "${ch:$i:1}"; sleep 0.2 - done - wait "$pid" && rc=0 || rc=$? - if [ "$rc" -eq 0 ]; then printf "\r${GREEN}[+]${NC} %s\033[K\n" "$title" - else printf "\r${RED}[x]${NC} %s\033[K\n" "$title"; tail -n 40 "$log" >&2; fi - else - info "$title..." - "$@" >"$log" 2>&1 && rc=0 || rc=$? - [ "$rc" -ne 0 ] && tail -n 40 "$log" >&2 - fi - rm -f "$log" - return "$rc" -} - -ui_info_start() { [ "$HAS_WHIPTAIL" = 1 ] && wt --title "Подождите" --infobox "$1" 7 60 || info "$1"; } - -# --------------------------------------------------------------------------- -# Система -# --------------------------------------------------------------------------- -detect_arch() { - case "$(uname -m)" in - x86_64|amd64) GOARCH="amd64" ;; - aarch64|arm64) GOARCH="arm64" ;; - *) error "Неподдерживаемая архитектура: $(uname -m)" ;; - esac -} - -pkg_install() { - if command -v apt-get >/dev/null 2>&1; then - apt-get update -y -qq >/dev/null 2>&1 || true - apt-get install -y -qq "$@" >/dev/null 2>&1 || true - elif command -v dnf >/dev/null 2>&1; then dnf install -y -q "$@" >/dev/null 2>&1 || true - elif command -v yum >/dev/null 2>&1; then yum install -y -q "$@" >/dev/null 2>&1 || true - fi -} - -ensure_base_deps() { - local missing=() b - for b in curl jq openssl; do command -v "$b" >/dev/null 2>&1 || missing+=("$b"); done - if [ "${#missing[@]}" -ne 0 ]; then - with_progress "Установка зависимостей: ${missing[*]}" pkg_install "${missing[@]}" - missing=() - for b in curl jq openssl; do command -v "$b" >/dev/null 2>&1 || missing+=("$b"); done - [ "${#missing[@]}" -ne 0 ] && error "Не удалось установить: ${missing[*]}. Поставьте вручную." - fi -} - -ensure_tui() { - command -v whiptail >/dev/null 2>&1 && { HAS_WHIPTAIL=1; return; } - if command -v apt-get >/dev/null 2>&1; then pkg_install whiptail; else pkg_install newt; fi - command -v whiptail >/dev/null 2>&1 && HAS_WHIPTAIL=1 || { HAS_WHIPTAIL=0; warn "whiptail недоступен — текстовый режим."; } -} - -detect_wg_port() { - WG_PORT="" - command -v wg >/dev/null 2>&1 || return 0 - if wg show all listen-port >/dev/null 2>&1; then - WG_PORT="$(wg show all listen-port 2>/dev/null | head -n1 | awk '{print $2}' || true)" - fi - if [ -z "$WG_PORT" ] && ls /etc/wireguard/*.conf >/dev/null 2>&1; then - WG_PORT="$(grep -i ListenPort /etc/wireguard/*.conf 2>/dev/null | head -n1 | awk -F= '{print $2}' | tr -d ' ' || true)" - fi -} - -# --------------------------------------------------------------------------- -# GitHub API -# --------------------------------------------------------------------------- -gh_latest_version() { curl -s --max-time 10 "https://api.github.com/repos/${REPO}/releases/latest" | jq -r '.tag_name // empty'; } -gh_recent_versions() { curl -s --max-time 10 "https://api.github.com/repos/${REPO}/releases?per_page=6" | jq -r '.[].tag_name'; } - -resolve_asset_url() { # VERSION ASSET - local api - [ "$1" = "latest" ] && api="https://api.github.com/repos/${REPO}/releases/latest" \ - || api="https://api.github.com/repos/${REPO}/releases/tags/$1" - curl -s --max-time 15 "$api" | jq -r --arg n "$2" '.assets[] | select(.name==$n) | .browser_download_url' -} - -image_tag() { [ "$VERSION" = "latest" ] && echo "latest" || echo "$VERSION"; } - -choose_version() { - ui_info_start "Получение списка версий с GitHub..." - local latest others t def="${VERSION:-latest}" - latest="$(gh_latest_version || true)" - others="$([ -n "$latest" ] && gh_recent_versions 2>/dev/null | grep -vx "$latest" | head -4 || true)" - - if [ "$HAS_WHIPTAIL" = 1 ] && [ -n "$latest" ]; then - local args=("latest" "последний стабильный (= $latest)") sel - while IFS= read -r t; do [ -n "$t" ] && args+=("$t" "релиз $t"); done <<< "$others" - args+=("custom" "ввести тег вручную") - sel=$(wt --title "Версия" --default-item "$def" --menu "Выберите версию" 17 72 8 "${args[@]}" 3>&1 1>&2 2>&3) || ui_abort - [ "$sel" = "custom" ] && ui_input VERSION "Тег (latest или vX.Y.Z)" "$def" || VERSION="$sel" - else - echo - if [ -n "$latest" ]; then - echo -e " ${GREEN}latest${NC} (= $latest)" - [ -n "$others" ] && while IFS= read -r t; do echo " $t"; done <<< "$others" - else - warn "Список релизов недоступен — введите тег вручную." - fi - echo - ui_input VERSION "Версия (latest или vX.Y.Z)" "$def" - fi -} - -# --------------------------------------------------------------------------- -# Конфиг установки -# --------------------------------------------------------------------------- -is_installed() { [ -f "$CONF_FILE" ] || [ -f "$COMPOSE_FILE" ] || [ -f "$UNIT_FILE" ]; } -load_config() { [ -f "$CONF_FILE" ] && . "$CONF_FILE" || true; } - -save_config() { - mkdir -p "$APP_DIR" - cat > "$CONF_FILE" </dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then - ufw allow "${LISTEN_PORT}/udp" >/dev/null 2>&1 || warn "ufw allow не удался." - success "Порт ${LISTEN_PORT}/udp открыт (UFW)." - elif command -v iptables >/dev/null 2>&1; then - if ! iptables -C INPUT -p udp --dport "$LISTEN_PORT" -j ACCEPT 2>/dev/null; then - iptables -I INPUT -p udp --dport "$LISTEN_PORT" -j ACCEPT 2>/dev/null || warn "iptables -I не удался." - command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save >/dev/null 2>&1 || true - fi - success "Порт ${LISTEN_PORT}/udp открыт (iptables)." - else - warn "Брандмауэр не найден — откройте ${LISTEN_PORT}/udp вручную." - fi -} - -# --------------------------------------------------------------------------- -# Применение -# --------------------------------------------------------------------------- -init_clients_file() { - [ -n "$CLIENTS_FILE_CONF" ] && [ ! -f "$CLIENTS_FILE_CONF" ] && echo "[]" > "$CLIENTS_FILE_CONF" || true -} - -ensure_docker() { - command -v docker >/dev/null 2>&1 && return 0 - if [ "$NONINTERACTIVE" != 1 ] && ! ui_yesno "Docker не найден. Установить?" "Y"; then - error "Для метода docker нужен Docker." - fi - with_progress "Установка Docker" sh -c 'curl -fsSL https://get.docker.com | sh' \ - || error "Установка Docker не удалась." - command -v docker >/dev/null 2>&1 || error "Docker не появился в PATH." -} - -healthcheck_docker() { - sleep 2 - [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)" = "true" ] && { success "Контейнер работает."; return; } - warn "Контейнер не запущен. Логи:" - docker logs --tail 40 "$CONTAINER" 2>&1 || true - error "$CONTAINER не поднялся." -} - -apply_docker() { - ensure_docker - { - echo "services:" - echo " free-turn-proxy:" - echo " image: ${IMAGE}:$(image_tag)" - echo " container_name: ${CONTAINER}" - echo " network_mode: \"host\"" - echo " restart: unless-stopped" - echo " environment:" - echo " - CONNECT_ADDR=$(connect_addr)" - echo " - LISTEN_ADDR=0.0.0.0:${LISTEN_PORT}" - echo " - MODE=${PROXY_MODE}" - echo " - OBF_PROFILE=${OBF_PROFILE}" - echo " - OBF_KEY=${OBF_KEY}" - if [ -n "$CLIENTS_FILE_CONF" ]; then - echo " - CLIENTS_FILE=${CLIENTS_FILE_CONF}" - echo " volumes:" - echo " - ${CLIENTS_FILE_CONF}:${CLIENTS_FILE_CONF}" - fi - } > "$COMPOSE_FILE" - - cd "$APP_DIR" - with_progress "Загрузка образа ${IMAGE}:$(image_tag)" docker compose pull || error "docker compose pull не удался." - with_progress "Запуск контейнера" docker compose up -d || error "docker compose up не удался." - healthcheck_docker -} - -download_binary() { - local url - url="$(resolve_asset_url "$VERSION" "server-linux-${GOARCH}" || true)" - [ -z "$url" ] && error "Не найден server-linux-${GOARCH} в релизе ${VERSION}." - with_progress "Скачивание server-linux-${GOARCH} (${VERSION})" \ - curl -fL -o "${APP_DIR}/server.new" "$url" || error "Не удалось скачать $url" - chmod +x "${APP_DIR}/server.new" - mv -f "${APP_DIR}/server.new" "${APP_DIR}/server" -} - -healthcheck_systemd() { - sleep 1 - systemctl is-active --quiet "$SERVICE" && { success "Служба активна."; return; } - warn "Служба не активна. Логи:" - journalctl -u "$SERVICE" --no-pager -n 40 2>&1 || true - error "$SERVICE не запустилась." -} - -apply_systemd() { - download_binary - local args="-listen 0.0.0.0:${LISTEN_PORT} -connect $(connect_addr) -mode ${PROXY_MODE}" - [ "$OBF_PROFILE" != "none" ] && args="$args -obf-profile ${OBF_PROFILE} -obf-key ${OBF_KEY}" - [ -n "$CLIENTS_FILE_CONF" ] && args="$args -clients-file ${CLIENTS_FILE_CONF}" - cat > "$UNIT_FILE" </dev/null 2>&1 || true - systemctl restart "$SERVICE" || error "systemctl restart не удался." - healthcheck_systemd -} - -apply() { - mkdir -p "$APP_DIR" - init_clients_file - if [ "$INSTALL_METHOD" = "1" ]; then apply_docker; else apply_systemd; fi - [ "$OPEN_FIREWALL" = "1" ] && firewall_open - save_config -} - -print_summary() { - local ext_ip; ext_ip="$(curl -s --max-time 5 ifconfig.me || echo "IP_СЕРВЕРА")" - local body="Сервер (peer): ${ext_ip}:${LISTEN_PORT}\nРежим: ${PROXY_MODE}\nВерсия: ${VERSION}\nМетод: $(method_label)" - [ "$OBF_PROFILE" != "none" ] && body="${body}\nОбфускация: ${OBF_PROFILE}\nКлюч: ${OBF_KEY}" - if [ -n "$CLIENTS_FILE_CONF" ]; then - if [ "$INSTALL_METHOD" = "1" ]; then - body="${body}\n\nClient ID auth ВКЛ. Добавить клиента:\n docker exec -it ${CONTAINER} /app/server clients add " - else - body="${body}\n\nClient ID auth ВКЛ. Добавить клиента:\n ${APP_DIR}/server -clients-file ${CLIENTS_FILE_CONF} clients add " - fi - fi - if [ "$HAS_WHIPTAIL" = 1 ]; then - wt --title "Готово ✓" --msgbox "$(echo -e "$body")" 18 74 - else - echo ""; success "Готово!" - echo "--------------------------------------------------------" - echo -e "$body" - echo "--------------------------------------------------------" - fi - echo -e "Документация: https://github.com/${REPO}" -} - -# --------------------------------------------------------------------------- -# Сценарии -# --------------------------------------------------------------------------- -flow_install() { wizard; validate_config; apply; print_summary; } -flow_reconfigure() { wizard; validate_config; apply; print_summary; } - -flow_update() { - [ -f "$CONF_FILE" ] || { warn "Конфиг не найден — переконфигурация."; flow_reconfigure; return; } - choose_version - [ -z "$OPEN_FIREWALL" ] && OPEN_FIREWALL=0 - validate_config; apply; print_summary -} - -do_uninstall() { # REMOVE_DIR(0|1) - if [ "$INSTALL_METHOD" = "1" ]; then - [ -f "$COMPOSE_FILE" ] && ( cd "$APP_DIR" && docker compose down ) || warn "docker compose down — пропуск/ошибка." - else - systemctl disable --now "$SERVICE" >/dev/null 2>&1 || true - rm -f "$UNIT_FILE"; systemctl daemon-reload || true - fi - success "Служба остановлена и удалена." - [ "$1" = "1" ] && { rm -rf "$APP_DIR"; success "Каталог $APP_DIR удалён."; } || info "Каталог сохранён: $APP_DIR" -} - -flow_uninstall() { - ui_yesno "Удалить Free Turn Proxy Server?" "N" || { info "Отменено."; return; } - local rm_dir=0 - ui_yesno "Удалить каталог ${APP_DIR} (ключи, clients.json, конфиг)?" "N" && rm_dir=1 - do_uninstall "$rm_dir" -} - -menu_existing() { - local choice - ui_menu choice "Установка найдена (метод: $(method_label), версия: ${VERSION:-?}). Действие:" "update" \ - update "Обновить версию (конфиг сохранён)" \ - reconfigure "Переконфигурировать" \ - uninstall "Удалить" \ - exit "Выход" - case "$choice" in - update) flow_update ;; reconfigure) flow_reconfigure ;; - uninstall) flow_uninstall ;; exit) info "Выход."; exit 0 ;; - esac -} - -run_noninteractive() { - is_installed && load_config || true - apply_overrides - if [ "${ACTION:-install}" = "uninstall" ]; then do_uninstall "$PURGE"; return; fi - validate_config - [ -z "$OPEN_FIREWALL" ] && OPEN_FIREWALL=1 - info "Применяю конфигурацию (метод: $(method_label))..." - apply; print_summary -} - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- -usage() { - cat < &2; fi; } +log_error() { if [ "$HAS_GUM" = 1 ]; then gum log --level error -- "$1"; else echo -e "${C_RED}[x]${C_NC} $1" >&2; fi; } +log_success() { if [ "$HAS_GUM" = 1 ]; then gum style --foreground "$MD_SUCCESS" "✔ $1"; else echo -e "${C_GREEN}[+]${C_NC} $1"; fi; } + +die() { log_error "$1"; exit 1; } +ui_abort() { log_info "Отменено."; exit 0; } + +# Градиент-баннер из ASCII-арта. +ui_banner() { + local art=( +'███████╗██████╗ ███████╗███████╗████████╗██╗ ██╗██████╗ ███╗ ██╗' +'██╔════╝██╔══██╗██╔════╝██╔════╝╚══██╔══╝██║ ██║██╔══██╗████╗ ██║' +'█████╗ ██████╔╝█████╗ █████╗ ██║ ██║ ██║██████╔╝██╔██╗ ██║' +'██╔══╝ ██╔══██╗██╔══╝ ██╔══╝ ██║ ██║ ██║██╔══██╗██║╚██╗██║' +'██║ ██║ ██║███████╗███████╗ ██║ ╚██████╔╝██║ ██║██║ ╚████║' +'╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝') + printf '\n' + if [ "$HAS_GUM" = 1 ]; then + local i lines=() + for i in "${!art[@]}"; do + lines+=("$(gum style --foreground "${BANNER_GRADIENT[$i]}" "${art[$i]}")") + done + gum join --vertical "${lines[@]}" + gum style --foreground "$MD_SECONDARY" --italic --margin "0 0 1 1" \ + "TURN-relay туннель · установщик сервера" + else + echo -e "${C_CYAN}"; printf '%s\n' "${art[@]}"; echo -e "${C_NC}" + echo " TURN-relay туннель · установщик сервера"; echo + fi +} + +# Информационный бокс. ui_note TITLE TEXT +ui_note() { + if [ "$HAS_GUM" = 1 ]; then + gum style --border rounded --border-foreground "$MD_PRIMARY" --padding "0 1" --margin "1 0" \ + "$(gum style --foreground "$MD_PRIMARY" --bold "$1")" "$2" + else + echo; log_warn "$1: $2" + fi +} + +# Текстовый ввод с дефолтом. ui_input VAR PROMPT [DEFAULT] +ui_input() { + local __var="$1" __prompt="$2" __def="${3:-}" __ans + if [ "$HAS_GUM" = 1 ]; then + __ans=$(gum input --prompt "$__prompt: " --prompt.foreground "$MD_PRIMARY" \ + --cursor.foreground "$MD_TERTIARY" --value "$__def" код 0(да)/1(нет) +ui_yesno() { + local __prompt="$1" __def="${2:-Y}" __ans + if [ "$HAS_GUM" = 1 ]; then + local flags=(--selected.background "$MD_PRIMARY" --selected.foreground "#1C1B1F") + [ "$__def" = "N" ] && flags+=(--default=false) + gum confirm "${flags[@]}" "$__prompt" "$0" 2>&1' "$log" "$@"; then rc=0; else rc=$?; fi + if [ "$rc" -eq 0 ]; then log_success "$title" + else + log_error "$title — ошибка (код $rc)" + tail -n 40 "$log" | gum style --border rounded --border-foreground "$MD_ERROR" --padding "0 1" + fi + elif [ -t 1 ]; then + ( "$@" >"$log" 2>&1 ) & + local pid=$! i=0 + local ch='|/-'$'\\' # кадры спиннера: | / - \ + while kill -0 "$pid" 2>/dev/null; do + i=$(((i + 1) % 4)); printf "\r${C_CYAN}[*]${C_NC} %s %s" "$title" "${ch:$i:1}"; sleep 0.2 + done + wait "$pid" && rc=0 || rc=$? + if [ "$rc" -eq 0 ]; then printf "\r${C_GREEN}[+]${C_NC} %s\033[K\n" "$title" + else printf "\r${C_RED}[x]${C_NC} %s\033[K\n" "$title"; tail -n 40 "$log" >&2; fi + else + log_info "$title..." + "$@" >"$log" 2>&1 && rc=0 || rc=$? + [ "$rc" -ne 0 ] && tail -n 40 "$log" >&2 + fi + rm -f "$log" + return "$rc" +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Система / зависимости +# ═══════════════════════════════════════════════════════════════════════════ +detect_arch() { + case "$(uname -m)" in + x86_64 | amd64) GOARCH="amd64" ;; + aarch64 | arm64) GOARCH="arm64" ;; + *) die "Неподдерживаемая архитектура: $(uname -m)" ;; + esac +} + +pkg_install() { + if command -v apt-get >/dev/null 2>&1; then + apt-get update -y -qq >/dev/null 2>&1 || true + apt-get install -y -qq "$@" >/dev/null 2>&1 || true + elif command -v dnf >/dev/null 2>&1; then dnf install -y -q "$@" >/dev/null 2>&1 || true + elif command -v yum >/dev/null 2>&1; then yum install -y -q "$@" >/dev/null 2>&1 || true + fi +} + +ensure_base_deps() { + local missing=() b + for b in curl jq openssl tar; do command -v "$b" >/dev/null 2>&1 || missing+=("$b"); done + [ "${#missing[@]}" -eq 0 ] && return 0 + ui_spin "Установка зависимостей: ${missing[*]}" pkg_install "${missing[@]}" + missing=() + for b in curl jq openssl tar; do command -v "$b" >/dev/null 2>&1 || missing+=("$b"); done + [ "${#missing[@]}" -ne 0 ] && die "Не удалось установить: ${missing[*]}. Поставьте вручную." + return 0 +} + +# Скачать конкретную версию gum в /usr/local/bin. gum_download VERSION -> 0/1 +gum_download() { + local ver="$1" arch tmp bin + case "$GOARCH" in amd64) arch="x86_64" ;; arm64) arch="arm64" ;; *) return 1 ;; esac + local url="https://github.com/charmbracelet/gum/releases/download/v${ver}/gum_${ver}_Linux_${arch}.tar.gz" + tmp="$(mktemp -d)" + if curl -fsSL --max-time 30 "$url" | tar -xz -C "$tmp" 2>/dev/null; then + bin="$(find "$tmp" -name gum -type f 2>/dev/null | head -n1 || true)" + [ -n "$bin" ] && install -m 0755 "$bin" /usr/local/bin/gum 2>/dev/null + fi + rm -rf "$tmp" + command -v gum >/dev/null 2>&1 +} + +ensure_gum() { + command -v gum >/dev/null 2>&1 && { HAS_GUM=1; return; } + log_info "Установка gum ${GUM_VERSION}..." + if gum_download "$GUM_VERSION"; then HAS_GUM=1; return; fi + # Fallback: последний релиз (если пин недоступен/битый). + local latest + latest="$(curl -s --max-time 10 'https://api.github.com/repos/charmbracelet/gum/releases/latest' \ + | jq -r '.tag_name // empty' | sed 's/^v//' || true)" + if [ -n "$latest" ] && gum_download "$latest"; then HAS_GUM=1; return; fi + HAS_GUM=0 + log_warn "gum недоступен — текстовый режим." +} + +detect_wg_port() { + WG_PORT="" + command -v wg >/dev/null 2>&1 || return 0 + if wg show all listen-port >/dev/null 2>&1; then + WG_PORT="$(wg show all listen-port 2>/dev/null | head -n1 | awk '{print $2}' || true)" + fi + if [ -z "$WG_PORT" ] && ls /etc/wireguard/*.conf >/dev/null 2>&1; then + WG_PORT="$(grep -i ListenPort /etc/wireguard/*.conf 2>/dev/null | head -n1 | awk -F= '{print $2}' | tr -d ' ' || true)" + fi +} + +# ═══════════════════════════════════════════════════════════════════════════ +# GitHub API +# ═══════════════════════════════════════════════════════════════════════════ +gh_latest_version() { curl -s --max-time 10 "https://api.github.com/repos/${REPO}/releases/latest" | jq -r '.tag_name // empty'; } +gh_recent_versions() { curl -s --max-time 10 "https://api.github.com/repos/${REPO}/releases?per_page=6" | jq -r '.[].tag_name'; } + +resolve_asset_url() { # VERSION ASSET_NAME + local api + [ "$1" = "latest" ] && api="https://api.github.com/repos/${REPO}/releases/latest" \ + || api="https://api.github.com/repos/${REPO}/releases/tags/$1" + curl -s --max-time 15 "$api" | jq -r --arg n "$2" '.assets[] | select(.name==$n) | .browser_download_url' +} + +image_tag() { [ "$VERSION" = "latest" ] && echo "latest" || echo "$VERSION"; } + +# ═══════════════════════════════════════════════════════════════════════════ +# Конфиг: load / save / validate / overrides +# ═══════════════════════════════════════════════════════════════════════════ +is_installed() { [ -f "$CONF_FILE" ] || [ -f "$COMPOSE_FILE" ] || [ -f "$UNIT_FILE" ]; } +# shellcheck disable=SC1090 # путь к конфигу динамический, по дизайну +load_config() { [ -f "$CONF_FILE" ] && . "$CONF_FILE" || true; } + +save_config() { + mkdir -p "$APP_DIR" + cat > "$CONF_FILE" </dev/null | grep -vx "$latest" | head -4 || true)" + + if [ "$HAS_GUM" = 1 ] && [ -n "$latest" ]; then + local labels=("latest (= $latest)") tags=("latest") sel + while IFS= read -r tag; do [ -n "$tag" ] && { labels+=("$tag"); tags+=("$tag"); }; done <<< "$others" + labels+=("ввести тег вручную"); tags+=("custom") + sel=$(gum choose --header "Версия:" --header.foreground "$MD_PRIMARY" \ + --cursor "❯ " --cursor.foreground "$MD_TERTIARY" "${labels[@]}" "$CLIENTS_FILE_CONF" || true +} + +# ── WireGuard bootstrap (опциональный) ────────────────────────────────────── +# Поднимает WG-сервер на 127.0.0.1+0.0.0.0:BACKEND_PORT — бэкенд, в который +# free-turn-proxy форвардит расшифрованный UDP. Публичный порт WG в файрвол НЕ +# открываем: трафик приходит только через free-turn-proxy. + +wg_install() { + command -v wg >/dev/null 2>&1 && command -v wg-quick >/dev/null 2>&1 && return 0 + ui_spin "Установка WireGuard" pkg_install wireguard-tools + command -v wg >/dev/null 2>&1 || { ui_spin "Установка WireGuard (wireguard)" pkg_install wireguard; } + command -v wg >/dev/null 2>&1 || die "Не удалось установить WireGuard (wg/wg-quick)." +} + +wg_detect_wan() { # имя WAN-интерфейса для NAT + ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' || true +} + +wg_enable_forwarding() { + echo 'net.ipv4.ip_forward = 1' > /etc/sysctl.d/99-free-turn-proxy-wg.conf + sysctl -q -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true +} + +# Генерирует server+client ключи и пишет wg0.conf. Идемпотентно: если конфиг +# уже есть — не перетирает (ключи стабильны). +wireguard_bootstrap() { + [ "$WG_SETUP" = "1" ] || return 0 + wg_install + + if [ -f "$WG_CONF" ]; then + log_info "WireGuard уже настроен ($WG_CONF) — переустанавливаю только службу." + systemctl enable --now "wg-quick@${WG_IFACE}" >/dev/null 2>&1 || \ + ui_spin "Запуск wg-quick@${WG_IFACE}" wg-quick up "$WG_IFACE" || true + return 0 + fi + + wg_enable_forwarding + local wan; wan="$(wg_detect_wan)" + [ -z "$wan" ] && { log_warn "WAN-интерфейс не определён — NAT пропущен, настройте вручную."; } + + ( umask 077; wg genkey > "${WG_DIR}/server.key"; wg genkey > "${WG_DIR}/client.key" ) + local srv_priv srv_pub cli_priv cli_pub + srv_priv="$(cat "${WG_DIR}/server.key")"; srv_pub="$(wg pubkey < "${WG_DIR}/server.key")" + cli_priv="$(cat "${WG_DIR}/client.key")"; cli_pub="$(wg pubkey < "${WG_DIR}/client.key")" + + local postup="" postdown="" + if [ -n "$wan" ]; then + postup="PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ${wan} -j MASQUERADE" + postdown="PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ${wan} -j MASQUERADE" + fi + + ( umask 077 + cat > "$WG_CONF" </dev/null 2>&1 \ + || ui_spin "Запуск wg-quick@${WG_IFACE}" wg-quick up "$WG_IFACE" \ + || die "Не удалось поднять ${WG_IFACE}." + log_success "WireGuard ${WG_IFACE} поднят (ListenPort ${BACKEND_PORT})." + log_warn "WG слушает 0.0.0.0:${BACKEND_PORT}. Порт в firewall НЕ открывается (трафик идёт через free-turn-proxy). Если firewall отсутствует — WG доступен напрямую; закройте ${BACKEND_PORT}/udp вручную." + + # Клиентский конфиг. Endpoint = ЛОКАЛЬНЫЙ free-turn-proxy client (не IP VPS), + # т.к. WG-приложение подключается к туннелю, а не напрямую к серверу. + ( umask 077 + cat > "$WG_CLIENT_CONF" </dev/null 2>&1 || ui_spin "Установка qrencode" pkg_install qrencode + command -v qrencode >/dev/null 2>&1 || { log_warn "qrencode недоступен — QR пропущен."; return 0; } + echo + if [ "$HAS_GUM" = 1 ]; then gum style --foreground "$MD_PRIMARY" --bold "QR клиентского WireGuard-конфига (Endpoint ${WG_ENDPOINT}):" + else echo "QR клиентского WireGuard-конфига (Endpoint ${WG_ENDPOINT}):"; fi + qrencode -t ansiutf8 < "$WG_CLIENT_CONF" + log_info "Сканируйте в приложении WireGuard — конфиг готов к работе." +} + +firewall_open() { + if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + ufw allow "${LISTEN_PORT}/udp" >/dev/null 2>&1 || log_warn "ufw allow не удался." + log_success "Порт ${LISTEN_PORT}/udp открыт (UFW)." + elif command -v iptables >/dev/null 2>&1; then + if ! iptables -C INPUT -p udp --dport "$LISTEN_PORT" -j ACCEPT 2>/dev/null; then + iptables -I INPUT -p udp --dport "$LISTEN_PORT" -j ACCEPT 2>/dev/null || log_warn "iptables -I не удался." + command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save >/dev/null 2>&1 || true + fi + log_success "Порт ${LISTEN_PORT}/udp открыт (iptables)." + else + log_warn "Брандмауэр не найден — откройте ${LISTEN_PORT}/udp вручную." + fi +} + +ensure_docker() { + command -v docker >/dev/null 2>&1 && return 0 + if [ "$NONINTERACTIVE" != 1 ] && ! ui_yesno "Docker не найден. Установить?" "Y"; then + die "Для метода docker нужен Docker." + fi + ui_spin "Установка Docker" sh -c 'curl -fsSL https://get.docker.com | sh' || die "Установка Docker не удалась." + command -v docker >/dev/null 2>&1 || die "Docker не появился в PATH." +} + +healthcheck_docker() { + sleep 2 + [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)" = "true" ] \ + && { log_success "Контейнер работает."; return; } + log_warn "Контейнер не запущен. Логи:" + docker logs --tail 40 "$CONTAINER" 2>&1 || true + die "$CONTAINER не поднялся." +} + +apply_docker() { + ensure_docker + { + echo "services:" + echo " free-turn-proxy:" + echo " image: ${IMAGE}:$(image_tag)" + echo " container_name: ${CONTAINER}" + echo " network_mode: \"host\"" + echo " restart: unless-stopped" + echo " environment:" + echo " - CONNECT_ADDR=$(connect_addr)" + echo " - LISTEN_ADDR=0.0.0.0:${LISTEN_PORT}" + echo " - MODE=${PROXY_MODE}" + echo " - OBF_PROFILE=${OBF_PROFILE}" + [ "$OBF_PROFILE" != "none" ] && echo " - OBF_KEY=${OBF_KEY}" + if [ -n "$CLIENTS_FILE_CONF" ]; then + echo " - CLIENTS_FILE=${CLIENTS_FILE_CONF}" + echo " volumes:" + echo " - ${CLIENTS_FILE_CONF}:${CLIENTS_FILE_CONF}" + fi + } > "$COMPOSE_FILE" + chmod 600 "$COMPOSE_FILE" # содержит OBF_KEY: не отдавать non-root + + ( cd "$APP_DIR" && ui_spin "Загрузка образа ${IMAGE}:$(image_tag)" docker compose pull ) || die "docker compose pull не удался." + ( cd "$APP_DIR" && ui_spin "Запуск контейнера" docker compose up -d ) || die "docker compose up не удался." + healthcheck_docker +} + +download_binary() { + local url + url="$(resolve_asset_url "$VERSION" "server-linux-${GOARCH}" || true)" + [ -z "$url" ] && die "Не найден server-linux-${GOARCH} в релизе ${VERSION}." + ui_spin "Скачивание server-linux-${GOARCH} (${VERSION})" \ + curl -fL -o "${APP_DIR}/server.new" "$url" || die "Не удалось скачать $url" + chmod +x "${APP_DIR}/server.new" + mv -f "${APP_DIR}/server.new" "${APP_DIR}/server" +} + +healthcheck_systemd() { + sleep 1 + systemctl is-active --quiet "$SERVICE" && { log_success "Служба активна."; return; } + log_warn "Служба не активна. Логи:" + journalctl -u "$SERVICE" --no-pager -n 40 2>&1 || true + die "$SERVICE не запустилась." +} + +apply_systemd() { + download_binary + local args; args="-listen 0.0.0.0:${LISTEN_PORT} -connect $(connect_addr) -mode ${PROXY_MODE}" + [ "$OBF_PROFILE" != "none" ] && args="$args -obf-profile ${OBF_PROFILE} -obf-key ${OBF_KEY}" + [ -n "$CLIENTS_FILE_CONF" ] && args="$args -clients-file ${CLIENTS_FILE_CONF}" + cat > "$UNIT_FILE" </dev/null 2>&1 || true + systemctl restart "$SERVICE" || die "systemctl restart не удался." + healthcheck_systemd +} + +apply() { + mkdir -p "$APP_DIR" + init_clients_file + wireguard_bootstrap + if [ "$INSTALL_METHOD" = "docker" ]; then apply_docker; else apply_systemd; fi + [ "$OPEN_FIREWALL" = "1" ] && firewall_open + save_config +} + +print_summary() { + local ext_ip; ext_ip="$(curl -s --max-time 5 ifconfig.me || echo "IP_СЕРВЕРА")" + local add_cmd + if [ "$INSTALL_METHOD" = "docker" ]; then + add_cmd="docker exec -it ${CONTAINER} /app/server clients add " + else + add_cmd="${APP_DIR}/server -clients-file ${CLIENTS_FILE_CONF} clients add " + fi + + if [ "$HAS_GUM" = 1 ]; then + { + echo "# Готово ✓" + echo + echo "| | |" + echo "|---|---|" + echo "| **Сервер (peer)** | \`${ext_ip}:${LISTEN_PORT}\` |" + echo "| **Режим** | ${PROXY_MODE} |" + echo "| **Версия** | ${VERSION} |" + echo "| **Метод** | ${INSTALL_METHOD} |" + [ "$OBF_PROFILE" != "none" ] && echo "| **Обфускация** | ${OBF_PROFILE} |" + [ "$OBF_PROFILE" != "none" ] && echo "| **Ключ** | \`${OBF_KEY}\` |" + echo + if [ -n "$CLIENTS_FILE_CONF" ]; then + echo "Client ID auth включён. Добавить клиента:" + echo + echo '```' + echo "$add_cmd" + echo '```' + fi + if [ "$WG_SETUP" = "1" ]; then + echo "WireGuard поднят. Клиентский конфиг (\`Endpoint ${WG_ENDPOINT}\`, готов к импорту):" + echo + echo '```' + echo "$WG_CLIENT_CONF" + echo '```' + fi + echo "Документация: https://github.com/${REPO}" + } | gum format | gum style --border rounded --border-foreground "$MD_SUCCESS" --padding "1 2" --margin "1 0" + else + echo; log_success "Готово!" + echo "--------------------------------------------------------" + echo "Сервер (peer): ${ext_ip}:${LISTEN_PORT}" + echo "Режим: ${PROXY_MODE} | Версия: ${VERSION} | Метод: ${INSTALL_METHOD}" + [ "$OBF_PROFILE" != "none" ] && echo "Обфускация: ${OBF_PROFILE} | Ключ: ${OBF_KEY}" + [ -n "$CLIENTS_FILE_CONF" ] && { echo "Client ID auth включён. Добавить клиента:"; echo " $add_cmd"; } + [ "$WG_SETUP" = "1" ] && echo "WireGuard поднят. Клиентский конфиг: $WG_CLIENT_CONF (Endpoint $WG_ENDPOINT, готов)." + echo "--------------------------------------------------------" + echo "Документация: https://github.com/${REPO}" + fi + wg_render_qr +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Сценарии +# ═══════════════════════════════════════════════════════════════════════════ +flow_install() { wizard; validate_config; review_config; apply; print_summary; } + +flow_update() { + [ -f "$CONF_FILE" ] || { log_warn "Конфиг не найден — переконфигурация."; flow_install; return; } + wizard_version + [ -z "$OPEN_FIREWALL" ] && OPEN_FIREWALL=0 + validate_config; apply; print_summary +} + +do_uninstall() { # REMOVE_DIR(0|1) + if [ "$INSTALL_METHOD" = "docker" ]; then + if [ -f "$COMPOSE_FILE" ]; then + ( cd "$APP_DIR" && docker compose down ) || log_warn "docker compose down — ошибка." + fi + else + systemctl disable --now "$SERVICE" >/dev/null 2>&1 || true + rm -f "$UNIT_FILE"; systemctl daemon-reload || true + fi + log_success "Служба остановлена и удалена." + if [ "$1" = "1" ]; then rm -rf "$APP_DIR"; log_success "Каталог $APP_DIR удалён." + else log_info "Каталог сохранён: $APP_DIR"; fi +} + +flow_uninstall() { + ui_yesno "Удалить Free Turn Proxy Server?" "N" || ui_abort + local rm_dir=0 + ui_yesno "Удалить каталог ${APP_DIR} (ключи, clients.json, конфиг)?" "N" && rm_dir=1 + do_uninstall "$rm_dir" +} + +menu_existing() { + local choice + ui_menu choice "Установка найдена (метод: ${INSTALL_METHOD}, версия: ${VERSION:-?}). Действие:" "update" \ + update "Обновить версию (конфиг сохранён)" \ + reconfigure "Переконфигурировать" \ + uninstall "Удалить" \ + exit "Выход" + case "$choice" in + update) flow_update ;; + reconfigure) flow_install ;; + uninstall) flow_uninstall ;; + exit) ui_abort ;; + esac +} + +run_noninteractive() { + is_installed && load_config || true + apply_overrides + if [ "${ACTION:-install}" = "uninstall" ]; then do_uninstall "$PURGE"; return; fi + if [ "${ACTION:-}" = "update" ] && ! is_installed; then + die "--update: установка не найдена. Запустите без --update." + fi + validate_config + [ -z "$OPEN_FIREWALL" ] && OPEN_FIREWALL=1 + log_info "Применяю конфигурацию (метод: ${INSTALL_METHOD})..." + apply; print_summary +} + +# ═══════════════════════════════════════════════════════════════════════════ +# CLI +# ═══════════════════════════════════════════════════════════════════════════ +usage() { + cat <