#!/usr/bin/env bash # Free Turn Proxy - установщик и контроллер сервера. set -Eeuo pipefail umask 077 PROTO_VERSION=3 PREFIX="${FT_PREFIX:-/opt/free-turn-proxy}" CONF_FILE="$PREFIX/install.conf" LOCKFILE="$PREFIX/control.lock" BIN="$PREFIX/server" VERFILE="$PREFIX/version" ENVFILE="$PREFIX/run.env" AUTH_DIR="$PREFIX/auth" CLIENTSFILE="$AUTH_DIR/clients.json" # Раздаётся по вебу - метаданные и allowlist держим вне его. CLIENTS_DIR="$PREFIX/clients" CLIENTS_META="$PREFIX/clients.list" OWNER="owner" # Веб-раздача: QR не влезает в терминал, картинку снимают телефоном. Сервер живёт TTL - # порт с приватными ключами не должен стоять открытым между показами. WEB_ROOT="$PREFIX/web" WEB_TOKEN_FILE="$PREFIX/web.token" WEB_LOG="$PREFIX/web.log" WEB_UNIT="freeturn-web" WEB_PROBE_EXT=".ok" SERVICE="free-turn-proxy.service" UNIT_FILE="/etc/systemd/system/$SERVICE" CONTAINER="free-turn-proxy" GUM_VERSION="0.17.0" WG_DIR="${FT_WG_DIR:-/etc/wireguard}" WG_IFACE="ft-wg0" WG_CONF="$WG_DIR/$WG_IFACE.conf" WG_UNIT="wg-quick@$WG_IFACE" WG_MARKER="# managed-by: free-turn-proxy" WG_MTU=1280 # = tunnel.DefaultMTU клиента CLIENT_LISTEN="127.0.0.1:9000" # = -listen клиента по умолчанию, Endpoint клиентского WG SYSCTL_FILE="/etc/sysctl.d/99-free-turn-proxy.conf" REPO="hackdiaz-dev/free-turn-proxy" IMAGE="ghcr.io/$REPO" RELEASES_URL="https://github.com/$REPO/releases" # 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.conf INSTALL_METHOD="docker" # docker | systemd BACKEND="new" # new - свой WG ft-wg0 | external - «мой VPN» по CONNECT CONNECT="" PUBLIC_HOST="" # адрес VPS в ссылках клиентов VERSION="latest" PROXY_MODE="udp" # udp | tcp (tcp только для external) LISTEN_PORT="56000" WG_PORT="51820" WG_NET="10.13.13.0/24" OBF_PROFILE="rtpopus3" # rtpopus3 | rtpopus2 | rtpopus | none OBF_KEY="" KCP_ARGS="" # argv-токены -kcp-* сервера, только tcp FT_WEB_PORT="${FT_WEB_PORT:-8080}" FT_WEB_TTL="${FT_WEB_TTL:-900}" HAS_GUM=0 NONINTERACTIVE=0 ACTION="" FORCE_UPDATE=0 LOCAL_BIN="" PURGE=0 TARGET="all" TAIL=80 NAME="" KCP_SET=0 PREV_LISTEN_PORT="" PREV_SHARE="" PEER_IP="" PEER_PUB="" PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}" export PATH export COLORFGBG="15;0" # ───────────────────────────────────────────────────────────────────────────── # Валидаторы. Всё, что уходит в install.conf (он source-ится), heredoc-конфиги и argv. valid_port() { [[ "$1" =~ ^[1-9][0-9]{0,4}$ ]] && [ "$1" -le 65535 ]; } valid_hex64() { [[ "$1" =~ ^[0-9a-fA-F]{64}$ ]]; } valid_host() { [[ "$1" =~ ^[a-zA-Z0-9.-]{1,253}$ || "$1" =~ ^[0-9a-fA-F:]{2,39}$ ]]; } valid_name() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$ ]]; } valid_hostport() { [[ "$1" =~ ^(\[[0-9a-fA-F:]{2,39}\]|[a-zA-Z0-9.-]{1,253}):([0-9]{1,5})$ ]] && valid_port "${BASH_REMATCH[2]}" } valid_kcp() { [[ "$1" =~ ^((-kcp-(nodelay|interval|resend|nc|sndwnd|rcvwnd|mtu)\ [0-9]{1,6}|-kcp-acknodelay=(true|false))( |$))*$ ]]; } valid_version() { [[ "$1" =~ ^(latest|local|v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?)$ ]]; } # Подсеть WG: сетевой адрес, префикс /16../29 (сервер .1, клиенты с .2). valid_net() { [[ "$1" =~ ^((0|[1-9][0-9]{0,2})\.){3}(0|[1-9][0-9]{0,2})/(1[6-9]|2[0-9])$ ]] || return 1 local ip=${1%/*} o for o in ${ip//./ }; do [ "$o" -le 255 ] || return 1; done [ $(( $(ip2int "$ip") & ~$(mask_int "${1#*/}") & 0xffffffff )) -eq 0 ] } ip2int() { local IFS=.; set -- $1; echo $(( ($1 << 24) + ($2 << 16) + ($3 << 8) + $4 )); } int2ip() { printf '%d.%d.%d.%d' $(( $1 >> 24 & 255 )) $(( $1 >> 16 & 255 )) $(( $1 >> 8 & 255 )) $(( $1 & 255 )); } mask_int() { echo $(( (0xffffffff << (32 - $1)) & 0xffffffff )); } nets_overlap() { local b1=${1#*/} b2=${2#*/} m m=$(( b1 < b2 ? b1 : b2 )) [ $(( $(ip2int "${1%/*}") & $(mask_int "$m") )) -eq $(( $(ip2int "${2%/*}") & $(mask_int "$m") )) ] } join_hostport() { case "$1" in *:*) printf '[%s]:%s' "$1" "$2" ;; *) printf '%s:%s' "$1" "$2" ;; esac; } # ───────────────────────────────────────────────────────────────────────────── # JSON RPC: буферизация в один объект, trap EXIT ловит неожиданный выход. _DATA=() _LOGS=() _EMITTED=0 _STAGE="init" _IS_RPC=0 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"); } _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 [ "$_IS_RPC" = 1 ] || return 0 printf '{"proto":%d,"result":"ok","data":{%s},"logs":[%s]}\n' \ "$PROTO_VERSION" "$(_data_json)" "$(_logs_json)" } fail() { local code=$1 msg=${2:-$1} if [ "$_IS_RPC" != 1 ]; then log_error "$msg" exit 1 fi [ "$_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 } die() { fail internal "$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 # ───────────────────────────────────────────────────────────────────────────── # UI: обёртки над gum с plain-fallback. В RPC сообщения копятся в logs[]. gum() { case "${1:-}" in style|join|format|log) if [ -t 0 ]; then command gum "$@" &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 } log() { log_info "$*"; } ui_drain_input() { [ -t 0 ] || [ -r /dev/tty ] || return 0 stty -echo /dev/null || true local discard while read -r -t 0.05 -n 1000 discard /dev/null; do :; done stty echo /dev/null || true } ui_abort() { ui_drain_input; log_info "Отменено."; exit 0; } 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" "FreeTurn · установщик сервера" else echo -e "${C_CYAN}"; printf '%s\n' "${art[@]}"; echo -e "${C_NC}" echo " FreeTurn · установщик сервера"; echo fi } 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() { 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" "$out" 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=$? if [ "$rc" -eq 0 ]; then log_success "$title" else log_error "$title - ошибка (код $rc)" tail -n 40 "$out" | gum style --border rounded --border-foreground "$MD_ERROR" --padding "0 1" fi else log_info "$title..." "$@" >"$out" 2>&1 || rc=$? [ "$rc" -eq 0 ] || tail -n 40 "$out" >&2 fi rm -f "$out" return "$rc" } # step: долгий шаг; в RPC молча, хвост вывода - в logs[] при ошибке. Внутри не звать fail. step() { local title=$1; shift if [ "$_IS_RPC" = 1 ]; then local out rc=0 out=$("$@" 2>&1) || rc=$? [ "$rc" -eq 0 ] || log_error "$title: $(printf '%s' "$out" | tail -n 5)" return "$rc" fi ui_spin "$title" "$@" } # ───────────────────────────────────────────────────────────────────────────── # Система: блокировка, пакеты, окружение. with_lock() { command -v flock >/dev/null 2>&1 || return 0 mkdir -p "$PREFIX" 2>/dev/null || return 0 exec 8>"$LOCKFILE" 2>/dev/null || return 0 flock -w 300 8 2>/dev/null || true } require_root() { [ "$(id -u 2>/dev/null || echo -1)" -eq 0 ] || fail needs_root "нужен root"; } has_systemd() { command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; } pkg_mgr() { local m for m in apt-get dnf yum apk pacman zypper; do command -v "$m" >/dev/null 2>&1 && { echo "$m"; return 0; } done return 1 } pkg_install() { local mgr; mgr=$(pkg_mgr) || return 1 case "$mgr" in apt-get) 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 ;; esac } ensure_base_deps() { local missing=() b for b in curl jq openssl; do command -v "$b" >/dev/null 2>&1 || missing+=("$b"); done [ "${#missing[@]}" -eq 0 ] && return 0 step "Установка зависимостей: ${missing[*]}" pkg_install "${missing[@]}" || true for b in "${missing[@]}"; do command -v "$b" >/dev/null 2>&1 || fail internal "не удалось установить $b - поставьте вручную" done } gum_download() { local ver="$1" arch tmp bin case "$(uname -m 2>/dev/null || true)" in x86_64|amd64) arch="x86_64" ;; aarch64|arm64) arch="arm64" ;; *) return 1 ;; esac tmp="$(mktemp -d)" if curl -fsSL --connect-timeout 10 --max-time 30 \ "https://github.com/charmbracelet/gum/releases/download/v${ver}/gum_${ver}_Linux_${arch}.tar.gz" \ | 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" type -P gum >/dev/null 2>&1 } ensure_gum() { type -P gum >/dev/null 2>&1 && { HAS_GUM=1; return 0; } log_info "Установка gum ${GUM_VERSION}..." gum_download "$GUM_VERSION" && { HAS_GUM=1; return 0; } log_warn "gum недоступен - классический текстовый режим." } _mips_is_le() { [ "$(printf '\1\0' | od -An -tx2 -N2 2>/dev/null | tr -d ' \n')" = "0001" ]; } # Имя ассета релиза (.goreleaser.yaml, archives.raw). server_asset() { case "$(uname -m 2>/dev/null || true)" in x86_64|amd64) echo server-linux-amd64 ;; aarch64|arm64) echo server-linux-arm64 ;; armv7*) echo server-linux-armv7 ;; i386|i486|i586|i686) echo server-linux-386 ;; riscv64) echo server-linux-riscv64 ;; mips64*) _mips_is_le && echo server-linux-mips64le-softfloat ;; mips*) if _mips_is_le; then echo server-linux-mipsle-softfloat; else echo server-linux-mips-softfloat; fi ;; *) return 1 ;; esac } 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)" [[ "$ip" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] && { echo "$ip"; return 0; } done return 1 } # Занят ли UDP-порт. Без ss проверить нечем - считаем свободным. port_busy_udp() { command -v ss >/dev/null 2>&1 || return 1 [ -n "$(ss -Hlun "sport = :$1" 2>/dev/null || true)" ] } # firewalld запущен (на RHEL-семействе ufw нет, а правила iptables firewalld не учитывает). _firewalld_running() { command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1 } firewall_open_port() { local port=$1 proto=${2:-udp} if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then ufw allow "${port}/${proto}" >/dev/null 2>&1 || true elif _firewalld_running; then firewall-cmd --add-port="${port}/${proto}" >/dev/null 2>&1 || true firewall-cmd --permanent --add-port="${port}/${proto}" >/dev/null 2>&1 || true elif command -v iptables >/dev/null 2>&1; then if ! iptables -C INPUT -p "$proto" --dport "$port" -j ACCEPT 2>/dev/null; then iptables -I INPUT -p "$proto" --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 return 0 } firewall_close_port() { local port=$1 proto=${2:-udp} [ -n "$port" ] || return 0 if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then ufw delete allow "${port}/${proto}" >/dev/null 2>&1 || true elif _firewalld_running; then firewall-cmd --remove-port="${port}/${proto}" >/dev/null 2>&1 || true firewall-cmd --permanent --remove-port="${port}/${proto}" >/dev/null 2>&1 || true elif command -v iptables >/dev/null 2>&1; then iptables -D INPUT -p "$proto" --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 return 0 } # ───────────────────────────────────────────────────────────────────────────── # install.conf share_fingerprint() { printf '%s|' "$BACKEND" "$PUBLIC_HOST" "$LISTEN_PORT" "$PROXY_MODE" "$OBF_PROFILE" "$OBF_KEY"; } # shellcheck disable=SC1090 load_config() { [ -f "$CONF_FILE" ] || return 0 . "$CONF_FILE" PREV_LISTEN_PORT="$LISTEN_PORT" PREV_SHARE="$(share_fingerprint)" } save_config() { mkdir -p "$PREFIX" cat > "$CONF_FILE.tmp" </dev/null 2>&1 && docker container inspect "$CONTAINER" >/dev/null 2>&1 } ft_running() { if [ "$INSTALL_METHOD" = systemd ]; then systemctl is-active --quiet "$SERVICE" 2>/dev/null else [ "$(docker inspect -f '{{.State.Status}}' "$CONTAINER" 2>/dev/null || true)" = running ] fi } ft_version() { if [ "$INSTALL_METHOD" = systemd ] || [ "$VERSION" = local ]; then cat "$VERFILE" 2>/dev/null || true else image_tag; fi } ft_logs() { if [ "$INSTALL_METHOD" = systemd ]; then journalctl -u "$SERVICE" -n "$1" --no-pager -o short-iso 2>/dev/null || true else docker logs --tail "$1" "$CONTAINER" 2>&1 || true fi } # Рестарт-петля выглядит как running первые секунды - проверяем после паузы. ft_check_started() { sleep 2 ft_running && return 0 local l while IFS= read -r l; do log_error "$l"; done < <(ft_logs 20) fail start_failed "FreeTurn не запустился" } ft_stop() { if [ "$INSTALL_METHOD" = systemd ]; then systemctl stop "$SERVICE" 2>/dev/null || true else docker stop "$CONTAINER" >/dev/null 2>&1 || true fi } ft_restart() { if [ "$INSTALL_METHOD" = systemd ]; then systemctl restart "$SERVICE" 2>/dev/null || fail start_failed "systemctl restart $SERVICE" else docker restart "$CONTAINER" >/dev/null 2>&1 || fail start_failed "docker restart $CONTAINER" fi ft_check_started } # Снимает оба метода: смена docker <-> systemd не должна оставлять второй экземпляр на порту. ft_remove() { if command -v docker >/dev/null 2>&1; then docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; fi if [ -f "$UNIT_FILE" ]; then systemctl disable --now "$SERVICE" >/dev/null 2>&1 || true rm -f "$UNIT_FILE" systemctl daemon-reload 2>/dev/null || true fi rm -f "$ENVFILE" } ensure_docker() { if ! command -v docker >/dev/null 2>&1; then step "Установка Docker" sh -c 'curl -fsSL https://get.docker.com | sh' \ || fail docker_failed "установка Docker не удалась" fi docker info >/dev/null 2>&1 || systemctl start docker >/dev/null 2>&1 || service docker start >/dev/null 2>&1 || true docker info >/dev/null 2>&1 || fail docker_failed "Docker не запущен" } docker_apply() { ensure_docker local ref="$IMAGE:$(image_tag)" argv mount=() step "Загрузка образа $ref" docker pull "$ref" \ || docker image inspect "$ref" >/dev/null 2>&1 || fail docker_failed "docker pull $ref" if [ "$VERSION" = local ]; then binary_ensure mount=(-v "$BIN:/app/server:ro") fi mapfile -t argv < <(server_argv) docker run -d --name "$CONTAINER" --network host --restart unless-stopped \ -v "$AUTH_DIR:$AUTH_DIR:ro" ${mount[@]+"${mount[@]}"} --entrypoint /app/server "$ref" "${argv[@]}" >/dev/null \ || fail docker_failed "docker run $ref" } _dl() { curl -fsSL --connect-timeout 15 --max-time 300 -o "$2" "$1"; } # Свой бинарь (отладка ядра из приложения): без сети и checksums, ставится как есть. binary_local() { [ "$(head -c4 "$LOCAL_BIN" | od -An -c | tr -d ' \n')" = '177ELF' ] || fail bad_arg "bin: не ELF" mkdir -p "$PREFIX" { install -m 0755 "$LOCAL_BIN" "$BIN.tmp" && mv -f "$BIN.tmp" "$BIN"; } \ || fail internal "не удалось поставить $LOCAL_BIN" rm -f "$LOCAL_BIN" echo local > "$VERFILE" } binary_ensure() { if [ -n "$LOCAL_BIN" ]; then binary_local; return 0; fi if [ -x "$BIN" ] && [ "$FORCE_UPDATE" != 1 ] && [ "$(cat "$VERFILE" 2>/dev/null || true)" = "$VERSION" ]; then return 0 fi local asset base tmp want got asset=$(server_asset) || fail unsupported_arch "архитектура $(uname -m) не поддерживается" base="$RELEASES_URL/download/$VERSION" [ "$VERSION" = latest ] && base="$RELEASES_URL/latest/download" tmp=$(mktemp -d) if ! step "Скачивание $asset ($VERSION)" _dl "$base/$asset" "$tmp/server" \ || ! _dl "$base/checksums.txt" "$tmp/sums" 2>/dev/null; then rm -rf "$tmp" fail download_failed "не удалось скачать $base/$asset" fi want=$(awk -v n="$asset" '$2 == n { print $1 }' "$tmp/sums") got=$(sha256sum "$tmp/server" | awk '{ print $1 }') if [ -z "$want" ] || [ "$want" != "$got" ]; then rm -rf "$tmp" fail download_failed "sha256 $asset не совпал с checksums.txt" fi chmod 0755 "$tmp/server" mv -f "$tmp/server" "$BIN" rm -rf "$tmp" echo "$VERSION" > "$VERFILE" } systemd_apply() { has_systemd || fail no_systemd "systemd не найден - выберите метод docker" binary_ensure # Токены argv без пробелов (валидаторы), systemd режет $FT_ARGS по словам. printf 'FT_ARGS=%s\n' "$(server_argv | tr '\n' ' ')" > "$ENVFILE" cat > "$UNIT_FILE" </dev/null 2>&1 || true systemctl restart "$SERVICE" || fail start_failed "systemctl restart $SERVICE" } ft_apply() { ft_remove port_busy_udp "$LISTEN_PORT" && fail port_busy "порт $LISTEN_PORT/udp занят" if [ "$INSTALL_METHOD" = docker ]; then docker_apply; else systemd_apply; fi ft_check_started if [ -n "$PREV_LISTEN_PORT" ] && [ "$PREV_LISTEN_PORT" != "$LISTEN_PORT" ]; then firewall_close_port "$PREV_LISTEN_PORT" udp fi firewall_open_port "$LISTEN_PORT" udp } # ───────────────────────────────────────────────────────────────────────────── # WireGuard ft-wg0: ядерный, wg-quick@ft-wg0. Чужой WG не трогаем. wg_ours() { [ -f "$WG_CONF" ] && grep -qxF "$WG_MARKER" "$WG_CONF"; } wg_up() { ip link show "$WG_IFACE" >/dev/null 2>&1; } wg_conf_get() { sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//p" "$WG_CONF" 2>/dev/null | head -n1 | tr -d ' \r'; } wg_server_pub() { wg pubkey <<< "$(wg_conf_get PrivateKey)"; } wg_conf_net() { local addr; addr=$(wg_conf_get Address) [ -n "$addr" ] || return 0 printf '%s/%s' "$(int2ip $(( $(ip2int "${addr%/*}") & $(mask_int "${addr#*/}") )))" "${addr#*/}" } wg_tools_ensure() { command -v wg >/dev/null 2>&1 && command -v wg-quick >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1 \ && return 0 step "Установка wireguard-tools" pkg_install wireguard-tools iptables || true command -v wg >/dev/null 2>&1 && command -v wg-quick >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1 \ || fail wg_install_failed "не удалось установить wireguard-tools и iptables" } # Прямая проверка: ядро создаёт интерфейс wireguard (модуль подгрузится сам). wg_kernel_check() { wg_up && return 0 if ip link add dev ftwgprobe type wireguard 2>/dev/null; then ip link del dev ftwgprobe 2>/dev/null || true return 0 fi fail wg_unsupported "ядро без WireGuard (OpenVZ/LXC?) - выберите «мой VPN»" } wg_subnet_free() { local cidr while read -r cidr; do [ -n "$cidr" ] && nets_overlap "$1" "$cidr" && return 1 done < <(ip -o -4 addr show 2>/dev/null | awk '{ print $4 }' ip -4 route show 2>/dev/null | awk '$1 ~ /^[0-9.]+(\/[0-9]+)?$/ { print ($1 ~ /\//) ? $1 : $1 "/32" }') return 0 } wg_write_conf() { local srv; srv=$(int2ip $(( $(ip2int "${WG_NET%/*}") + 1 ))) mkdir -p "$WG_DIR" chmod 0700 "$WG_DIR" cat > "$WG_CONF" < "$SYSCTL_FILE" sysctl -q -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true systemctl enable "$WG_UNIT" >/dev/null 2>&1 || true if [ "$restart" = 1 ]; then systemctl restart "$WG_UNIT" >/dev/null 2>&1 || true else systemctl start "$WG_UNIT" >/dev/null 2>&1 || true fi systemctl is-active --quiet "$WG_UNIT" || fail start_failed "$WG_UNIT не поднялся (journalctl -u $WG_UNIT)" } wg_remove() { wg_ours || return 0 systemctl disable --now "$WG_UNIT" >/dev/null 2>&1 || true rm -f "$WG_CONF" "$SYSCTL_FILE" } wg_sync() { wg_up || return 0 wg syncconf "$WG_IFACE" <(wg-quick strip "$WG_CONF") || fail start_failed "wg syncconf $WG_IFACE" } wg_next_ip() { local base last used i ip base=$(ip2int "${WG_NET%/*}") last=$(( base + (1 << (32 - ${WG_NET#*/})) - 2 )) used=" $(sed -n 's/^AllowedIPs[[:space:]]*=[[:space:]]*\([0-9.]*\)\/32.*/\1/p' "$WG_CONF" | tr '\n' ' ') " for (( i = base + 2; i <= last; i++ )); do ip=$(int2ip "$i") case "$used" in *" $ip "*) ;; *) echo "$ip"; return 0 ;; esac done return 1 } # Пир клиента: блок в ft-wg0.conf + клиентский конфиг; результат - в PEER_IP/PEER_PUB. wg_peer_add() { local name=$1 priv PEER_IP=$(wg_next_ip) || fail subnet_full "в подсети $WG_NET нет свободных адресов" priv=$(wg genkey) PEER_PUB=$(wg pubkey <<< "$priv") printf '\n[Peer]\n# ft-client: %s\nPublicKey = %s\nAllowedIPs = %s/32\n' "$name" "$PEER_PUB" "$PEER_IP" >> "$WG_CONF" wg_sync cat > "$(client_dir "$name")/$name.conf" < "$WG_CONF.tmp" chmod 0600 "$WG_CONF.tmp" mv -f "$WG_CONF.tmp" "$WG_CONF" wg_sync } # ───────────────────────────────────────────────────────────────────────────── # Клиенты: строка clients.list = name|client_id|ip|pub|created (ip/pub - только при своём WG). client_dir() { printf '%s/%s' "$CLIENTS_DIR" "$1"; } meta_get() { awk -F'|' -v n="$1" '$1 == n { print; exit }' "$CLIENTS_META" 2>/dev/null || true; } meta_has() { [ -n "$(meta_get "$1")" ]; } meta_put() { local name=${1%%|*} awk -F'|' -v n="$name" -v l="$1" '$1 == n { print l; f = 1; next } { print } END { if (!f) print l }' \ "$CLIENTS_META" 2>/dev/null > "$CLIENTS_META.tmp" || printf '%s\n' "$1" > "$CLIENTS_META.tmp" mv -f "$CLIENTS_META.tmp" "$CLIENTS_META" } meta_del() { [ -f "$CLIENTS_META" ] || return 0 awk -F'|' -v n="$1" '$1 != n' "$CLIENTS_META" > "$CLIENTS_META.tmp" mv -f "$CLIENTS_META.tmp" "$CLIENTS_META" } auth_init() { mkdir -p "$AUTH_DIR" "$CLIENTS_DIR" chmod 0700 "$AUTH_DIR" "$CLIENTS_DIR" [ -f "$CLIENTSFILE" ] || echo '{"clients":{}}' > "$CLIENTSFILE" } # Правка напрямую: сервер перечитывает clients.json по mtime (hot reload). allow_add() { jq --arg id "$1" --arg c "$2" '.clients[$id] = {comment: $c}' "$CLIENTSFILE" > "$CLIENTSFILE.tmp" \ && mv -f "$CLIENTSFILE.tmp" "$CLIENTSFILE" } allow_del() { jq --arg id "$1" 'del(.clients[$id])' "$CLIENTSFILE" > "$CLIENTSFILE.tmp" \ && mv -f "$CLIENTSFILE.tmp" "$CLIENTSFILE" } # freeturn://base64url(json), схема - internal/uri. n/spc не пишем: у клиента свои дефолты. freeturn_uri() { # cid name wg_conf local json b64 json="{\"v\":1,\"provider\":\"vk\",\"peer\":\"$(esc "$(join_hostport "$PUBLIC_HOST" "$LISTEN_PORT")")\"" [ "$PROXY_MODE" = udp ] || json="$json,\"mode\":\"$PROXY_MODE\"" [ "$OBF_PROFILE" = none ] || json="$json,\"obf\":\"$OBF_PROFILE\",\"key\":\"$OBF_KEY\"" json="$json,\"cid\":\"$1\",\"name\":\"$(esc "$2")\"" [ -z "$3" ] || json="$json,\"wg\":\"$(esc "$3")\"" json="$json}" b64=$(printf '%s' "$json" | openssl base64 -A | tr '+/' '-_' | tr -d '=') printf 'freeturn://%s' "$b64" } client_conf_file() { printf '%s/%s.conf' "$(client_dir "$1")" "$1"; } client_link() { # name cid local conf wg="" conf=$(client_conf_file "$1") [ -f "$conf" ] && wg=$(cat "$conf") freeturn_uri "$2" "$1" "$wg" } # Ссылки несут host/порт/ключ - после apply пересобираются все. clients_links_write() { local name cid [ -f "$CLIENTS_META" ] || return 0 while IFS='|' read -r name cid _; do [ -d "$(client_dir "$name")" ] || continue client_link "$name" "$cid" > "$(client_dir "$name")/$name.link" done < "$CLIENTS_META" } client_create() { local name=$1 cid ip="" pub="" meta_has "$name" && fail exists "клиент '$name' уже есть" mkdir -p "$(client_dir "$name")" cid=$(openssl rand -hex 16) allow_add "$cid" "$name" || fail internal "не удалось обновить $CLIENTSFILE" if [ "$BACKEND" = new ]; then wg_peer_add "$name" ip=$PEER_IP pub=$PEER_PUB fi meta_put "$name|$cid|$ip|$pub|$(date '+%Y-%m-%d %H:%M')" client_link "$name" "$cid" > "$(client_dir "$name")/$name.link" } client_delete() { local name=$1 cid [ "$name" = "$OWNER" ] && fail owner_protected "хозяина удалить нельзя" meta_has "$name" || fail not_found "клиента '$name' нет" cid=$(meta_get "$name" | cut -d'|' -f2) allow_del "$cid" || fail internal "не удалось обновить $CLIENTSFILE" wg_peer_remove "$name" rm -rf "$(client_dir "$name")" web_unlink "$name" meta_del "$name" } # Хозяин есть всегда; при своём WG у каждого клиента есть пир. clients_sync() { meta_has "$OWNER" || client_create "$OWNER" if [ "$BACKEND" = new ]; then local name cid ip pub created while IFS='|' read -r name cid ip pub created; do [ -z "$pub" ] || continue mkdir -p "$(client_dir "$name")" wg_peer_add "$name" meta_put "$name|$cid|$PEER_IP|$PEER_PUB|$created" done < <(cat "$CLIENTS_META") fi clients_links_write } # После сноса WG пиров нет: у клиентов остаётся только FreeTurn-часть. clients_drop_peers() { [ -f "$CLIENTS_META" ] || return 0 local name cid ip pub created while IFS='|' read -r name cid ip pub created; do rm -f "$(client_conf_file "$name")" meta_put "$name|$cid|||$created" done < <(cat "$CLIENTS_META") clients_links_write } # ───────────────────────────────────────────────────────────────────────────── # Применение конфигурации: общий путь TUI и RPC apply. Идемпотентно. apply_config() { require_root ensure_base_deps validate_config with_lock if [ "$BACKEND" = external ] && wg_ours; then fail backend_locked "свой WireGuard ещё стоит - сначала uninstall --target=wg" fi auth_init [ "$BACKEND" = new ] && wg_apply save_config ft_apply clients_sync install_cli } install_cli() { local target="$PREFIX/install.sh" cur="${BASH_SOURCE[0]:-$0}" if [ -f "$cur" ]; then [ "$(readlink -f "$cur" 2>/dev/null || true)" = "$(readlink -f "$target" 2>/dev/null || true)" ] \ || cp -f "$cur" "$target" 2>/dev/null || return 0 else # curl | bash и RPC по ssh: файла скрипта нет. _dl "https://raw.githubusercontent.com/$REPO/master/scripts/install.sh" "$target" 2>/dev/null || return 0 fi chmod 0755 "$target" ln -sf "$target" /usr/local/bin/freeturn 2>/dev/null || true } do_uninstall() { local target=$1 with_lock case "$target" in freeturn|all) ft_remove firewall_close_port "$LISTEN_PORT" udp rm -f "$BIN" "$VERFILE" log_success "FreeTurn удалён." ;; esac case "$target" in wg|all) if wg_ours; then wg_remove clients_drop_peers log_success "WireGuard $WG_IFACE удалён." fi ;; esac if [ "$target" = all ]; then stop_web_server if [ "$PURGE" = 1 ]; then rm -rf "$PREFIX" rm -f /usr/local/bin/freeturn log_success "Каталог $PREFIX удалён." fi fi return 0 } # ───────────────────────────────────────────────────────────────────────────── # RPC-команды RPC_COMMANDS="probe apply stop restart logs client-list client-add client-conf client-remove uninstall" is_rpc_command() { local c for c in $RPC_COMMANDS; do [ "$1" = "$c" ] && return 0; done return 1 } require_conf() { [ -f "$CONF_FILE" ] || fail not_installed "сервер не установлен"; } require_name() { [ -n "$NAME" ] || fail bad_arg "--name обязателен" meta_has "$NAME" || fail not_found "клиента '$NAME' нет" } conf_b64() { [ -f "$1" ] && base64 < "$1" | tr -d '\n'; } config_json() { printf '{"method":"%s","backend":"%s","connect":"%s","host":"%s","listen_port":%s,"wg_port":%s,"wg_net":"%s","mode":"%s","obf_profile":"%s","obf_key":"%s","version":"%s"}' \ "$INSTALL_METHOD" "$BACKEND" "$CONNECT" "$PUBLIC_HOST" "$LISTEN_PORT" "$WG_PORT" "$WG_NET" \ "$PROXY_MODE" "$OBF_PROFILE" "$OBF_KEY" "$VERSION" } client_json() { # meta-строка, вывод wg show latest-handshakes local name cid ip pub created hs el IFS='|' read -r name cid ip pub created <<< "$1" el="{\"name\":\"$name\",\"client_id\":\"$cid\",\"self\":$([ "$name" = "$OWNER" ] && echo true || echo false)" if [ -n "$pub" ]; then hs=$(printf '%s\n' "$2" | awk -v p="$pub" '$1 == p { print $2 }') el="$el,\"ip\":\"$ip\",\"pub\":\"$pub\",\"hs\":${hs:-0}" fi printf '%s}' "$el" } cmd_probe() { stage probe local installed=false ft_installed && installed=true d_bool installed "$installed" d_bool running "$([ "$installed" = true ] && ft_running && echo true || echo false)" d_num euid "$(id -u 2>/dev/null || echo -1)" d_str arch "$(uname -m 2>/dev/null || true)" if [ -f "$CONF_FILE" ]; then d_str method "$INSTALL_METHOD" d_str version "$(ft_version)" d_raw config "$(config_json)" fi ok } cmd_apply() { stage apply apply_config local line cid pub conf line=$(meta_get "$OWNER") cid=$(cut -d'|' -f2 <<< "$line") pub=$(cut -d'|' -f4 <<< "$line") conf=$(client_conf_file "$OWNER") d_raw owner "{\"client_id\":\"$cid\",\"pub\":\"$pub\",\"conf_b64\":\"$(conf_b64 "$conf" || true)\",\"link\":\"$(client_link "$OWNER" "$cid")\"}" d_str obf_key "$OBF_KEY" d_bool needs_restart "$([ -n "$PREV_SHARE" ] && [ "$PREV_SHARE" != "$(share_fingerprint)" ] && echo true || echo false)" ok } cmd_stop() { stage stop require_root ft_installed || fail not_installed "FreeTurn не установлен" with_lock ft_stop d_bool stopped true ok } cmd_restart() { stage restart require_root ft_installed || fail not_installed "FreeTurn не установлен" with_lock ft_restart d_bool running true ok } cmd_logs() { stage logs ft_installed || fail not_installed "FreeTurn не установлен" [[ "$TAIL" =~ ^[0-9]{1,5}$ ]] || fail bad_arg "tail: число" local l out="" first=1 while IFS= read -r l; do [ "$first" = 1 ] && first=0 || out="$out," out="$out\"$(esc "$l")\"" done < <(ft_logs "$TAIL") d_raw lines "[$out]" ok } cmd_client_list() { stage client_list require_conf local hs="" line out="" first=1 wg_up && hs=$(wg show "$WG_IFACE" latest-handshakes 2>/dev/null || true) if [ -f "$CLIENTS_META" ]; then while IFS= read -r line; do [ -n "$line" ] || continue [ "$first" = 1 ] && first=0 || out="$out," out="$out$(client_json "$line" "$hs")" done < "$CLIENTS_META" fi d_raw clients "[$out]" d_raw share "{\"backend\":\"$BACKEND\",\"host\":\"$PUBLIC_HOST\",\"port\":$LISTEN_PORT,\"mode\":\"$PROXY_MODE\",\"obf_profile\":\"$OBF_PROFILE\",\"obf_key\":\"$OBF_KEY\"}" ok } _emit_client() { local line cid line=$(meta_get "$1") cid=$(cut -d'|' -f2 <<< "$line") d_raw client "$(client_json "$line" "")" [ -f "$(client_conf_file "$1")" ] && d_str conf_b64 "$(conf_b64 "$(client_conf_file "$1")")" d_str link "$(client_link "$1" "$cid")" } cmd_client_add() { stage client_add require_root require_conf [ -n "$NAME" ] || fail bad_arg "--name обязателен" with_lock client_create "$NAME" _emit_client "$NAME" ok } cmd_client_conf() { stage client_conf require_conf require_name _emit_client "$NAME" ok } cmd_client_remove() { stage client_remove require_root require_conf [ -n "$NAME" ] || fail bad_arg "--name обязателен" with_lock client_delete "$NAME" d_bool removed true ok } cmd_uninstall() { stage uninstall require_root do_uninstall "$TARGET" d_str target "$TARGET" ok } # ───────────────────────────────────────────────────────────────────────────── # Опции: один парсер для RPC (--k=v) и CLI установщика. kcp_opt() { [ "$KCP_SET" = 1 ] || { KCP_ARGS=""; KCP_SET=1; } KCP_ARGS="${KCP_ARGS:+$KCP_ARGS }$1" } parse_opts() { local a v while [ $# -gt 0 ]; do a=$1 v=${1#*=} case "$a" in --method=*) INSTALL_METHOD=$v ;; --backend=*) BACKEND=$v ;; --connect=*) CONNECT=$v; valid_hostport "$v" || fail bad_arg "connect: host:port | [ipv6]:port" ;; --host=*) PUBLIC_HOST=$v; valid_host "$v" || fail bad_arg "host: имя или IP" ;; --listen-port=*) LISTEN_PORT=$v ;; --wg-port=*) WG_PORT=$v ;; --wg-net=*) WG_NET=$v ;; --mode=*) PROXY_MODE=$v ;; --obf-profile=*) OBF_PROFILE=$v ;; --obf-key=*) OBF_KEY=$v; valid_hex64 "$v" || fail bad_arg "obf-key: 64 hex-символа" ;; --version=*) VERSION=$v ;; --bin=*) LOCAL_BIN=$v; VERSION=local ;; --web-port=*) FT_WEB_PORT=$v ;; --web-ttl=*) FT_WEB_TTL=$v ;; --kcp-nodelay=*|--kcp-interval=*|--kcp-resend=*|--kcp-nc=*|--kcp-sndwnd=*|--kcp-rcvwnd=*|--kcp-mtu=*) a=${a%%=*} [[ "$v" =~ ^[0-9]{1,6}$ ]] || fail bad_arg "$a: число" kcp_opt "${a#-} $v" ;; --kcp-acknodelay=*) [[ "$v" =~ ^(true|false)$ ]] || fail bad_arg "--kcp-acknodelay: true | false" kcp_opt "-kcp-acknodelay=$v" ;; --tail=*) TAIL=$v ;; --name=*) NAME=$v; valid_name "$v" || fail bad_arg "name: латиница, цифры, . _ - (до 32)" ;; --target=*) TARGET=$v; [[ "$v" =~ ^(freeturn|wg|all)$ ]] || fail bad_arg "target: freeturn | wg | all" ;; --purge) PURGE=1 ;; -y|--yes) NONINTERACTIVE=1 ;; --reconfigure) ACTION=reconfigure ;; --update) ACTION=update; NONINTERACTIVE=1; FORCE_UPDATE=1 ;; --uninstall) ACTION=uninstall; NONINTERACTIVE=1 ;; *) fail bad_arg "неизвестный аргумент: $a (см. --help)" ;; esac shift done } # ───────────────────────────────────────────────────────────────────────────── # Веб-раздача файлов клиента и QR. # Секрет живёт столько же, сколько установка: выданные ссылки не протухают. Лежит на # диске, иначе каждый $(...) выдумывал бы новый. web_token() { if [ -s "$WEB_TOKEN_FILE" ]; then tr -d ' \r\n' < "$WEB_TOKEN_FILE" return 0 fi local tok; tok=$(openssl rand -hex 16 2>/dev/null) || return 1 printf '%s\n' "$tok" > "$WEB_TOKEN_FILE" 2>/dev/null || return 1 printf '%s' "$tok" } # Токен на клиента: ссылка 'phone' не открывает файлы 'laptop'. Выводится из мастер-секрета. client_token() { local master h; master=$(web_token) || return 1 h=$(printf '%s:%s' "$master" "$1" | sha256sum 2>/dev/null | awk '{ print $1 }') [[ "$h" =~ ^[0-9a-f]{64}$ ]] || return 1 printf '%s' "${h:0:32}" } web_unlink() { [ -s "$WEB_TOKEN_FILE" ] || return 0 local tok; tok=$(client_token "$1" 2>/dev/null || true) [ -z "$tok" ] || rm -f "$WEB_ROOT/$tok" } # docroot отдаёт только index.html; каталог клиента - за симлинком с именем-секретом. _web_layout() { local master; master=$(web_token) || { log_warn "Не удалось сохранить $WEB_TOKEN_FILE - веб-раздача выключена."; return 1; } mkdir -p "$WEB_ROOT" "$CLIENTS_DIR" 2>/dev/null || return 1 chmod 0755 "$WEB_ROOT" 2>/dev/null || true printf 'freeturn\n' > "$WEB_ROOT/index.html" chmod 0644 "$WEB_ROOT/index.html" 2>/dev/null || true # Пустой маяк на неугадываемом имени: по нему _web_listening узнаёт свой сервер. : > "$WEB_ROOT/${master}${WEB_PROBE_EXT}" chmod 0644 "$WEB_ROOT/${master}${WEB_PROBE_EXT}" 2>/dev/null || true local keep=" " name tok link while IFS='|' read -r name _; do [ -n "$name" ] && [ -d "$(client_dir "$name")" ] || continue tok=$(client_token "$name") || continue ln -sfn "$(client_dir "$name")" "$WEB_ROOT/$tok" 2>/dev/null || return 1 keep="${keep}${tok} " done < <(cat "$CLIENTS_META" 2>/dev/null) for link in "$WEB_ROOT"/*; do [ -L "$link" ] || continue case "$keep" in *" ${link##*/} "*) ;; *) rm -f "$link" ;; esac done return 0 } _web_systemd_active() { has_systemd && systemctl is-active --quiet "${WEB_UNIT}.service" 2>/dev/null; } # Пробой по маяку, а не по ss: 200 на нём отдаём только мы, чужой слушатель вернёт 404. _web_listening() { local i=${1:-1} while :; do curl -fsS --max-time 2 -o /dev/null "http://127.0.0.1:${FT_WEB_PORT}/$(web_token)${WEB_PROBE_EXT}" 2>/dev/null \ && return 0 i=$((i - 1)) [ "$i" -le 0 ] && return 1 sleep 1 done } # Строкой, а не функцией: исполняется вне скрипта (ExecStopPost, детач-обёртка). Зеркалит # firewall_open_port - ufw, firewalld ИЛИ iptables, иначе снесём постоянное правило админа. _web_close_cmd() { local port=$1 ufw_bin ipt_bin fw_bin ufw_bin=$(command -v ufw 2>/dev/null || true) ipt_bin=$(command -v iptables 2>/dev/null || true) fw_bin=$(command -v firewall-cmd 2>/dev/null || true) if [ -n "$ufw_bin" ] && ufw status 2>/dev/null | grep -q "Status: active"; then printf "%s delete allow %s/tcp >/dev/null 2>&1; exit 0" "$ufw_bin" "$port" elif [ -n "$fw_bin" ] && "$fw_bin" --state >/dev/null 2>&1; then printf "%s --remove-port=%s/tcp >/dev/null 2>&1; %s --permanent --remove-port=%s/tcp >/dev/null 2>&1; exit 0" "$fw_bin" "$port" "$fw_bin" "$port" elif [ -n "$ipt_bin" ]; then printf "%s -D INPUT -p tcp --dport %s -j ACCEPT >/dev/null 2>&1; exit 0" "$ipt_bin" "$port" else printf "exit 0" fi } # busybox вторым: не умеет листинг каталога. _web_server_argv() { if command -v python3 >/dev/null 2>&1; then printf '%s\n' python3 -m http.server "$1" --directory "$WEB_ROOT" elif command -v busybox >/dev/null 2>&1; then printf '%s\n' busybox httpd -f -p "$1" -h "$WEB_ROOT" else pkg_install python3 >/dev/null 2>&1 || true command -v python3 >/dev/null 2>&1 || return 1 printf '%s\n' python3 -m http.server "$1" --directory "$WEB_ROOT" fi } # Транзиентный юнит: RuntimeMaxSec гасит сервер, ExecStopPost закрывает порт при любом исходе. _web_start_systemd() { local port=$1 ttl=$2; shift 2 has_systemd && command -v systemd-run >/dev/null 2>&1 || return 1 systemd-run --collect --quiet --unit="$WEB_UNIT" \ --property=RuntimeMaxSec="$ttl" \ --property=WorkingDirectory="$WEB_ROOT" \ --property=ExecStopPost="/bin/sh -c \"$(_web_close_cmd "$port")\"" \ "$@" >/dev/null 2>&1 } # Без systemd: одна отвязанная обёртка держит и таймер, и закрытие порта. _web_start_nohup() { local port=$1 ttl=$2; shift 2 command -v timeout >/dev/null 2>&1 || return 1 local cmd; cmd=$(printf '%q ' "$@") setsid nohup bash -c "timeout $ttl $cmd >>'$WEB_LOG' 2>&1; $(_web_close_cmd "$port")" >/dev/null 2>&1 & _web_listening 8 } ensure_web_server() { local port="$FT_WEB_PORT" ttl="$FT_WEB_TTL" a argv=() _web_layout || return 1 # Сервер жив с прошлого вызова - правило файрвола могли снести извне, вернуть. if _web_systemd_active || _web_listening; then firewall_open_port "$port" tcp return 0 fi while IFS= read -r a; do argv+=("$a"); done < <(_web_server_argv "$port") [ "${#argv[@]}" -gt 0 ] || { log_warn "Нет ни python3, ни busybox - веб-раздача выключена."; return 1; } if command -v ss >/dev/null 2>&1 && [ -n "$(ss -Hltn "sport = :$port" 2>/dev/null || true)" ]; then log_warn "Порт $port/tcp занят - веб-раздача выключена, задайте --web-port." return 1 fi firewall_open_port "$port" tcp if _web_start_systemd "$port" "$ttl" "${argv[@]}"; then _web_listening 8 && return 0 systemctl stop "${WEB_UNIT}.service" >/dev/null 2>&1 || true fi _web_start_nohup "$port" "$ttl" "${argv[@]}" && return 0 firewall_close_port "$port" tcp log_warn "Не удалось поднять веб-раздачу." return 1 } stop_web_server() { has_systemd && systemctl stop "${WEB_UNIT}.service" >/dev/null 2>&1 || true pkill -f "http\.server ${FT_WEB_PORT} --directory ${WEB_ROOT}" >/dev/null 2>&1 || true pkill -f "httpd -f -p ${FT_WEB_PORT} -h ${WEB_ROOT}" >/dev/null 2>&1 || true firewall_close_port "$FT_WEB_PORT" tcp rm -rf "$WEB_ROOT" "$WEB_LOG" } qr_png() { # src dst command -v qrencode >/dev/null 2>&1 || pkg_install qrencode || return 0 qrencode -s 8 -m 2 -o "$2" < "$1" 2>/dev/null || true } show_client_links() { local name=$1 dir link url ttl_min=$(( FT_WEB_TTL / 60 )) dir=$(client_dir "$name") link=$(cat "$dir/$name.link" 2>/dev/null || true) qr_png "$dir/$name.link" "$dir/$name-link.png" [ -f "$dir/$name.conf" ] && qr_png "$dir/$name.conf" "$dir/$name-wg.png" local lines=("Клиент '$name'" "" "Ссылка для приложения FreeTurn:" "$link" "") if ensure_web_server; then url="http://$(join_hostport "$PUBLIC_HOST" "$FT_WEB_PORT")/$(client_token "$name")" lines+=("QR ссылки: $url/$name-link.png") [ -f "$dir/$name.conf" ] && lines+=("WireGuard-конфиг: $url/$name.conf" "QR WireGuard: $url/$name-wg.png") lines+=("" "Раздача живёт $ttl_min мин (снова: freeturn client qr $name)." \ "Ссылка равносильна ключам клиента - не публикуйте её.") fi lines+=("Файлы на сервере: $dir") echo if [ "$HAS_GUM" = 1 ]; then gum style --border rounded --border-foreground "$MD_PRIMARY" --padding "1 2" "$(printf '%s\n' "${lines[@]}")" else printf ' %s\n' "${lines[@]}" fi } # ───────────────────────────────────────────────────────────────────────────── # CLI: клиенты client_pick() { # VAR prompt [ -s "$CLIENTS_META" ] || fail not_found "клиентов нет" local names=() n sel while IFS='|' read -r n _; do names+=("$n"); done < "$CLIENTS_META" if [ "$HAS_GUM" = 1 ]; then sel=$(gum choose --header "$2" "${names[@]}" /dev/null < "$CLIENTS_META" || echo 0) + 1 ))" valid_name valid_name "$name" || fail bad_arg "Имя: латиница, цифры, . _ - (до 32 символов)" with_lock client_create "$name" log_success "Клиент '$name' добавлен." show_client_links "$name" ;; list) client_list ;; qr) [ -n "$name" ] || client_pick name "Клиент:" meta_has "$name" || fail not_found "Клиента '$name' нет." show_client_links "$name" ;; remove) [ -n "$name" ] || client_pick name "Удалить клиента:" with_lock client_delete "$name" log_success "Клиент '$name' удалён." ;; *) fail bad_arg "Использование: freeturn client [имя]" ;; esac } # ───────────────────────────────────────────────────────────────────────────── # TUI-мастер wizard() { ui_menu BACKEND "VPN за FreeTurn:" "$BACKEND" \ new "Создать WireGuard на этом сервере" \ external "Мой VPN (уже поднят: Amnezia, wg-easy, 3x-ui, Xray...)" if [ "$BACKEND" = new ]; then PROXY_MODE=udp ask WG_NET "Подсеть WireGuard" "$WG_NET" valid_net ask WG_PORT "Порт WireGuard (локальный, наружу не открывается)" "$WG_PORT" valid_port else ask CONNECT "Адрес VPN host:port (для VPN на этом сервере - 127.0.0.1:порт)" \ "${CONNECT:-127.0.0.1:51820}" valid_hostport ui_menu PROXY_MODE "Протокол VPN:" "$PROXY_MODE" \ udp "UDP (WireGuard / AmneziaWG / Hysteria)" \ tcp "TCP (Xray / sing-box)" fi ui_menu INSTALL_METHOD "Запуск FreeTurn:" "$INSTALL_METHOD" \ docker "Docker" \ systemd "systemd (бинарь на хосте)" ask LISTEN_PORT "Внешний порт FreeTurn (UDP)" "$LISTEN_PORT" valid_port ui_menu OBF_PROFILE "Обфускация:" "$OBF_PROFILE" \ rtpopus3 "rtpopus3 (рекомендуется)" \ rtpopus2 "rtpopus2" \ rtpopus "rtpopus" \ none "none (без обфускации - VK режет скорость)" [ -n "$PUBLIC_HOST" ] || PUBLIC_HOST=$(get_public_ip || true) ask PUBLIC_HOST "Адрес сервера для ссылок клиентов" "$PUBLIC_HOST" valid_host } review_config() { local lines=("Настройки" "") if [ "$BACKEND" = new ]; then lines+=("$(printf '%-18s %s' "VPN:" "WireGuard $WG_IFACE, $WG_NET, порт $WG_PORT")") else lines+=("$(printf '%-18s %s' "VPN:" "$CONNECT ($PROXY_MODE)")") fi lines+=("$(printf '%-18s %s' "Запуск:" "$INSTALL_METHOD")" "$(printf '%-18s %s' "FreeTurn:" "$(join_hostport "$PUBLIC_HOST" "$LISTEN_PORT")/udp")" "$(printf '%-18s %s' "Обфускация:" "$OBF_PROFILE")") echo if [ "$HAS_GUM" = 1 ]; then gum style --border double --border-foreground "$MD_PRIMARY" --padding "1 2" "$(printf '%s\n' "${lines[@]}")" else printf ' %s\n' "${lines[@]}" fi ui_drain_input ui_yesno "Применить?" "Y" || ui_abort } run_apply() { apply_config log_success "Сервер готов." show_client_links "$OWNER" log_info "Клиенты: freeturn client " } flow_install() { wizard; review_config; run_apply; } flow_uninstall() { local choice ui_menu choice "Что удалить?" "all" \ freeturn "Только FreeTurn" \ wg "Только WireGuard $WG_IFACE" \ all "Всё" \ back "Отмена" [ "$choice" = back ] && return 0 ui_yesno "Точно удалить ($choice)?" "N" || ui_abort [ "$choice" = all ] && ui_yesno "Удалить и данные (клиенты, ключи, $PREFIX)?" "N" && PURGE=1 do_uninstall "$choice" } menu_clients() { local c while :; do ui_menu c "Клиенты:" add add "Добавить" list "Список" qr "Ссылки и QR" remove "Удалить" back "Назад" case "$c" in back) return 0 ;; *) ( client_cli "$c" ) || true ;; esac done } menu_existing() { local choice while :; do ui_menu choice "Сервер настроен. Действие:" clients \ clients "Клиенты" \ reconfigure "Изменить настройки" \ update "Обновить FreeTurn" \ restart "Перезапустить FreeTurn" \ logs "Логи" \ uninstall "Удалить" \ exit "Выход" case "$choice" in clients) menu_clients ;; reconfigure) flow_install; return 0 ;; update) FORCE_UPDATE=1; run_apply; return 0 ;; restart) ft_restart; log_success "FreeTurn перезапущен." ;; logs) ft_logs 40 ;; uninstall) flow_uninstall; return 0 ;; exit) return 0 ;; esac done } usage() { cat < [--ключ=значение ...] EOF } main() { case "${1:-}" in -h|--help) usage; return 0 ;; esac if is_rpc_command "${1:-}"; then _IS_RPC=1 local cmd=$1; shift load_config parse_opts "$@" "cmd_${cmd//-/_}" return 0 fi case "${1:-}" in ""|-*|client) ;; *) _IS_RPC=1; fail bad_arg "unknown command: $1" ;; esac require_root ensure_base_deps load_config if [ "${1:-}" = client ]; then shift ensure_gum client_cli "$@" return 0 fi parse_opts "$@" if [ "$ACTION" = uninstall ]; then do_uninstall "$TARGET"; return 0; fi if [ "$ACTION" = update ] && ! [ -f "$CONF_FILE" ]; then fail not_installed "Сервер не установлен."; fi if [ "$NONINTERACTIVE" = 1 ]; then run_apply; return 0; fi ensure_gum ui_banner if [ "$ACTION" = reconfigure ] || ! [ -f "$CONF_FILE" ]; then flow_install else menu_existing fi ui_drain_input } if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then main "$@" fi