#!/usr/bin/env bash # Free Turn Proxy - установщик и контроллер сервера. # Сгенерирован из scripts/control/*.sh. set -Eeuo pipefail umask 077 PROTO_VERSION=2 # Пути PREFIX="${FT_PREFIX:-/opt/free-turn-proxy}" STATE_FILE="$PREFIX/state" PIDFILE="$PREFIX/proxy.pid" LOCKFILE="$PREFIX/control.lock" PEERS_LOCK="$PREFIX/peers.lock" LOGFILE="$PREFIX/server.log" VERFILE="$PREFIX/version" ARGSFILE="$PREFIX/run.args" ENVFILE="$PREFIX/run.env" LAUNCHER="$PREFIX/launch.sh" CLIENTSFILE="$PREFIX/clients.json" OWNERCIDFILE="$PREFIX/owner.cid" SHARE_DIR="$PREFIX/share" APP_DIR="$PREFIX" CONF_FILE="${PREFIX}/install.conf" # Службы и контейнеры SERVICE="free-turn-proxy.service" UNIT_NAME="$SERVICE" UNIT_FILE="/etc/systemd/system/${SERVICE}" UNIT_PATH="$UNIT_FILE" LEGACY_UNIT="vk-turn-proxy.service" COMPOSE_FILE="${PREFIX}/docker-compose.yml" CONTAINER="free-turn-proxy" AWG_CONTAINER="freeturn-awg" GUM_VERSION="0.17.0" # AmneziaWG и WireGuard AWG_DIR="${PREFIX}/awg" AWG_IFACE="${FT_AWG_IFACE:-ftawg0}" AWG_CONF="${AWG_DIR}/${AWG_IFACE}.conf" AWG_NET="10.13.13" WG_DIR="${FT_WG_DIR:-/etc/wireguard}" WG_IFACE="${FT_WG_IFACE:-ft-wg0}" WG_CONF="${WG_DIR}/${WG_IFACE}.conf" WG_MARKER="# managed-by: free-turn-proxy" WG_NET="${FT_WG_NET:-10.13.13}" WG_CLIENT_CONF="${PREFIX}/wireguard-client.conf" WG_MTU="${FT_WG_MTU:-1280}" CLIENTS_DIR="${PREFIX}/clients" CLIENTS_META="${CLIENTS_DIR}/clients.list" # Репозитории и ссылки REPO="samosvalishe/free-turn-proxy" IMAGE="ghcr.io/${REPO}" AWG_IMAGE="ghcr.io/samosvalishe/freeturn-awg:latest" RELEASES_URL="https://github.com/${REPO}/releases" BASE_URL="${RELEASES_URL}/latest/download" # Цвета (Material Design 3 + ANSI fallback) MD_PRIMARY="#D0BCFF" MD_SECONDARY="#CCC2DC" MD_TERTIARY="#EFB8C8" MD_SUCCESS="#81C784" MD_ERROR="#F2B8B5" BANNER_GRADIENT=("#EADDFF" "#D0BCFF" "#B69DF8" "#9A82DB" "#7F67BE" "#6750A4") C_RED='\033[0;31m' C_GREEN='\033[0;32m' C_YELLOW='\033[1;33m' C_CYAN='\033[0;36m' C_NC='\033[0m' # Конфигурация сервера INSTALL_METHOD="docker" # docker | systemd INSTALL_FREETURN=1 # 1 = ставить релей FreeTurn INSTALL_AWG=1 # 1 = ставить AmneziaWG 3.1 INSTALL_WG=0 # 1 = ставить WireGuard VERSION="latest" PROVIDER="vk" PROXY_MODE="udp" # udp | tcp BACKEND_TYPE="awg" # awg | wg | custom BACKEND_PORT="51820" LISTEN_PORT="56000" AWG_DIRECT_PORT=1 # 1 = открыть BACKEND_PORT/udp в файрволе OBF_PROFILE="rtpopus3" # rtpopus3 | rtpopus2 | rtpopus | none OBF_KEY="" CLIENTS_FILE_CONF="" WG_ENDPOINT="127.0.0.1:9000" # AmneziaWG 3.1 параметры AWG_JC="" AWG_JMIN="" AWG_JMAX="" AWG_S1="" AWG_S2="" AWG_S3="" AWG_S4="" AWG_H1="" AWG_H2="" AWG_H3="" AWG_H4="" AWG_HPK="" # Состояние рантайма HAS_GUM=0 GOARCH="" WG_PORT="" NONINTERACTIVE=0 OPEN_FIREWALL="" PURGE=0 ACTION="" CLIENT_SUBCOMMAND="" CLIENT_SUBARG="" UNINSTALL_TARGET="all" # freeturn | awg | all OVERRIDES=() PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}" export PATH valid_port() { [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ]; } valid_hex64() { [[ "$1" =~ ^[0-9a-fA-F]{64}$ ]]; } valid_endpoint() { [[ "$1" =~ ^(\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}$ ]]; } # ───────────────────────────────────────────────────────────────────────────── # Модуль: 10-proto.sh # ───────────────────────────────────────────────────────────────────────────── # JSON RPC v2 для Android ServerControl.kt (буферизация в 1 JSON-объект, trap EXIT) _DATA=() # "key": _LOGS=() # заковыченные JSON-строки _EMITTED=0 # защита от двойной печати _STAGE="init" _IS_RPC=0 # 1 = режим JSON RPC для Android / API stage() { _STAGE="$1"; } esc() { local s=${1-} s=${s//\\/\\\\} s=${s//\"/\\\"} s=${s//$'\t'/\\t} s=${s//$'\r'/\\r} s=${s//$'\n'/\\n} s=${s//[$'\001'-$'\037'$'\177']/} printf '%s' "$s" } d_str() { _DATA+=("\"$1\":\"$(esc "${2-}")\""); } d_num() { _DATA+=("\"$1\":${2:-0}"); } d_bool() { _DATA+=("\"$1\":$2"); } d_raw() { _DATA+=("\"$1\":$2"); } log() { if [ "$_IS_RPC" = 1 ]; then _LOGS+=("\"$(esc "$*")\"") else log_info "$*" fi } _join() { local IFS=','; printf '%s' "${*-}"; } _data_json() { if [ "${#_DATA[@]}" -gt 0 ]; then _join "${_DATA[@]}"; fi; } _logs_json() { if [ "${#_LOGS[@]}" -gt 0 ]; then _join "${_LOGS[@]}"; fi; } ok() { [ "$_EMITTED" -eq 1 ] && return 0 _EMITTED=1 trap - EXIT printf '{"proto":%d,"result":"ok","data":{%s},"logs":[%s]}\n' \ "$PROTO_VERSION" "$(_data_json)" "$(_logs_json)" } fail() { local code=$1 msg=${2:-$1} [ "$_EMITTED" -eq 1 ] && exit 1 _EMITTED=1 trap - EXIT printf '{"proto":%d,"result":"err","code":"%s","msg":"%s","stage":"%s","logs":[%s]}\n' \ "$PROTO_VERSION" "$code" "$(esc "$msg")" "$_STAGE" "$(_logs_json)" exit 1 } _on_exit() { local rc=$? [ "$_EMITTED" -eq 1 ] && return [ "$_IS_RPC" -ne 1 ] && return _EMITTED=1 printf '{"proto":%d,"result":"err","code":"internal","msg":"unexpected exit %d","stage":"%s","logs":[%s]}\n' \ "$PROTO_VERSION" "$rc" "$_STAGE" "$(_logs_json)" } trap _on_exit EXIT # ───────────────────────────────────────────────────────────────────────────── # Модуль: 20-ui.sh # ───────────────────────────────────────────────────────────────────────────── # UI-слой: обёртки над gum с plain-fallback при отсутствии gum. log_info() { [ "$_IS_RPC" = 1 ] && { _LOGS+=("\"$(esc "$*")\""); return 0; } if [ "$HAS_GUM" = 1 ]; then gum log --level info -- "$1"; else echo -e "${C_CYAN}[*]${C_NC} $1"; fi } log_warn() { [ "$_IS_RPC" = 1 ] && { _LOGS+=("\"$(esc "WARN: $*")\""); return 0; } if [ "$HAS_GUM" = 1 ]; then gum log --level warn -- "$1"; else echo -e "${C_YELLOW}[!]${C_NC} $1" >&2; fi } log_error() { [ "$_IS_RPC" = 1 ] && { _LOGS+=("\"$(esc "ERROR: $*")\""); return 0; } if [ "$HAS_GUM" = 1 ]; then gum log --level error -- "$1"; else echo -e "${C_RED}[x]${C_NC} $1" >&2; fi } log_success() { [ "$_IS_RPC" = 1 ] && { _LOGS+=("\"$(esc "$*")\""); return 0; } if [ "$HAS_GUM" = 1 ]; then gum style --foreground "$MD_SUCCESS" "✔ $1"; else echo -e "${C_GREEN}[+]${C_NC} $1"; fi } die() { if [ "$_IS_RPC" = 1 ]; then fail internal "$1"; fi log_error "$1" exit 1 } ui_abort() { log_info "Отменено."; exit 0; } ui_banner() { [ "$_IS_RPC" = 1 ] && return 0 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" \ "FreeTurn & AmneziaWG 3.1 · установщик сервера" else echo -e "${C_CYAN}"; printf '%s\n' "${art[@]}"; echo -e "${C_NC}" echo " FreeTurn & AmneziaWG 3.1 · установщик сервера"; echo fi } ui_note() { [ "$_IS_RPC" = 1 ] && return 0 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() { 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" "$log" 2>&1 ) & local pid=$! gum spin --spinner dot --spinner.foreground "$MD_PRIMARY" --title "$title" \ -- bash -c 'while kill -0 "$1" 2>/dev/null; do sleep 0.1; done' _ "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null && rc=0 || rc=$? 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 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" 2>/dev/null && 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" } render_qr_file() { local file="$1" title="$2" [ "$_IS_RPC" = 1 ] && return 0 [ -f "$file" ] || return 0 command -v qrencode >/dev/null 2>&1 || pkg_install qrencode || true if command -v qrencode >/dev/null 2>&1; then echo if [ "$HAS_GUM" = 1 ]; then gum style --foreground "$MD_PRIMARY" --bold "$title" else echo -e "${C_CYAN}${title}${C_NC}"; fi qrencode -t ansiutf8 -m 1 < "$file" else log_warn "qrencode не установлен - QR пропущен." fi } render_qr_text() { local text="$1" title="$2" [ "$_IS_RPC" = 1 ] && return 0 [ -n "$text" ] || return 0 command -v qrencode >/dev/null 2>&1 || pkg_install qrencode || true if command -v qrencode >/dev/null 2>&1; then echo if [ "$HAS_GUM" = 1 ]; then gum style --foreground "$MD_PRIMARY" --bold "$title" else echo -e "${C_CYAN}${title}${C_NC}"; fi qrencode -t ansiutf8 -m 1 <<< "$text" else log_warn "qrencode не установлен - QR пропущен." fi } # ───────────────────────────────────────────────────────────────────────────── # Модуль: 30-system.sh # ───────────────────────────────────────────────────────────────────────────── # Системные примитивы: блокировки flock, пакетный менеджер, детекция окружения. with_lock() { command -v flock >/dev/null 2>&1 || return 0 [ -d "$PREFIX" ] || mkdir -p "$PREFIX" 2>/dev/null || return 0 [ -w "$PREFIX" ] || return 0 exec 8>"$LOCKFILE" 2>/dev/null || return 0 flock -w 300 8 2>/dev/null || true } with_peers_lock() { command -v flock >/dev/null 2>&1 || return 0 [ -d "$PREFIX" ] || mkdir -p "$PREFIX" 2>/dev/null || return 0 exec 9>"$PEERS_LOCK" 2>/dev/null || return 0 flock -w 60 9 2>/dev/null || true } pkg_mgr() { local m for m in apt-get dnf yum apk pacman zypper; do if command -v "$m" >/dev/null 2>&1; then echo "$m"; return 0; fi done return 1 } _apt_wait_lock() { command -v fuser >/dev/null 2>&1 || return 0 local i=0 while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \ || fuser /var/lib/dpkg/lock >/dev/null 2>&1; do [ "$i" -ge 60 ] && return 0 log "ожидание dpkg lock ($((i * 2))s)..." sleep 2 i=$((i + 1)) done } pkg_install() { local mgr; mgr=$(pkg_mgr) || return 1 case "$mgr" in apt-get) _apt_wait_lock DEBIAN_FRONTEND=noninteractive NEEDRESTART_SUSPEND=1 \ apt-get -o DPkg::Lock::Timeout=300 update -qq >/dev/null 2>&1 || true DEBIAN_FRONTEND=noninteractive NEEDRESTART_SUSPEND=1 \ apt-get -o DPkg::Lock::Timeout=300 install -y -qq "$@" >/dev/null 2>&1 ;; dnf) dnf install -y -q "$@" >/dev/null 2>&1 ;; yum) yum install -y -q "$@" >/dev/null 2>&1 ;; apk) apk add --no-cache "$@" >/dev/null 2>&1 ;; pacman) pacman -Sy --noconfirm "$@" >/dev/null 2>&1 ;; zypper) zypper --non-interactive install "$@" >/dev/null 2>&1 ;; *) return 1 ;; esac } pkg_remove() { local mgr; mgr=$(pkg_mgr) || return 1 case "$mgr" in apt-get) _apt_wait_lock DEBIAN_FRONTEND=noninteractive NEEDRESTART_SUSPEND=1 \ apt-get -o DPkg::Lock::Timeout=300 remove -y -qq "$@" >/dev/null 2>&1 ;; dnf) dnf remove -y -q "$@" >/dev/null 2>&1 ;; yum) yum remove -y -q "$@" >/dev/null 2>&1 ;; apk) apk del "$@" >/dev/null 2>&1 ;; pacman) pacman -Rns --noconfirm "$@" >/dev/null 2>&1 ;; zypper) zypper --non-interactive remove "$@" >/dev/null 2>&1 ;; *) return 1 ;; esac } export -f pkg_mgr _apt_wait_lock pkg_install pkg_remove 2>/dev/null || true 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 if [ "$_IS_RPC" = 1 ]; then pkg_install "${missing[@]}" || true else ui_spin "Установка зависимостей: ${missing[*]}" pkg_install "${missing[@]}" fi missing=() for b in curl jq openssl tar; do command -v "$b" >/dev/null 2>&1 || missing+=("$b") done if [ "${#missing[@]}" -ne 0 ]; then die "Не удалось установить: ${missing[*]}. Установите их вручную." fi } gum_download() { local ver="$1" arch tmp bin case "$(uname -m 2>/dev/null || echo "")" in x86_64|amd64) arch="x86_64" ;; aarch64|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 --connect-timeout 10 --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() { [ "$_IS_RPC" = 1 ] && { HAS_GUM=0; return 0; } command -v gum >/dev/null 2>&1 && { HAS_GUM=1; return 0; } log_info "Установка gum ${GUM_VERSION}..." if gum_download "$GUM_VERSION"; then HAS_GUM=1; return 0; fi local latest latest="$(curl -s --max-time 10 'https://api.github.com/repos/charmbracelet/gum/releases/latest' \ | jq -r '.tag_name // empty' 2>/dev/null | sed 's/^v//' || true)" if [ -n "$latest" ] && gum_download "$latest"; then HAS_GUM=1; return 0; fi HAS_GUM=0 log_warn "gum недоступен - классический текстовый режим." } compose_cmd() { if docker compose version >/dev/null 2>&1; then docker compose "$@" elif command -v docker-compose >/dev/null 2>&1; then docker-compose "$@" else die "Docker Compose не найден." fi } export -f compose_cmd 2>/dev/null || true _install_compose_step() { local mgr; mgr=$(pkg_mgr 2>/dev/null || true) if [ -n "$mgr" ]; then case "$mgr" in apt-get) pkg_install docker-compose-v2 || pkg_install docker-compose-plugin || pkg_install docker-compose || true ;; dnf|yum) pkg_install docker-compose-plugin || pkg_install docker-compose || true ;; apk) pkg_install docker-cli-compose || pkg_install docker-compose || true ;; *) pkg_install docker-compose || true ;; esac fi if command -v docker-compose >/dev/null 2>&1 && ! docker compose version >/dev/null 2>&1; then mkdir -p /usr/local/lib/docker/cli-plugins ln -sf "$(command -v docker-compose)" /usr/local/lib/docker/cli-plugins/docker-compose 2>/dev/null || true fi if docker compose version >/dev/null 2>&1; then return 0 fi local m; m=$(uname -m 2>/dev/null || echo "x86_64") case "$m" in x86_64|amd64) m="x86_64" ;; aarch64|arm64) m="aarch64" ;; armv7*) m="armv7" ;; armv6*) m="armv6" ;; riscv64) m="riscv64" ;; s390x) m="s390x" ;; ppc64le) m="ppc64le" ;; *) return 1 ;; esac local plugin_dir="/usr/local/lib/docker/cli-plugins" mkdir -p "$plugin_dir" /usr/local/bin if curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$m" \ -o "$plugin_dir/docker-compose" 2>/dev/null; then chmod +x "$plugin_dir/docker-compose" ln -sf "$plugin_dir/docker-compose" /usr/local/bin/docker-compose 2>/dev/null || true return 0 fi return 1 } export -f _install_compose_step 2>/dev/null || true ensure_compose() { if docker compose version >/dev/null 2>&1; then return 0 fi if command -v docker-compose >/dev/null 2>&1; then mkdir -p /usr/local/lib/docker/cli-plugins ln -sf "$(command -v docker-compose)" /usr/local/lib/docker/cli-plugins/docker-compose 2>/dev/null || true if docker compose version >/dev/null 2>&1; then return 0 fi fi if [ "$_IS_RPC" = 1 ]; then _install_compose_step >/dev/null 2>&1 || fail compose_install_failed "docker compose install failed" else ui_spin "Установка Docker Compose" _install_compose_step || die "Установка Docker Compose не удалась." fi if ! docker compose version >/dev/null 2>&1 && ! command -v docker-compose >/dev/null 2>&1; then die "Docker Compose не появился в системе." fi } ensure_docker() { if ! command -v docker >/dev/null 2>&1; then if [ "$NONINTERACTIVE" != 1 ] && [ "$_IS_RPC" != 1 ]; then if ! ui_yesno "Docker не найден. Установить автоматически?" "Y"; then die "Для выбранного метода требуется Docker." fi fi if [ "$_IS_RPC" = 1 ]; then curl -fsSL https://get.docker.com | sh >/dev/null 2>&1 || fail docker_install_failed "docker install failed" else ui_spin "Установка Docker" sh -c 'curl -fsSL https://get.docker.com | sh' || die "Установка Docker не удалась." fi command -v docker >/dev/null 2>&1 || die "Docker не появился в PATH." fi if ! docker info >/dev/null 2>&1; then systemctl start docker >/dev/null 2>&1 || service docker start >/dev/null 2>&1 || true fi ensure_compose } _mips_is_le() { local hex; hex=$(printf '\1\0' | od -An -tx2 -N2 2>/dev/null | tr -d ' \n') [ "$hex" = "0001" ] } detect_arch() { local m; m=$(uname -m 2>/dev/null || echo "") case "$m" in x86_64|amd64) GOARCH="amd64"; echo "server-linux-amd64" ;; aarch64|arm64) GOARCH="arm64"; echo "server-linux-arm64" ;; armv7l|armv6l|armv5*|arm) GOARCH="arm"; echo "server-linux-arm" ;; i386|i486|i586|i686) GOARCH="386"; echo "server-linux-386" ;; riscv64) GOARCH="riscv64"; echo "server-linux-riscv64" ;; mips64|mips64le) if _mips_is_le; then GOARCH="mips64le"; echo "server-linux-mips64le"; else echo ""; return 1; fi ;; mips|mipsel|mipsle) if _mips_is_le; then GOARCH="mipsle"; echo "server-linux-mipsle"; else GOARCH="mips"; echo "server-linux-mips"; fi ;; *) echo ""; return 1 ;; esac } ensure_arch() { local a; a=$(detect_arch) || { if [ "$_IS_RPC" = 1 ]; then fail unsupported_arch "unsupported arch: $(uname -m)"; else die "Неподдерживаемая архитектура: $(uname -m)"; fi } case "$a" in server-linux-amd64) GOARCH="amd64" ;; server-linux-arm64) GOARCH="arm64" ;; server-linux-arm) GOARCH="arm" ;; server-linux-386) GOARCH="386" ;; server-linux-riscv64) GOARCH="riscv64" ;; server-linux-mips64le) GOARCH="mips64le" ;; server-linux-mipsle) GOARCH="mipsle" ;; server-linux-mips) GOARCH="mips" ;; esac } detect_virt() { local v if command -v systemd-detect-virt >/dev/null 2>&1; then v=$(systemd-detect-virt 2>/dev/null || true) [ -n "$v" ] && { echo "$v"; return 0; } fi [ -f /.dockerenv ] && { echo docker; return 0; } grep -qa 'container=lxc' /proc/1/environ 2>/dev/null && { echo lxc; return 0; } echo none } wg_kernel_ok() { [ -d /sys/module/wireguard ] && return 0 modprobe wireguard >/dev/null 2>&1 } has_systemd() { command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] } conflict_warp() { command -v warp-cli >/dev/null 2>&1 && return 0 ip link show 2>/dev/null | grep -qi 'CloudflareWARP' && return 0 ls "$WG_DIR"/wgcf*.conf >/dev/null 2>&1 && return 0 return 1 } conflict_x3ui() { [ -d /etc/x-ui ] || [ -d /usr/local/x-ui ] && return 0 command -v x-ui >/dev/null 2>&1 && return 0 has_systemd && systemctl list-unit-files 2>/dev/null | grep -qi '^x-ui' && return 0 return 1 } conflict_wgeasy() { if command -v docker >/dev/null 2>&1; then docker ps --format '{{.Names}} {{.Image}}' 2>/dev/null | grep -qi 'wg-easy' && return 0 fi [ -n "${WG_HOST:-}" ] && return 0 return 1 } conflict_tailscale() { command -v tailscale >/dev/null 2>&1 && return 0 ip link show 2>/dev/null | grep -qi 'tailscale' && return 0 return 1 } other_wg_ifaces_csv() { command -v wg >/dev/null 2>&1 || return 0 local i out="" first=1 for i in $(wg show interfaces 2>/dev/null || true); do [ "$i" = "$WG_IFACE" ] && continue [ "$i" = "$AWG_IFACE" ] && continue [ "$first" -eq 1 ] && first=0 || out="$out," out="$out\"$(esc "$i")\"" done printf '%s' "$out" } port_owner() { local proto=$1 port=$2 letter line name case "$proto" in tcp) letter=t ;; udp) letter=u ;; *) echo unknown; return 0 ;; esac if command -v ss >/dev/null 2>&1; then line=$(ss -H -ln"$letter"p 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {print; exit}' || true) [ -z "$line" ] && { echo free; return 0; } name=$(printf '%s' "$line" | sed -nE 's/.*users:\(\("([^"]+)".*/\1/p') echo "${name:-unknown}"; return 0 fi echo unknown } port_pid() { local proto=$1 port=$2 letter line case "$proto" in tcp) letter=t ;; udp) letter=u ;; *) return 0 ;; esac command -v ss >/dev/null 2>&1 || return 0 line=$(ss -H -ln"$letter"p 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {print; exit}' || true) printf '%s' "$line" | sed -nE 's/.*pid=([0-9]+).*/\1/p' } pid_is_ours() { local exe; exe=$(readlink "/proc/$1/exe" 2>/dev/null || true) case "$exe" in "$PREFIX"/*) return 0 ;; esac return 1 } get_public_ip() { local ip="" url for url in "https://api.ipify.org" "https://icanhazip.com" "https://ifconfig.me/ip" "https://ident.me"; do ip="$(curl -4 -fsSL --connect-timeout 3 --max-time 5 "$url" 2>/dev/null | tr -d ' \r\n' || true)" if [[ "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then echo "$ip"; return 0 fi done echo "IP_СЕРВЕРА" } detect_wg_port() { WG_PORT="" if [ -f "$AWG_CONF" ]; then WG_PORT="$(sed -n 's/^[[:space:]]*ListenPort[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" | head -n1 | tr -d ' \r')" [ -n "$WG_PORT" ] && return 0 fi if [ -f "$WG_CONF" ]; then WG_PORT="$(sed -n 's/^[[:space:]]*ListenPort[[:space:]]*=[[:space:]]*//Ip' "$WG_CONF" | head -n1 | tr -d ' \r')" [ -n "$WG_PORT" ] && return 0 fi 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 } # ───────────────────────────────────────────────────────────────────────────── # Модуль: 40-state.sh # ───────────────────────────────────────────────────────────────────────────── # Сохранение, загрузка и валидация конфигурации сервера (install.conf и state). state_get() { [ -f "$STATE_FILE" ] || return 0 sed -n "s/^$1=//p" "$STATE_FILE" 2>/dev/null | head -n1 } state_set() { local k=$1 v=$2 tmp [ -d "$PREFIX" ] || mkdir -p "$PREFIX" 2>/dev/null || return 0 [ -w "$PREFIX" ] || return 0 tmp=$(mktemp "$STATE_FILE.XXXXXX" 2>/dev/null) || return 0 if [ -f "$STATE_FILE" ]; then grep -v "^$k=" "$STATE_FILE" > "$tmp" 2>/dev/null || true fi printf '%s=%s\n' "$k" "$v" >> "$tmp" chmod 0600 "$tmp" mv -f "$tmp" "$STATE_FILE" } is_installed() { [ -f "$CONF_FILE" ] || [ -f "$COMPOSE_FILE" ] || [ -f "$UNIT_FILE" ] || [ -f "$AWG_CONF" ] } # shellcheck disable=SC1090 load_config() { [ -f "$CONF_FILE" ] && . "$CONF_FILE" || true if [ -n "${AWG_SETUP:-}" ]; then INSTALL_AWG="$AWG_SETUP"; fi if [ -n "${WG_SETUP:-}" ]; then INSTALL_WG="$WG_SETUP"; fi return 0 } save_config() { mkdir -p "$APP_DIR" 2>/dev/null || true cat > "$CONF_FILE" </dev/null 2>&1; then curl -fsSL --connect-timeout 15 --max-time 300 -o "$out" "$url" elif command -v wget >/dev/null 2>&1; then wget -q --timeout=300 -O "$out" "$url" else fail download_failed "neither curl nor wget present" fi } _resolve_version() { local url=$1 loc="" if command -v curl >/dev/null 2>&1; then loc=$(curl -sI "$url" 2>/dev/null | awk -F': ' 'tolower($1)=="location"{print $2}' | tr -d '\r' | head -n1) elif command -v wget >/dev/null 2>&1; then loc=$(wget --spider --server-response "$url" 2>&1 | awk '/[Ll]ocation:/{print $2}' | tr -d '\r' | head -n1) fi printf '%s' "$loc" | sed -nE 's#.*/releases/download/([^/]+)/.*#\1#p' } gh_latest_version() { curl -s --max-time 10 "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null | jq -r '.tag_name // empty' 2>/dev/null || true } gh_recent_versions() { curl -s --max-time 10 "https://api.github.com/repos/${REPO}/releases?per_page=6" 2>/dev/null | jq -r '.[].tag_name' 2>/dev/null || true } resolve_asset_url() { local ver=$1 asset=$2 api [ "$ver" = "latest" ] && api="https://api.github.com/repos/${REPO}/releases/latest" \ || api="https://api.github.com/repos/${REPO}/releases/tags/$ver" curl -s --max-time 15 "$api" 2>/dev/null | jq -r --arg n "$asset" '.assets[] | select(.name==$n) | .browser_download_url' 2>/dev/null || true } image_tag() { [ "$VERSION" = "latest" ] && echo "latest" || echo "$VERSION" } _verify_download() { local f=$1 want=${2:-} size magic got size=$(wc -c < "$f" 2>/dev/null || echo 0) [ "$size" -lt 100000 ] && { rm -f "$f"; fail too_small "Файл слишком мал ($size байт)"; } if command -v od >/dev/null 2>&1; then magic=$(od -An -tx1 -N4 "$f" 2>/dev/null | tr -d ' \n') if [ -n "$magic" ] && [ "$magic" != "7f454c46" ]; then rm -f "$f" fail not_elf "Файл не является бинарником ELF (magic: $magic)" fi fi if [ -n "$want" ] && command -v sha256sum >/dev/null 2>&1; then got=$(sha256sum "$f" | awk '{print $1}') if [ -n "$got" ] && [ "$got" != "$want" ]; then rm -f "$f" fail sha_mismatch "Не совпадает sha256: ожидался $want, получен $got" fi fi } download_binary() { ensure_arch local asset="server-linux-${GOARCH}" local url tmp url="$(resolve_asset_url "$VERSION" "$asset" || true)" [ -z "$url" ] && url="${BASE_URL}/${asset}" tmp=$(mktemp "${APP_DIR}/server.new.XXXXXX" 2>/dev/null) || tmp="${APP_DIR}/server.new.$$" if [ "$_IS_RPC" = 1 ]; then _dl "$url" "$tmp" || fail download_failed "Не удалось скачать $url" else ui_spin "Скачивание ${asset} (${VERSION})" _dl "$url" "$tmp" || die "Не удалось скачать $url" fi _verify_download "$tmp" "${ARG_SHA256:-}" chmod 0755 "$tmp" with_lock [ -f "${APP_DIR}/server" ] && cp -f "${APP_DIR}/server" "${APP_DIR}/server.bak" 2>/dev/null || true mv -f "$tmp" "${APP_DIR}/server" cp -f "${APP_DIR}/server" "${APP_DIR}/${asset}" 2>/dev/null || true echo "$VERSION" > "$VERFILE" } # ───────────────────────────────────────────────────────────────────────────── # Модуль: 60-backend.sh # ───────────────────────────────────────────────────────────────────────────── # VPN-бэкенды: AmneziaWG (AWG 3.1) и классический WireGuard (ft-wg0). init_awg_params() { if [ -f "$AWG_CONF" ]; then AWG_JC="$(sed -n 's/^[[:space:]]*Jc[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" | head -n1 | tr -d ' \r')" AWG_HPK="$(sed -n 's/^[[:space:]]*HeaderProtectionKey[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" | head -n1 | tr -d ' \r')" if [ -n "$AWG_JC" ] && [ -n "$AWG_HPK" ]; then AWG_JMIN="$(sed -n 's/^[[:space:]]*Jmin[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" | head -n1 | tr -d ' \r')" AWG_JMAX="$(sed -n 's/^[[:space:]]*Jmax[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" | head -n1 | tr -d ' \r')" AWG_S1="$(sed -n 's/^[[:space:]]*S1[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" | head -n1 | tr -d ' \r')" AWG_S2="$(sed -n 's/^[[:space:]]*S2[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" | head -n1 | tr -d ' \r')" AWG_S3="$(sed -n 's/^[[:space:]]*S3[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" | head -n1 | tr -d ' \r')" AWG_S4="$(sed -n 's/^[[:space:]]*S4[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" | head -n1 | tr -d ' \r')" AWG_H1="$(sed -n 's/^[[:space:]]*H1[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" | head -n1 | tr -d ' \r')" AWG_H2="$(sed -n 's/^[[:space:]]*H2[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" | head -n1 | tr -d ' \r')" AWG_H3="$(sed -n 's/^[[:space:]]*H3[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" | head -n1 | tr -d ' \r')" AWG_H4="$(sed -n 's/^[[:space:]]*H4[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" | head -n1 | tr -d ' \r')" return 0 fi fi AWG_JC=$((RANDOM % 3 + 4)) AWG_JMIN=10 AWG_JMAX=50 local s1 s2 s3 s4=12 s1=$((RANDOM % 136 + 15)) while :; do s2=$((RANDOM % 136 + 15)) [ "$s2" -ne "$s1" ] && [ "$s2" -ne "$s4" ] && [ $((s1 + 148)) -ne $((s2 + 92)) ] && break done while :; do s3=$((RANDOM % 50 + 15)) [ "$s3" -ne "$s1" ] && [ "$s3" -ne "$s2" ] && [ "$s3" -ne "$s4" ] && \ [ $((s1 + 148)) -ne $((s3 + 64)) ] && [ $((s2 + 92)) -ne $((s3 + 64)) ] && break done AWG_S1="$s1" AWG_S2="$s2" AWG_S3="$s3" AWG_S4="$s4" AWG_H1=1 AWG_H2=2 AWG_H3=3 AWG_H4=4 AWG_HPK="$(openssl rand 32 2>/dev/null | base64 | tr -d '\r\n')" } ensure_awg_image() { [ "$INSTALL_AWG" = "1" ] || return 0 command -v docker >/dev/null 2>&1 || return 0 if docker image inspect "${AWG_IMAGE}" >/dev/null 2>&1; then return 0 fi if docker pull "${AWG_IMAGE}" >/dev/null 2>&1; then return 0 fi local script_dir repo_dir script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo "")" repo_dir="$(cd "$script_dir/.." 2>/dev/null && pwd || echo "")" if [ -f "$repo_dir/docker/awg/Dockerfile" ]; then if [ "$_IS_RPC" = 1 ]; then docker build -t "${AWG_IMAGE}" -f "$repo_dir/docker/awg/Dockerfile" "$repo_dir" >/dev/null 2>&1 || true else ui_spin "Сборка Docker-образа AmneziaWG" docker build -t "${AWG_IMAGE}" -f "$repo_dir/docker/awg/Dockerfile" "$repo_dir" || true fi if docker image inspect "${AWG_IMAGE}" >/dev/null 2>&1; then return 0 fi fi local bdir raw_url bdir="$(mktemp -d)" raw_url="https://raw.githubusercontent.com/${REPO}/master" if curl -fsSL "$raw_url/docker/awg/Dockerfile" -o "$bdir/Dockerfile" 2>/dev/null && \ mkdir -p "$bdir/docker/awg" && \ curl -fsSL "$raw_url/docker/awg/start.sh" -o "$bdir/docker/awg/start.sh" 2>/dev/null; then if [ "$_IS_RPC" = 1 ]; then docker build -t "${AWG_IMAGE}" "$bdir" >/dev/null 2>&1 || true else ui_spin "Сборка Docker-образа AmneziaWG" docker build -t "${AWG_IMAGE}" "$bdir" || true fi fi rm -rf "$bdir" docker image inspect "${AWG_IMAGE}" >/dev/null 2>&1 || die "Не удалось получить или собрать образ ${AWG_IMAGE}." } generate_awg_keypair() { local priv="" pub="" if command -v awg >/dev/null 2>&1; then priv="$(awg genkey 2>/dev/null || true)" [ -n "$priv" ] && pub="$(awg pubkey <<< "$priv" 2>/dev/null || true)" elif [ "$(docker inspect -f '{{.State.Running}}' "$AWG_CONTAINER" 2>/dev/null || echo false)" = "true" ]; then priv="$(docker exec "$AWG_CONTAINER" awg genkey 2>/dev/null || true)" [ -n "$priv" ] && pub="$(docker exec -i "$AWG_CONTAINER" awg pubkey <<< "$priv" 2>/dev/null || true)" elif command -v docker >/dev/null 2>&1; then ensure_awg_image priv="$(docker run --rm "${AWG_IMAGE}" awg genkey 2>/dev/null || true)" [ -n "$priv" ] && pub="$(docker run --rm -i "${AWG_IMAGE}" awg pubkey <<< "$priv" 2>/dev/null || true)" elif command -v wg >/dev/null 2>&1; then priv="$(wg genkey 2>/dev/null || true)" [ -n "$priv" ] && pub="$(wg pubkey <<< "$priv" 2>/dev/null || true)" fi [ -z "$priv" ] || [ -z "$pub" ] && die "Не удалось сгенерировать ключи VPN." echo "$priv $pub" } awg_present() { [ -f "$AWG_CONF" ]; } awg_port() { [ -f "$AWG_CONF" ] || return 0 sed -n 's/^[[:space:]]*ListenPort[[:space:]]*=[[:space:]]*//Ip' "$AWG_CONF" 2>/dev/null \ | head -n1 | sed 's/[#;].*//' | tr -d ' \r' } wg_present() { [ -f "$WG_CONF" ] || [ -f "$WG_DIR/wg0.conf" ]; } wg_is_ours() { [ -f "$WG_CONF" ] || return 1 grep -qF "$WG_MARKER" "$WG_CONF" 2>/dev/null } wg_port() { local p="" if command -v wg >/dev/null 2>&1; then p=$(wg show "$WG_IFACE" listen-port 2>/dev/null | tr -d ' \r' || true) case "$p" in ''|0) p="" ;; esac if [ -n "$p" ]; then echo "$p"; return 0; fi fi if [ -f "$WG_CONF" ]; then p=$(sed -n 's/^[[:space:]]*ListenPort[[:space:]]*=[[:space:]]*//Ip' "$WG_CONF" 2>/dev/null \ | head -n1 | sed 's/[#;].*//' | tr -d ' \r') case "$p" in ''|*[!0-9]*) p="" ;; esac if [ -n "$p" ]; then echo "$p"; return 0; fi fi } alloc_client_ip() { local conf=$1 net_prefix=$2 max_ip=1 ips num if [ -f "$conf" ]; then ips=$(grep -oE "${net_prefix}\.[0-9]+" "$conf" 2>/dev/null | awk -F. '{print $4}' | sort -n || true) for num in $ips; do [ "$num" -gt "$max_ip" ] && max_ip="$num" done fi echo "${net_prefix}.$((max_ip + 1))" } enable_ip_forwarding() { [ -d /etc/sysctl.d ] || mkdir -p /etc/sysctl.d 2>/dev/null || true echo 'net.ipv4.ip_forward = 1' > /etc/sysctl.d/99-free-turn-proxy.conf 2>/dev/null || true sysctl -q -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true } awg_bootstrap() { [ "$INSTALL_AWG" = "1" ] || return 0 enable_ip_forwarding mkdir -p "$AWG_DIR" "$CLIENTS_DIR" init_awg_params [ -f "$AWG_CONF" ] && return 0 log_info "Инициализация AmneziaWG (AWG 3.1)..." local kp priv pub kp="$(generate_awg_keypair)" priv="${kp%% *}"; pub="${kp##* }" echo "$priv" > "${AWG_DIR}/server.key"; chmod 0600 "${AWG_DIR}/server.key" echo "$pub" > "${AWG_DIR}/server.pub" ( umask 077 cat > "$AWG_CONF" </dev/null || echo false)" = "true" ]; then docker exec "$AWG_CONTAINER" ft-awg-start sync >/dev/null 2>&1 || true elif command -v awg >/dev/null 2>&1; then awg syncconf "$AWG_IFACE" <(awg-quick strip "$AWG_IFACE" 2>/dev/null) 2>/dev/null || true fi } wireguard_bootstrap() { [ "$INSTALL_WG" = "1" ] || return 0 enable_ip_forwarding mkdir -p "$WG_DIR" "$CLIENTS_DIR" [ -f "$WG_CONF" ] && return 0 local kp priv pub kp="$(generate_awg_keypair)" priv="${kp%% *}"; pub="${kp##* }" echo "$priv" > "${WG_DIR}/server.key"; chmod 0600 "${WG_DIR}/server.key" echo "$pub" > "${WG_DIR}/server.pub" ( umask 077 cat > "$WG_CONF" </dev/null 2>&1 || return 0 ip link show "$WG_IFACE" >/dev/null 2>&1 || return 0 wg syncconf "$WG_IFACE" <(wg-quick strip "$WG_IFACE" 2>/dev/null) 2>/dev/null || true } _pub_fs() { printf '%s' "$1" | tr '/+' '_-' | tr -d '='; } generate_freeturn_uri() { local peer="$1" mode="$2" obf="$3" key="$4" cid="${5:-}" name="${6:-}" wg_conf="${7:-}" local json b64 json="{\"v\":1,\"provider\":\"$(esc "${PROVIDER:-vk}")\",\"peer\":\"$(esc "$peer")\"" if [ -n "$mode" ] && [ "$mode" != "udp" ]; then json="$json,\"mode\":\"$(esc "$mode")\"" fi if [ -n "$obf" ] && [ "$obf" != "none" ]; then json="$json,\"obf\":\"$(esc "$obf")\",\"key\":\"$(esc "$key")\"" fi local n="${STREAMS:-12}" spc="${STREAMS_PER_CRED:-12}" json="$json,\"n\":${n},\"spc\":${spc}" if [ -n "$cid" ]; then json="$json,\"cid\":\"$(esc "$cid")\"" fi if [ -n "$name" ]; then json="$json,\"name\":\"$(esc "$name")\"" fi if [ -n "$wg_conf" ]; then json="$json,\"wg\":\"$(esc "$wg_conf")\"" fi json="$json}" b64=$(printf '%s' "$json" | openssl base64 -A 2>/dev/null || printf '%s' "$json" | base64 | tr -d '\r\n') b64=$(printf '%s' "$b64" | tr '+/' '-_' | tr -d '=') echo "freeturn://${b64}" } peers_json() { local target_conf="" iface="" if [ -f "$AWG_CONF" ]; then target_conf="$AWG_CONF"; iface="$AWG_IFACE" elif wg_present && wg_is_ours; then target_conf="$WG_CONF"; iface="$WG_IFACE" else printf '[]'; return 0; fi local hs="" out="" first=1 in_peer=0 pub="" name="" ip="" raw line val command -v wg >/dev/null 2>&1 && hs=$(wg show "$iface" latest-handshakes 2>/dev/null || true) flush_peer() { if [ "$in_peer" = 1 ] && [ -n "$pub" ]; then local h conf_yes el h=$(printf '%s\n' "$hs" | awk -v p="$pub" '$1==p{print $2}') [ -f "$SHARE_DIR/$(_pub_fs "$pub").conf" ] || [ -f "$CLIENTS_DIR/$(_pub_fs "$pub").conf" ] \ && conf_yes=true || conf_yes=false el="{\"pub\":\"$(esc "$pub")\"" [ -n "$name" ] && el="$el,\"name_b64\":\"$(esc "$name")\"" [ -n "$ip" ] && el="$el,\"ip\":\"$(esc "$ip")\"" [ -n "$h" ] && [ "$h" != 0 ] && el="$el,\"hs\":$h" el="$el,\"has_conf\":$conf_yes}" [ "$first" = 1 ] && first=0 || out="$out," out="$out$el" fi pub=""; name=""; ip="" } while IFS= read -r raw || [ -n "$raw" ]; do line=${raw//$'\r'/}; line=${line#"${line%%[![:space:]]*}"}; line=${line%"${line##*[![:space:]]}"} case "$line" in "["*) flush_peer; case "$line" in \[[Pp]eer\]) in_peer=1 ;; *) in_peer=0 ;; esac ;; "# ft-user: "*) [ "$in_peer" = 1 ] && name="${line#\# ft-user: }" ;; "# client: "*) [ "$in_peer" = 1 ] && name=$(printf '%s' "${line#\# client: }" | base64 | tr -d '\r\n') ;; PublicKey*=*) [ "$in_peer" = 1 ] && { val=${line#*=}; pub=${val// /}; } ;; AllowedIPs*=*) [ "$in_peer" = 1 ] && [ -z "$ip" ] && { val=${line#*=}; val=${val%%,*}; val=${val%%/*}; ip=${val// /}; } ;; esac done < "$target_conf" flush_peer printf '[%s]' "$out" } # ───────────────────────────────────────────────────────────────────────────── # Модуль: 70-runtime.sh # ───────────────────────────────────────────────────────────────────────────── # Управление рантаймами (Docker Compose / Systemd), процессами и файрволом. current_runtime() { local r; r=$(state_get runtime) [ -n "$r" ] && { echo "$r"; return 0; } [ -f "$COMPOSE_FILE" ] || [ "$INSTALL_METHOD" = "docker" ] && { echo "docker"; return 0; } has_systemd && echo "systemd" || echo "nohup" } _running_nohup() { if [ -f "$PIDFILE" ]; then local pid; pid=$(cat "$PIDFILE" 2>/dev/null || echo "") [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && return 0 fi pgrep -f "^$PREFIX/(server|server-linux-)" >/dev/null 2>&1 } rt_running() { case "$(current_runtime)" in docker) if [ "$INSTALL_FREETURN" = "1" ]; then [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)" = "true" ] elif [ "$INSTALL_AWG" = "1" ]; then [ "$(docker inspect -f '{{.State.Running}}' "$AWG_CONTAINER" 2>/dev/null || echo false)" = "true" ] else return 1 fi ;; systemd) systemctl is-active --quiet "$UNIT_NAME" ;; nohup) _running_nohup ;; *) return 1 ;; esac } rt_pid() { case "$(current_runtime)" in docker) docker inspect -f '{{.State.Pid}}' "$CONTAINER" 2>/dev/null || echo 0 ;; systemd) local p; p=$(systemctl show -p MainPID --value "$UNIT_NAME" 2>/dev/null || echo 0) [ -n "$p" ] && [ "$p" != "0" ] && echo "$p" || true ;; nohup) [ -f "$PIDFILE" ] && cat "$PIDFILE" 2>/dev/null || true ;; esac } current_cmdline() { local p; p=$(rt_pid) [ -n "$p" ] && [ -r "/proc/$p/cmdline" ] && tr '\0' ' ' < "/proc/$p/cmdline" || true } _wait_running() { local i=0 while [ "$i" -lt 5 ]; do rt_running >/dev/null 2>&1 && return 0 sleep 1 i=$((i + 1)) done return 1 } _install_systemd_unit() { local launcher_content unit_content need_reload=0 launcher_content=$(cat <<'LAUNCH_EOF' #!/bin/bash set -e PREFIX="/opt/free-turn-proxy" [ -f "$PREFIX/run.args" ] || { echo "run.args missing" >&2; exit 1; } a=() while IFS= read -r line || [ -n "$line" ]; do a+=("$line"); done < "$PREFIX/run.args" if [ -x "$PREFIX/server" ]; then exec "$PREFIX/server" "${a[@]}"; fi arch=server-linux-amd64 case "$(uname -m 2>/dev/null || echo "")" in x86_64|amd64) arch=server-linux-amd64 ;; aarch64|arm64) arch=server-linux-arm64 ;; armv7l|armv6l|armv5*|arm) arch=server-linux-arm ;; i386|i486|i586|i686) arch=server-linux-386 ;; riscv64) arch=server-linux-riscv64 ;; esac exec "$PREFIX/$arch" "${a[@]}" LAUNCH_EOF ) if [ ! -f "$LAUNCHER" ] || [ "$(cat "$LAUNCHER" 2>/dev/null)" != "$launcher_content" ]; then printf '%s\n' "$launcher_content" > "$LAUNCHER" chmod 0755 "$LAUNCHER" fi unit_content=$(cat </dev/null)" != "$unit_content" ]; then printf '%s\n' "$unit_content" > "$UNIT_PATH" chmod 0644 "$UNIT_PATH" need_reload=1 fi [ "$need_reload" = "1" ] && systemctl daemon-reload systemctl enable "$UNIT_NAME" >/dev/null 2>&1 || true } _write_args_file() { local tmp="$ARGSFILE.tmp" ( umask 077; : > "$tmp" ) { echo "-listen"; echo "${ARG_LISTEN:-0.0.0.0:${LISTEN_PORT}}" echo "-connect"; echo "${ARG_CONNECT:-$(connect_addr)}" if [ "${ARG_MODE:-$PROXY_MODE}" = "tcp" ]; then echo "-mode"; echo "tcp" for tok in ${ARG_KCP[@]+"${ARG_KCP[@]}"}; do echo "$tok"; done else echo "-mode"; echo "udp" fi local prof="${ARG_OBF_PROFILE:-$OBF_PROFILE}" local key="${ARG_OBF_KEY:-$OBF_KEY}" if [ "$prof" != "none" ] && [ -n "$key" ]; then echo "-obf-profile"; echo "$prof" echo "-obf-key"; echo "$key" [ -n "${ARG_OBF_TIMING:-}" ] && { echo "-obf-timing"; echo "$ARG_OBF_TIMING"; } fi if [ -n "${ARG_CLIENT_ID:-}" ] || [ -n "${CLIENTS_FILE_CONF:-}" ]; then echo "-clients-file"; echo "${CLIENTS_FILE_CONF:-$CLIENTSFILE}" fi } >> "$tmp" chmod 0600 "$tmp" mv -f "$tmp" "$ARGSFILE" } _write_env_file() { local tmp="$ENVFILE.tmp" ( umask 077; : > "$tmp" ) chmod 0600 "$tmp" mv -f "$tmp" "$ENVFILE" } _stop_docker() { if [ -f "$COMPOSE_FILE" ] && command -v docker >/dev/null 2>&1; then ( cd "$APP_DIR" && compose_cmd stop ) >/dev/null 2>&1 || true fi docker stop "$CONTAINER" "$AWG_CONTAINER" >/dev/null 2>&1 || true } _stop_systemd() { systemctl stop "$UNIT_NAME" 2>/dev/null || true rm -f "$ENVFILE" } _stop_nohup() { if [ -f "$PIDFILE" ]; then local pid; pid=$(cat "$PIDFILE" 2>/dev/null || echo "") [ -n "$pid" ] && { kill "$pid" 2>/dev/null || true; sleep 1; kill -9 "$pid" 2>/dev/null || true; } rm -f "$PIDFILE" fi pkill -9 -f "^$PREFIX/(server|server-linux-)" 2>/dev/null || true rm -f "$ENVFILE" } rt_stop() { case "$(current_runtime)" in docker) _stop_docker ;; systemd) _stop_systemd ;; nohup) _stop_nohup ;; esac } init_clients_file() { local cfile="${CLIENTS_FILE_CONF:-$CLIENTSFILE}" [ -z "$cfile" ] && return 0 mkdir -p "$(dirname "$cfile")" 2>/dev/null || true if [ ! -f "$cfile" ]; then echo '{"clients":{}}' > "$cfile" chmod 0600 "$cfile" fi } healthcheck_docker() { sleep 2 if [ "$INSTALL_FREETURN" = "1" ]; then [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)" = "true" ] \ && log_success "Контейнер FreeTurn работает." \ || { log_warn "Логи ${CONTAINER}:" docker logs --tail 30 "$CONTAINER" 2>&1 || true die "Контейнер FreeTurn не запустился." } fi if [ "$INSTALL_AWG" = "1" ]; then [ "$(docker inspect -f '{{.State.Running}}' "$AWG_CONTAINER" 2>/dev/null || echo false)" = "true" ] \ && log_success "Контейнер AmneziaWG работает." \ || { log_warn "Логи ${AWG_CONTAINER}:" docker logs --tail 30 "$AWG_CONTAINER" 2>&1 || true die "Контейнер AmneziaWG не запустился." } fi } apply_docker() { state_set runtime docker ensure_docker init_clients_file mkdir -p "$APP_DIR" { echo "services:" if [ "$INSTALL_FREETURN" = "1" ]; then 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 local cdir; cdir="$(dirname "$CLIENTS_FILE_CONF")" echo " - CLIENTS_FILE=${CLIENTS_FILE_CONF}" echo " volumes:" echo " - ${cdir}:${cdir}" fi fi if [ "$INSTALL_AWG" = "1" ]; then [ "$INSTALL_FREETURN" = "1" ] && echo "" echo " freeturn-awg:" echo " image: ${AWG_IMAGE}" echo " container_name: ${AWG_CONTAINER}" echo " network_mode: \"host\"" echo " environment:" echo " - AWG_IFACE=${AWG_IFACE}" echo " - AWG_CONF=/etc/awg/${AWG_IFACE}.conf" echo " - AWG_LOG_LEVEL=verbose" echo " cap_add:" echo " - NET_ADMIN" echo " devices:" echo " - /dev/net/tun" echo " restart: unless-stopped" echo " volumes:" echo " - ${AWG_CONF}:/etc/awg/${AWG_IFACE}.conf:ro" fi } > "$COMPOSE_FILE" chmod 0600 "$COMPOSE_FILE" if [ "$INSTALL_FREETURN" = "1" ]; then if [ "$_IS_RPC" = 1 ]; then docker pull "${IMAGE}:$(image_tag)" >/dev/null 2>&1 || true else ui_spin "Загрузка Docker-образа FreeTurn" docker pull "${IMAGE}:$(image_tag)" || true fi fi if [ "$INSTALL_AWG" = "1" ]; then ensure_awg_image fi if [ "$_IS_RPC" = 1 ]; then ( cd "$APP_DIR" && compose_cmd up -d >/dev/null 2>&1 ) \ || fail compose_up_failed "docker compose up failed" else ( cd "$APP_DIR" && ui_spin "Запуск служб" compose_cmd up -d ) || die "docker compose up не удался." fi healthcheck_docker } healthcheck_systemd() { sleep 1 systemctl is-active --quiet "$SERVICE" && { log_success "Служба ${SERVICE} активна."; return 0; } log_warn "Служба не активна. Логи:" journalctl -u "$SERVICE" --no-pager -n 40 2>&1 || true die "$SERVICE не запустилась." } apply_systemd() { state_set runtime systemd download_binary init_clients_file _write_args_file _write_env_file _install_systemd_unit systemctl restart "$UNIT_NAME" || fail start_failed "systemctl restart failed" healthcheck_systemd } rt_start() { local bin=${1:-} case "$(current_runtime)" in docker) apply_docker ;; systemd) _install_systemd_unit _write_args_file _write_env_file systemctl restart "$UNIT_NAME" || fail start_failed "systemctl restart failed" _wait_running || fail start_failed "server failed to start; journalctl -u $UNIT_NAME" ;; nohup) [ -z "$bin" ] && bin="$PREFIX/server" [ -f "$PIDFILE" ] && _stop_nohup _write_env_file local args=(-listen "${ARG_LISTEN:-0.0.0.0:${LISTEN_PORT}}" -connect "${ARG_CONNECT:-$(connect_addr)}") [ "${ARG_MODE:-$PROXY_MODE}" = "tcp" ] && args+=(-mode tcp ${ARG_KCP[@]+"${ARG_KCP[@]}"}) || args+=(-mode udp) local prof="${ARG_OBF_PROFILE:-$OBF_PROFILE}" key="${ARG_OBF_KEY:-$OBF_KEY}" [ "$prof" != "none" ] && [ -n "$key" ] && args+=(-obf-profile "$prof" -obf-key "$key") ( cd "$PREFIX" && nohup "$bin" "${args[@]}" >"$LOGFILE" 2>&1 & echo $! > "$PIDFILE" ) sleep 1; kill -0 "$(<"$PIDFILE")" 2>/dev/null || fail start_failed "nohup start failed" ;; *) fail start_failed "unknown runtime" ;; esac local pid; pid=$(rt_pid) [ -n "$pid" ] && [ "$pid" != "0" ] && d_num pid "$pid" } firewall_open_port() { local port=${1:-} [[ "$port" =~ ^[0-9]+$ ]] || return 0 if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then ufw allow "${port}/udp" >/dev/null 2>&1 || true elif command -v iptables >/dev/null 2>&1; then if ! iptables -C INPUT -p udp --dport "$port" -j ACCEPT 2>/dev/null; then iptables -I INPUT -p udp --dport "$port" -j ACCEPT 2>/dev/null || true command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save >/dev/null 2>&1 || true fi fi } firewall_close_port() { local port=${1:-} [[ "$port" =~ ^[0-9]+$ ]] || return 0 if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then ufw delete allow "${port}/udp" >/dev/null 2>&1 || true elif command -v iptables >/dev/null 2>&1; then iptables -D INPUT -p udp --dport "$port" -j ACCEPT 2>/dev/null || true command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save >/dev/null 2>&1 || true fi } firewall_open() { [ "$INSTALL_FREETURN" = "1" ] && firewall_open_port "$LISTEN_PORT" if [ "$INSTALL_AWG" = "1" ] && [ "$AWG_DIRECT_PORT" = "1" ] && [ -n "$BACKEND_PORT" ]; then firewall_open_port "$BACKEND_PORT" fi } _bin_path() { [ -x "$PREFIX/server" ] && { echo "$PREFIX/server"; return 0; } local arch; arch=$(detect_arch) || arch="" [ -n "$arch" ] && [ -x "$PREFIX/$arch" ] && { echo "$PREFIX/$arch"; return 0; } } _run_clients_cmd() { local cfile="${CLIENTS_FILE_CONF:-$CLIENTSFILE}" bin; bin=$(_bin_path) if [ -n "$bin" ] && [ -x "$bin" ]; then CLIENTS_FILE="$cfile" "$bin" clients "$@" return $? fi if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)" = "true" ]; then docker exec -i "$CONTAINER" env CLIENTS_FILE="$cfile" /app/server clients "$@" return $? fi return 1 } clients_add() { _run_clients_cmd add "$1" "$2" >/dev/null 2>&1 || { [ "$_IS_RPC" = 1 ] && fail clients_cmd_failed "clients add failed" || log_warn "Не удалось добавить в clients.json" } } clients_remove_soft() { _run_clients_cmd remove "$1" >/dev/null 2>&1 || true } clients_json() { local cfile="${CLIENTS_FILE_CONF:-$CLIENTSFILE}" owner="" mapped="" f out="" first=1 line id comment el [ -f "$OWNERCIDFILE" ] && owner=$(tr -d ' \r\n' < "$OWNERCIDFILE") if [ -d "$SHARE_DIR" ]; then for f in "$SHARE_DIR"/*.cid; do [ -f "$f" ] && mapped="$mapped $(tr -d ' \r\n' < "$f")" done fi local raw; raw=$(_run_clients_cmd list 2>/dev/null) || { printf '[]'; return 0; } while IFS= read -r line; do case "$line" in " - "*) ;; *) continue ;; esac id=${line#" - "}; id=${id%% *} [ -n "$id" ] || continue [ "$id" = "$owner" ] && continue case " $mapped " in *" $id "*) continue ;; esac comment=$(printf '%s' "$line" | sed -nE 's/^ - [^ ]+ \(Comment: (.*)\)$/\1/p') el="{\"id\":\"$(esc "$id")\"" [ -n "$comment" ] && el="$el,\"name_b64\":\"$(printf '%s' "$comment" | base64 | tr -d '\r\n')\"" el="$el}" [ "$first" = 1 ] && first=0 || out="$out," out="$out$el" done <<< "$raw" printf '[%s]' "$out" } # ───────────────────────────────────────────────────────────────────────────── # Модуль: 80-commands.sh # ───────────────────────────────────────────────────────────────────────────── # Обработчики команд JSON RPC v2, CLI управления клиентами и интерактивных мастеров. # ───────────────────────────────────────────────────────────────────────────── # JSON RPC v2 парсер и команды # ───────────────────────────────────────────────────────────────────────────── ARG_LISTEN="" ARG_CONNECT="" ARG_MODE="udp" ARG_KCP=() ARG_OBF_PROFILE="none" ARG_OBF_KEY="" ARG_OBF_TIMING="" ARG_TAIL=80 ARG_WG_PORT="" ARG_WG_ENDPOINT="" ARG_NAME_B64="" ARG_PUBKEY="" ARG_CLIENT_ID="" ARG_SHA256="" ARG_DNS="1.1.1.1" ARG_WITH_WG_PKG=0 ARG_DRY_RUN=0 ARG_TARGET="all" parse_rpc_args() { while [ $# -gt 0 ]; do case "$1" in --listen=*) ARG_LISTEN="${1#*=}" ;; --connect=*) ARG_CONNECT="${1#*=}" ;; --mode=*) ARG_MODE="${1#*=}" ;; --obf-profile=*) ARG_OBF_PROFILE="${1#*=}" ;; --obf-timing=*) ARG_OBF_TIMING="${1#*=}" ;; --obf-key=*) ARG_OBF_KEY="${1#*=}" ;; --tail=*) ARG_TAIL="${1#*=}" ;; --port=*) ARG_WG_PORT="${1#*=}" ;; --endpoint=*) ARG_WG_ENDPOINT="${1#*=}" ;; --name-b64=*) ARG_NAME_B64="${1#*=}" ;; --pubkey=*) ARG_PUBKEY="${1#*=}" ;; --client-id=*) ARG_CLIENT_ID="${1#*=}" ;; --sha256=*) ARG_SHA256="${1#*=}" ;; --dns=*) ARG_DNS="${1#*=}" ;; --target=*) ARG_TARGET="${1#*=}" ;; --with-wg-pkg) ARG_WITH_WG_PKG=1 ;; --dry-run) ARG_DRY_RUN=1 ;; --kcp-acknodelay=*) ARG_KCP+=("-kcp-acknodelay=${1#*=}") ;; --kcp-*) ARG_KCP+=("-${1%%=*}" "${1#*=}") ;; *) fail bad_arg "unknown arg: $1" ;; esac shift done } cmd_probe() { stage probe local arch bin="" installed=false running=false version="" sha="" obf="none" mode="udp" arch=$(detect_arch) || arch="" [ -n "$arch" ] && [ -x "$PREFIX/$arch" ] && bin="$PREFIX/$arch" [ -z "$bin" ] && [ -x "$PREFIX/server" ] && bin="$PREFIX/server" if [ -n "$bin" ]; then installed=true sha=$(sha256sum "$bin" 2>/dev/null | awk '{print $1}' || true) [ -f "$VERFILE" ] && version=$(cat "$VERFILE" 2>/dev/null || true) elif [ -f "$COMPOSE_FILE" ]; then installed=true; version="docker" fi local runtime; runtime=$(current_runtime) if rt_running; then running=true local cmdline; cmdline=$(current_cmdline) if [ -n "$cmdline" ]; then obf=$(printf '%s' "$cmdline" | sed -nE 's/.*-obf-profile[= ]+([a-z0-9]+).*/\1/p'); obf=${obf:-none} mode=$(printf '%s' "$cmdline" | sed -nE 's/.*-mode[= ]+(udp|tcp).*/\1/p'); mode=${mode:-udp} elif [ "$runtime" = "docker" ]; then load_config; obf="${OBF_PROFILE:-none}"; mode="${PROXY_MODE:-udp}" fi fi local wgpresent=false wgp="" if awg_present; then wgpresent=true; wgp=$(awg_port) elif wg_present; then wgpresent=true; wgp=$(wg_port); fi local virt wgkernel=false virt=$(detect_virt) if wg_kernel_ok; then wgkernel=true; fi local cw=false cx=false cwe=false cts=false if conflict_warp; then cw=true; fi if conflict_x3ui; then cx=true; fi if conflict_wgeasy; then cwe=true; fi if conflict_tailscale; then cts=true; fi d_bool installed "$installed" [ -n "$version" ] && d_str version "$version" [ -n "$sha" ] && d_str bin_sha256 "$sha" d_bool running "$running" [ "$running" = true ] && { d_str mode "$mode"; d_str obf "$obf"; } d_str runtime "$runtime" d_num euid "$(id -u 2>/dev/null || echo -1)" [ -n "$wgp" ] && d_raw wg "{\"present\":$wgpresent,\"port\":$wgp}" || d_raw wg "{\"present\":$wgpresent,\"port\":null}" d_str virt "$virt" d_bool wg_kernel "$wgkernel" d_raw conflicts "{\"warp\":$cw,\"x3ui\":$cx,\"wgeasy\":$cwe,\"tailscale\":$cts,\"other_ifaces\":[$(other_wg_ifaces_csv)]}" ok } cmd_wg_setup() { stage wg_setup [ -n "$ARG_WG_PORT" ] || fail bad_arg "--port required" [ -n "$ARG_WG_ENDPOINT" ] || fail bad_arg "--endpoint required" [ "$(id -u 2>/dev/null || echo -1)" -eq 0 ] || fail needs_root "root required" with_lock BACKEND_PORT="$ARG_WG_PORT" WG_ENDPOINT="$ARG_WG_ENDPOINT" awg_bootstrap local port; port=$(awg_port); [ -n "$port" ] || port="$ARG_WG_PORT" local existed=false; awg_present && existed=true d_raw wg "{\"port\":$port,\"existed\":$existed}" ok } cmd_install() { stage install [ "$(id -u 2>/dev/null || echo -1)" -eq 0 ] || fail needs_root "root required" mkdir -p "$PREFIX" || fail not_writable "cannot create $PREFIX" local name bin latest_url asset_url tmp ver curver="" cached=0 name=$(detect_arch) || fail unsupported_arch "unsupported arch: $(uname -m)" bin="$PREFIX/$name" latest_url="$BASE_URL/$name" [ -f "$VERFILE" ] && curver=$(cat "$VERFILE" 2>/dev/null || true) ver=$(_resolve_version "$latest_url") if [ -z "$ver" ]; then if [ -x "$bin" ] || [ -x "$PREFIX/server" ]; then ver="${curver:-installed}"; cached=1 else fail version_resolve_failed "cannot resolve latest version"; fi elif [ -x "$bin" ] && [ "$ver" = "$curver" ]; then cached=1; fi asset_url="$RELEASES_URL/download/$ver/$name" if [ "$cached" = 0 ]; then tmp=$(mktemp "$bin.XXXXXX" 2>/dev/null) || tmp="$bin.new.$$" _dl "$asset_url" "$tmp" || _dl "$latest_url" "$tmp" || { rm -f "$tmp"; fail download_failed "download failed"; } _verify_download "$tmp" "$ARG_SHA256" chmod 0755 "$tmp" with_lock [ -f "$bin" ] && cp -f "$bin" "$bin.bak" 2>/dev/null || true mv -f "$tmp" "$bin" cp -f "$bin" "$PREFIX/server" 2>/dev/null || true echo "$ver" > "$VERFILE" fi local was_running=false; rt_running && was_running=true if has_systemd; then _install_systemd_unit; state_set runtime systemd else state_set runtime nohup; fi d_str stage "$([ "$cached" = 1 ] && echo cached || echo downloaded)" d_str bin "$name" d_str version "$ver" d_str runtime "$(current_runtime)" d_bool needs_restart "$([ "$was_running" = true ] && [ "$cached" = 0 ] && echo true || echo false)" ok } cmd_start() { stage start [ -n "$ARG_LISTEN" ] || fail bad_arg "--listen required" [ -n "$ARG_CONNECT" ] || fail bad_arg "--connect required" with_lock if [ -n "$ARG_CLIENT_ID" ]; then init_clients_file clients_add "$ARG_CLIENT_ID" "owner" printf '%s\n' "$ARG_CLIENT_ID" > "$OWNERCIDFILE"; chmod 0600 "$OWNERCIDFILE" fi local port proto=udp owner opid; port=${ARG_LISTEN##*:} if [[ "$port" =~ ^[0-9]+$ ]]; then owner=$(port_owner "$proto" "$port") case "$owner" in free|unknown) : ;; *) opid=$(port_pid "$proto" "$port") [ -n "$opid" ] && ! pid_is_ours "$opid" && fail listen_port_busy "$proto port $port busy" ;; esac firewall_open_port "$port" fi rt_stop rt_start ok } cmd_stop() { stage stop; with_lock; rt_stop; d_bool stopped true; ok; } cmd_logs() { stage logs case "$(current_runtime)" in docker) command -v docker >/dev/null 2>&1 && while IFS= read -r l; do log "$l"; done < <(docker logs --tail "$ARG_TAIL" "$CONTAINER" 2>&1 || true) ;; systemd) command -v journalctl >/dev/null 2>&1 && while IFS= read -r l; do log "$l"; done < <(journalctl -u "$UNIT_NAME" -n "$ARG_TAIL" --no-pager 2>/dev/null || true) ;; nohup) [ -f "$LOGFILE" ] && while IFS= read -r l; do log "$l"; done < <(tail -n "$ARG_TAIL" "$LOGFILE") ;; esac ok } cmd_share_info() { stage share_info load_config local backend=false if awg_present || { wg_present && wg_is_ours; }; then backend=true fi d_bool wg_backend "$backend" d_str mode "${PROXY_MODE:-udp}" d_str obf_profile "${OBF_PROFILE:-none}" if [ -n "${OBF_KEY:-}" ]; then d_str obf_key "$OBF_KEY"; fi if awg_present; then d_str backend_type "awg"; fi ok } cmd_share_list() { stage share_list d_raw peers "$(peers_json)" local cj; cj=$(clients_json) || fail clients_cmd_failed "clients list failed" d_raw clients "$cj" ok } cmd_peer_add() { stage peer_add [ -n "$ARG_NAME_B64" ] || fail bad_arg "--name-b64 required" [ -n "$ARG_WG_ENDPOINT" ] || fail bad_arg "--endpoint required" with_lock mkdir -p "$SHARE_DIR" "$CLIENTS_DIR" [ -n "$ARG_CLIENT_ID" ] && clients_add "$ARG_CLIENT_ID" "$(printf '%s' "$ARG_NAME_B64" | base64 -d 2>/dev/null || echo "client")" with_peers_lock local cname; cname="$(printf '%s' "$ARG_NAME_B64" | base64 -d 2>/dev/null || echo "client")" init_awg_params local cli_ip; cli_ip=$(alloc_client_ip "$AWG_CONF" "$AWG_NET") local kp cli_priv cli_pub srv_pub kp="$(generate_awg_keypair)"; cli_priv="${kp%% *}"; cli_pub="${kp##* }" srv_pub=$(cat "${AWG_DIR}/server.pub" 2>/dev/null || true) cat >> "$AWG_CONF" < "$stored" < "$SHARE_DIR/$(_pub_fs "$cli_pub").cid"; d_str client_id "$ARG_CLIENT_ID"; } d_raw peer "{\"pub\":\"$(esc "$cli_pub")\",\"ip\":\"$(esc "$cli_ip")\"}" d_str client_conf_b64 "$(base64 < "$stored" | tr -d '\n')" ok } cmd_peer_conf() { stage peer_conf [ -n "$ARG_PUBKEY" ] || fail bad_arg "--pubkey required" local stored="$SHARE_DIR/$(_pub_fs "$ARG_PUBKEY").conf" [ -f "$stored" ] || fail no_stored_conf "conf not found" local cidfile="$SHARE_DIR/$(_pub_fs "$ARG_PUBKEY").cid" [ -f "$cidfile" ] && d_str client_id "$(cat "$cidfile" | tr -d ' \r\n')" d_str client_conf_b64 "$(base64 < "$stored" | tr -d '\n')" ok } cmd_peer_remove() { stage peer_remove [ -n "$ARG_PUBKEY" ] || fail bad_arg "--pubkey required" with_lock local target_conf="" [ -f "$AWG_CONF" ] && target_conf="$AWG_CONF" [ -z "$target_conf" ] && wg_present && target_conf="$WG_CONF" [ -z "$target_conf" ] && fail no_wg_backend "no managed vpn backend" local cidfile="$SHARE_DIR/$(_pub_fs "$ARG_PUBKEY").cid" [ -f "$cidfile" ] && { clients_remove_soft "$(cat "$cidfile" | tr -d ' \r\n')"; rm -f "$cidfile"; } with_peers_lock local tmp="$target_conf.tmp" awk -v key="$ARG_PUBKEY" ' function flushbuf() { for (j = 0; j < n; j++) print buf[j]; n = 0 } /^[ \t]*\[/ { if (insec) { if (drop) n = 0; flushbuf() }; insec = 1; drop = 0; buf[n++] = $0; next } { if (!insec) { print; next }; buf[n++] = $0; line = $0; gsub(/[ \t\r]/, "", line); if (line == "PublicKey=" key) drop = 1 } END { if (insec) { if (drop) n = 0; flushbuf() } } ' "$target_conf" > "$tmp" 2>/dev/null || rm -f "$tmp" if [ -s "$tmp" ]; then chmod 0600 "$tmp"; mv -f "$tmp" "$target_conf" awg_reconcile; wg_reconcile fi rm -f "$tmp" "$SHARE_DIR/$(_pub_fs "$ARG_PUBKEY").conf" d_bool removed true ok } cmd_client_add() { stage client_add [ -n "$ARG_CLIENT_ID" ] || fail bad_arg "--client-id required" [ -n "$ARG_NAME_B64" ] || fail bad_arg "--name-b64 required" with_lock clients_add "$ARG_CLIENT_ID" "$(printf '%s' "$ARG_NAME_B64" | base64 -d 2>/dev/null || echo "$ARG_CLIENT_ID")" ok } cmd_client_remove() { stage client_remove [ -n "$ARG_CLIENT_ID" ] || fail bad_arg "--client-id required" with_lock clients_remove_soft "$ARG_CLIENT_ID" ok } do_uninstall() { local target=${1:-all} purge=${2:-0} with_lock case "$target" in freeturn) log_info "Удаление FreeTurn..." if [ -f "$COMPOSE_FILE" ] && command -v docker >/dev/null 2>&1; then docker stop "$CONTAINER" >/dev/null 2>&1 || true docker rm -f "$CONTAINER" >/dev/null 2>&1 || true fi if has_systemd; then systemctl disable --now "$SERVICE" >/dev/null 2>&1 || true rm -f "$UNIT_FILE"; systemctl daemon-reload || true fi firewall_close_port "$LISTEN_PORT" log_success "FreeTurn удалён. AmneziaWG остался активен." ;; awg) log_info "Удаление AmneziaWG / WireGuard..." if [ -f "$COMPOSE_FILE" ] && command -v docker >/dev/null 2>&1; then docker stop "$AWG_CONTAINER" >/dev/null 2>&1 || true docker rm -f "$AWG_CONTAINER" >/dev/null 2>&1 || true fi if has_systemd; then systemctl disable --now "wg-quick@${WG_IFACE}" >/dev/null 2>&1 || true fi firewall_close_port "$BACKEND_PORT" rm -rf "$AWG_DIR" log_success "AmneziaWG удалён. FreeTurn остался активен." ;; all) log_info "Полное удаление..." rt_stop if [ -f "$COMPOSE_FILE" ] && command -v docker >/dev/null 2>&1; then ( cd "$APP_DIR" && compose_cmd down -v ) >/dev/null 2>&1 || true fi if has_systemd; then systemctl disable --now "$SERVICE" >/dev/null 2>&1 || true systemctl disable --now "wg-quick@${WG_IFACE}" >/dev/null 2>&1 || true rm -f "$UNIT_FILE" "$LAUNCHER" systemctl daemon-reload || true fi firewall_close_port "$LISTEN_PORT" [ -n "$BACKEND_PORT" ] && firewall_close_port "$BACKEND_PORT" rm -f /etc/sysctl.d/99-free-turn-proxy.conf if [ "$purge" = "1" ] || [ "$PURGE" = "1" ]; then rm -rf "$APP_DIR" log_success "Каталог $APP_DIR полностью удалён." fi log_success "Все компоненты удалены." ;; esac } cmd_uninstall() { stage uninstall [ "$(id -u 2>/dev/null || echo -1)" -eq 0 ] || fail needs_root "root required" do_uninstall "${ARG_TARGET:-all}" "$ARG_WITH_WG_PKG" d_str target "${ARG_TARGET:-all}" d_bool uninstalled true ok } # ───────────────────────────────────────────────────────────────────────────── # CLI управление клиентами # ───────────────────────────────────────────────────────────────────────────── client_add() { local cname="${1:-}" silent="${2:-0}" mkdir -p "$CLIENTS_DIR" "$SHARE_DIR" local ext_ip; ext_ip="$(get_public_ip)" if [ -z "$cname" ]; then local next_num=1 [ -f "$CLIENTS_META" ] && next_num="$(( $(wc -l < "$CLIENTS_META") + 1 ))" ui_input cname "Имя нового клиента" "client-${next_num}" fi local cid="" if [ "$INSTALL_FREETURN" = "1" ] && [ -n "$CLIENTS_FILE_CONF" ]; then cid="$(openssl rand -hex 16)" clients_add "$cid" "$cname" fi local direct_conf="" relay_conf="" ft_file="" client_ip="" if [ "$INSTALL_AWG" = "1" ] && [ -f "$AWG_CONF" ]; then init_awg_params client_ip=$(alloc_client_ip "$AWG_CONF" "$AWG_NET") local kp cli_priv cli_pub srv_pub kp="$(generate_awg_keypair)"; cli_priv="${kp%% *}"; cli_pub="${kp##* }" srv_pub="$(cat "${AWG_DIR}/server.pub" 2>/dev/null || true)" cat >> "$AWG_CONF" < "$direct_conf" < "$relay_conf" </dev/null || true [ -n "$cid" ] && printf '%s\n' "$cid" > "$SHARE_DIR/$(_pub_fs "$cli_pub").cid" 2>/dev/null || true fi local ft_uri="" ft_vpn_uri="" if [ "$INSTALL_FREETURN" = "1" ]; then ft_uri="$(generate_freeturn_uri "${ext_ip}:${LISTEN_PORT}" "${PROXY_MODE}" "${OBF_PROFILE}" "${OBF_KEY}" "${cid}" "${cname}")" ft_file="${CLIENTS_DIR}/${cname}-freeturn.txt" echo "$ft_uri" > "$ft_file"; chmod 0600 "$ft_file" if [ -f "$relay_conf" ]; then ft_vpn_uri="$(generate_freeturn_uri "${ext_ip}:${LISTEN_PORT}" "${PROXY_MODE}" "${OBF_PROFILE}" "${OBF_KEY}" "${cid}" "${cname}" "$(<"$relay_conf")")" ft_vpn_file="${CLIENTS_DIR}/${cname}-freeturn-vpn.txt" echo "$ft_vpn_uri" > "$ft_vpn_file"; chmod 0600 "$ft_vpn_file" fi fi echo "${cname}|${client_ip}|${cid}|$(date '+%Y-%m-%d %H:%M')" >> "$CLIENTS_META" log_success "Клиент '${cname}' добавлен!" if [ -n "$ft_vpn_uri" ]; then render_qr_text "$ft_vpn_uri" "QR-код для FreeTurn App (со вшитым VPN - ${cname}):" echo log_info "Ссылка FreeTurn VPN (All-in-One): $ft_vpn_uri" log_info "Ссылка FreeTurn Proxy (только релей): $ft_uri" [ -n "$cid" ] && log_info "Client ID: $cid" elif [ -n "$ft_uri" ]; then render_qr_text "$ft_uri" "QR-код для приложения FreeTurn (${cname}):" echo log_info "Ссылка FreeTurn: $ft_uri" [ -n "$cid" ] && log_info "Client ID: $cid" fi if [ -f "$direct_conf" ]; then echo render_qr_file "$direct_conf" "QR-код для AmneziaWG Direct (${cname}):" echo log_info "Конфиг AmneziaWG Direct: $direct_conf" [ "$INSTALL_FREETURN" = "1" ] && log_info "Конфиг через релей FreeTurn: $relay_conf" fi } client_list() { mkdir -p "$CLIENTS_DIR" [ ! -s "$CLIENTS_META" ] && { log_warn "Список клиентов пуст."; return 0; } echo if [ "$HAS_GUM" = 1 ]; then { echo "ИМЯ,IP,CLIENT_ID,ДАТА"; while IFS='|' read -r name ip cid dt; do echo "${name},${ip:--},${cid:--},${dt}"; done < "$CLIENTS_META"; } \ | gum table --border rounded else printf "%-15s %-16s %-34s %s\n" "Имя" "IP" "Client ID" "Дата" echo "-----------------------------------------------------------------------------" while IFS='|' read -r name ip cid dt; do printf "%-15s %-16s %-34s %s\n" "$name" "${ip:--}" "${cid:--}" "$dt"; done < "$CLIENTS_META" fi } client_qr() { local cname="${1:-}" mode="${2:-}" if [ -z "$cname" ]; then [ ! -s "$CLIENTS_META" ] && die "Нет клиентов." local names=() while IFS='|' read -r n _ _ _; do names+=("$n"); done < "$CLIENTS_META" [ "$HAS_GUM" = 1 ] && cname=$(gum choose --header "Клиент:" "${names[@]}" "$tmp" 2>/dev/null || rm -f "$tmp" [ -s "$tmp" ] && { chmod 0600 "$tmp"; mv -f "$tmp" "$AWG_CONF"; awg_reconcile; } rm -f "$tmp" fi rm -f "${CLIENTS_DIR}/${cname}-direct.conf" "${CLIENTS_DIR}/${cname}-relay.conf" "${CLIENTS_DIR}/${cname}-freeturn.txt" "${CLIENTS_DIR}/${cname}-freeturn-vpn.txt" [ -f "$CLIENTS_META" ] && sed -i "/^${cname}|/d" "$CLIENTS_META" log_success "Клиент '${cname}' удалён." } # ───────────────────────────────────────────────────────────────────────────── # TUI Мастер # ───────────────────────────────────────────────────────────────────────────── wizard() { local comp_choice="full" if [ "$INSTALL_FREETURN" = "1" ] && [ "$INSTALL_AWG" = "1" ]; then comp_choice="full" elif [ "$INSTALL_FREETURN" = "0" ] && [ "$INSTALL_AWG" = "1" ]; then comp_choice="awg_only" elif [ "$INSTALL_FREETURN" = "1" ] && [ "$INSTALL_AWG" = "0" ]; then comp_choice="freeturn_only" fi ui_menu comp_choice "Состав установки:" "$comp_choice" \ full "FreeTurn + AmneziaWG 3.1 (рекомендуется - полный комплект)" \ awg_only "Только AmneziaWG 3.1 (чистый VPN-сервер без релея)" \ freeturn_only "Только FreeTurn (релей для стороннего бэкенда)" case "$comp_choice" in full) INSTALL_FREETURN=1; INSTALL_AWG=1 ;; awg_only) INSTALL_FREETURN=0; INSTALL_AWG=1 ;; freeturn_only) INSTALL_FREETURN=1; INSTALL_AWG=0 ;; esac ui_menu INSTALL_METHOD "Метод запуска:" "$INSTALL_METHOD" \ docker "Docker Compose (удобно, изолированно)" \ systemd "Systemd (прямо на хосте)" if [ "$INSTALL_FREETURN" = "1" ]; then ui_menu PROXY_MODE "Режим релея:" "$PROXY_MODE" \ udp "UDP-relay (AmneziaWG / WireGuard)" \ tcp "TCP-forward (Xray / sing-box)" ask_port LISTEN_PORT "Внешний порт FreeTurn" "${LISTEN_PORT:-56000}" ui_menu OBF_PROFILE "Обфускация FreeTurn:" "$OBF_PROFILE" \ rtpopus3 "rtpopus3 (RTP/opus + RFC 8285 + ChaCha20)" \ rtpopus2 "rtpopus2 (RTP/opus + RFC 8285)" \ rtpopus "rtpopus (базовый RTP/opus)" \ none "none (без обфускации)" if [ "$OBF_PROFILE" != "none" ]; then [ -z "$OBF_KEY" ] && OBF_KEY="$(openssl rand -hex 32)" else OBF_KEY=""; fi ui_yesno "Включить авторизацию по Client ID (allowlist)?" "N" \ && CLIENTS_FILE_CONF="${CLIENTS_DIR}/clients.json" || CLIENTS_FILE_CONF="" fi if [ "$INSTALL_AWG" = "1" ]; then ask_port BACKEND_PORT "Порт AmneziaWG" "${BACKEND_PORT:-51820}" ui_yesno "Открыть порт AmneziaWG (${BACKEND_PORT}/udp) для прямого подключения?" "Y" \ && AWG_DIRECT_PORT=1 || AWG_DIRECT_PORT=0 ui_input WG_ENDPOINT "Локальный Endpoint клиента (-listen)" "${WG_ENDPOINT:-127.0.0.1:9000}" fi ui_yesno "Открыть необходимые порты в файрволе сервера?" "Y" \ && OPEN_FIREWALL=1 || OPEN_FIREWALL=0 } review_config() { local comp_name="FreeTurn + AmneziaWG 3.1" [ "$INSTALL_FREETURN" = "0" ] && comp_name="Только AmneziaWG 3.1" [ "$INSTALL_AWG" = "0" ] && comp_name="Только FreeTurn" if [ "$HAS_GUM" = 1 ]; then gum format <&1 | ${PAGER:-cat} ;; systemd) journalctl -u "$UNIT_NAME" -n 40 --no-pager ;; nohup) tail -n 40 "$LOGFILE" 2>/dev/null || echo "Лог пуст" ;; esac; menu_existing ;; uninstall) flow_uninstall ;; exit) ui_abort ;; esac } # ───────────────────────────────────────────────────────────────────────────── # Модуль: 90-main.sh # ───────────────────────────────────────────────────────────────────────────── # Точка входа: маршрутизация RPC, CLI управления пирами, non-interactive и TUI мастера. usage() { cat < сгенерируется) --clients-auth | --no-clients-auth авторизация по Client ID --firewall | --no-firewall открывать порты в файрволе Действия: --reconfigure переконфигурировать сервер --update обновить версию --uninstall удалить сервер --only freeturn|awg|all цель удаления (default: all) --purge удалить каталог /opt/free-turn-proxy -h, --help справка Машиночитаемый JSON RPC v2 (мобильное приложение): sudo bash install.sh [flags] EOF } parse_cli_args() { while [ $# -gt 0 ]; do case "$1" in -y | --yes | --non-interactive) NONINTERACTIVE=1 ;; --only-awg) OVERRIDES+=("INSTALL_FREETURN=0" "INSTALL_AWG=1" "AWG_DIRECT_PORT=1") ;; --only-freeturn) OVERRIDES+=("INSTALL_FREETURN=1" "INSTALL_AWG=0") ;; --with-freeturn) OVERRIDES+=("INSTALL_FREETURN=1") ;; --with-awg) OVERRIDES+=("INSTALL_AWG=1") ;; --method) OVERRIDES+=("INSTALL_METHOD=${2:-docker}"); shift ;; --mode) OVERRIDES+=("PROXY_MODE=${2:-udp}"); shift ;; --backend-port) OVERRIDES+=("BACKEND_PORT=${2:-51820}"); shift ;; --listen-port) OVERRIDES+=("LISTEN_PORT=${2:-56000}"); shift ;; --awg-direct) OVERRIDES+=("AWG_DIRECT_PORT=1") ;; --no-awg-direct) OVERRIDES+=("AWG_DIRECT_PORT=0") ;; --obf) OVERRIDES+=("OBF_PROFILE=${2:-rtpopus3}"); shift ;; --obf-key) OVERRIDES+=("OBF_KEY=${2:-}"); shift ;; --clients-auth) OVERRIDES+=("CLIENTS_FILE_CONF=${CLIENTS_DIR}/clients.json") ;; --no-clients-auth) OVERRIDES+=("CLIENTS_FILE_CONF=") ;; --wg-endpoint) OVERRIDES+=("WG_ENDPOINT=${2:-}"); shift ;; --version) OVERRIDES+=("VERSION=${2:-latest}"); shift ;; --firewall) OPEN_FIREWALL=1 ;; --no-firewall) OPEN_FIREWALL=0 ;; --reconfigure) ACTION="reconfigure" ;; --update) ACTION="update"; NONINTERACTIVE=1 ;; --uninstall) ACTION="uninstall"; NONINTERACTIVE=1 ;; --only) UNINSTALL_TARGET="${2:-all}"; shift ;; --purge) PURGE=1 ;; -h | --help) usage; exit 0 ;; *) die "Неизвестный аргумент: $1 (см. --help)" ;; esac shift done } main() { for arg in "$@"; do if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then usage; return 0 fi done # JSON RPC v2 протокол (вызовы без дефисов, кроме 'client') if [ $# -ge 1 ] && [[ "$1" =~ ^[a-z-]+$ ]] && [ "$1" != "client" ]; then case "$1" in probe) _IS_RPC=1; HAS_GUM=0; cmd_probe; return 0 ;; install) _IS_RPC=1; HAS_GUM=0; shift; parse_rpc_args "$@"; cmd_install; return 0 ;; wg-setup) _IS_RPC=1; HAS_GUM=0; shift; parse_rpc_args "$@"; cmd_wg_setup; return 0 ;; start) _IS_RPC=1; HAS_GUM=0; shift; parse_rpc_args "$@"; cmd_start; return 0 ;; stop) _IS_RPC=1; HAS_GUM=0; cmd_stop; return 0 ;; logs) _IS_RPC=1; HAS_GUM=0; shift; parse_rpc_args "$@"; cmd_logs; return 0 ;; share-info) _IS_RPC=1; HAS_GUM=0; cmd_share_info; return 0 ;; share-list) _IS_RPC=1; HAS_GUM=0; cmd_share_list; return 0 ;; peer-add) _IS_RPC=1; HAS_GUM=0; shift; parse_rpc_args "$@"; cmd_peer_add; return 0 ;; peer-conf) _IS_RPC=1; HAS_GUM=0; shift; parse_rpc_args "$@"; cmd_peer_conf; return 0 ;; peer-remove) _IS_RPC=1; HAS_GUM=0; shift; parse_rpc_args "$@"; cmd_peer_remove; return 0 ;; client-add) _IS_RPC=1; HAS_GUM=0; shift; parse_rpc_args "$@"; cmd_client_add; return 0 ;; client-remove) _IS_RPC=1; HAS_GUM=0; shift; parse_rpc_args "$@"; cmd_client_remove; return 0 ;; uninstall) _IS_RPC=1; HAS_GUM=0; shift; parse_rpc_args "$@"; cmd_uninstall; return 0 ;; *) _IS_RPC=1; HAS_GUM=0; fail bad_arg "unknown subcommand: $1" ;; esac fi # Проверка прав root [ "$(id -u 2>/dev/null || echo -1)" -ne 0 ] && die "Запустите скрипт от root (sudo)." ensure_base_deps detect_arch >/dev/null 2>&1 || true # CLI управление клиентами if [ $# -ge 1 ] && [ "$1" = "client" ]; then shift local sub="${1:-}" [ -n "$sub" ] && shift || true load_config case "$sub" in add) client_add "${1:-}" 0 ;; list) client_list ;; qr) client_qr "${1:-}" "${2:-direct}" ;; remove) client_remove "${1:-}" ;; *) die "Использование: sudo bash install.sh client " ;; esac return 0 fi parse_cli_args "$@" if [ "$NONINTERACTIVE" = 1 ]; then is_installed && load_config || true apply_overrides if [ "$ACTION" = "uninstall" ]; then do_uninstall "$UNINSTALL_TARGET" "$PURGE" return 0 fi validate_config [ -z "$OPEN_FIREWALL" ] && OPEN_FIREWALL=1 apply print_summary return 0 fi ensure_gum ui_banner if [ "$ACTION" = "reconfigure" ]; then flow_reconfigure elif [ "$ACTION" = "uninstall" ]; then flow_uninstall elif is_installed; then load_config menu_existing else flow_install fi } if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then main "$@" fi