From 0ee0d5333ea5020e321ae1c5e1368417ea2e42d3 Mon Sep 17 00:00:00 2001 From: samosvalishe Date: Tue, 22 Sep 2026 00:02:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(install)!:=20install.sh=20=D1=81=20JSON=20?= =?UTF-8?q?RPC=20proto=203=20=D0=B8=20=D0=B3=D0=BE=D0=BB=D1=8B=D0=BC=20Wir?= =?UTF-8?q?eGuard=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20AWG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 12 + .github/workflows/release.yml | 48 - Taskfile.yml | 10 +- docker/awg/Dockerfile | 32 - docker/awg/start.sh | 97 - scripts/install.sh | 3666 ++++++++++----------------------- scripts/tests/install.bats | 383 ++++ scripts/tests/installer.bats | 785 ------- 8 files changed, 1522 insertions(+), 3511 deletions(-) delete mode 100644 docker/awg/Dockerfile delete mode 100644 docker/awg/start.sh create mode 100644 scripts/tests/install.bats delete mode 100644 scripts/tests/installer.bats diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c50f8f..473ca6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,18 @@ jobs: - run: govulncheck ./... + install-sh: + name: install.sh (bats) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + + - name: Install bats + run: sudo apt-get update -qq && sudo apt-get install -y -qq bats + + - name: Run tests + run: bats scripts/tests + build-snapshot: name: Snapshot build (all targets) runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62e80a8..3438643 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,54 +86,6 @@ jobs: dist/freeturn-sources.jar retention-days: 7 - awg-image: - name: AWG image - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6.0.2 - - - uses: docker/setup-qemu-action@v4.0.0 - - - uses: docker/setup-buildx-action@v4.1.0 - - - name: Log in to GHCR - uses: docker/login-action@v4.2.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Resolve image tags - id: tags - run: | - version="${GITHUB_REF_NAME#v}" - repo=$(echo "ghcr.io/${{ github.repository_owner }}/freeturn-awg" | tr '[:upper:]' '[:lower:]') - { - echo "version=${version}" - echo "list<> "$GITHUB_OUTPUT" - - - uses: docker/build-push-action@v6.19.2 - with: - context: . - file: docker/awg/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.tags.outputs.list }} - labels: | - org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.version=${{ steps.tags.outputs.version }} - org.opencontainers.image.revision=${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max - goreleaser: name: GoReleaser runs-on: ubuntu-latest diff --git a/Taskfile.yml b/Taskfile.yml index 657dfed..fdc43ee 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -61,16 +61,16 @@ tasks: powershell -NoProfile -Command '$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"; $env:PATH = "$env:JAVA_HOME\bin;$env:PATH"; gomobile bind {{.GOMOBILE_FLAGS}} -o dist/freeturn.aar ./mobile' platforms: [windows] - docker:awg: - desc: Build the AWG service image locally (docker/awg) - cmds: - - docker build -f docker/awg/Dockerfile -t freeturn-awg:dev . - test: desc: Run unit tests with race detector cmds: - go test -race -count=1 ./... + test:sh: + desc: Run install.sh tests (bats) + cmds: + - bats scripts/tests + test:cover: desc: Run tests with coverage report (cover.html) cmds: diff --git a/docker/awg/Dockerfile b/docker/awg/Dockerfile deleted file mode 100644 index 197fdc0..0000000 --- a/docker/awg/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# syntax=docker/dockerfile:1 -# Образ AWG-сервиса: amneziawg-go в форграунде + amneziawg-tools (awg, awg-quick). -ARG GO_VERSION=1.26.7 -ARG ALPINE_VERSION=3.23 -ARG AWG_GO_VERSION=v3.1.20260814 -ARG AWG_TOOLS_VERSION=v3.1.20260812 - -FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS go -ARG AWG_GO_VERSION -ARG TARGETARCH -RUN apk add --no-cache git -ENV CGO_ENABLED=0 GOOS=linux - -RUN GOARCH=$TARGETARCH go install "github.com/amnezia-vpn/amneziawg-go/v3@${AWG_GO_VERSION}" \ - && install -D "$(find /go/bin -type f -name amneziawg-go | head -n1)" /out/amneziawg-go - -FROM alpine:${ALPINE_VERSION} AS tools -ARG AWG_TOOLS_VERSION -RUN apk add --no-cache git build-base linux-headers \ - && git clone --depth 1 --branch "${AWG_TOOLS_VERSION}" https://github.com/amnezia-vpn/amneziawg-tools.git /src \ - && make -C /src/src - -FROM alpine:${ALPINE_VERSION} -RUN apk add --no-cache bash iproute2 iptables -COPY --from=go /out/amneziawg-go /usr/bin/amneziawg-go -COPY --from=tools /src/src/wg /usr/bin/awg -COPY --from=tools /src/src/wg-quick/linux.bash /usr/bin/awg-quick -COPY docker/awg/start.sh /usr/local/bin/ft-awg-start -RUN sed -i 's/\r$//' /usr/local/bin/ft-awg-start \ - && chmod +x /usr/bin/amneziawg-go /usr/bin/awg /usr/bin/awg-quick /usr/local/bin/ft-awg-start - -ENTRYPOINT ["/usr/local/bin/ft-awg-start"] diff --git a/docker/awg/start.sh b/docker/awg/start.sh deleted file mode 100644 index f162078..0000000 --- a/docker/awg/start.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/bin/bash -# PID 1 контейнера AWG: демон в форграунде, чтобы его смерть роняла контейнер. -# `ft-awg-start sync` - применить правки conf к живому интерфейсу (docker exec с хоста). -set -euo pipefail - -IFACE="${AWG_IFACE:-ftawg0}" -CONF="${AWG_CONF:-/etc/awg/${IFACE}.conf}" -if [ ! -f "$CONF" ] && [ -f "/etc/awg/awg0.conf" ]; then - CONF="/etc/awg/awg0.conf" -fi - -# Значения ключа из [Interface] (до первого [Peer]), по одному на строку. -_iface_vals() { - sed -n "/^[[:space:]]*\[[pP][eE][eE][rR]\]/q; s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//Ip" "$CONF" \ - | sed 's/[#;].*//' | tr -d ' \r' | tr ',' '\n' | grep -v '^$' || true -} - -_iface_val() { - _iface_vals "$1" | head -n1 -} - -_sync() { - awg-quick strip "$CONF" | awg syncconf "$IFACE" /dev/stdin -} - -if [ "${1:-}" = "sync" ]; then - _sync - exit 0 -fi - -[ -f "$CONF" ] || { echo "ft-awg: $CONF not found" >&2; exit 1; } - -ADDRS=$(_iface_vals Address) -MTU=$(_iface_val MTU) -[ -n "$ADDRS" ] || { echo "ft-awg: no Address in $CONF" >&2; exit 1; } -: "${MTU:=1280}" - -# В namespace контейнера форвардинг задаётся флагом docker --sysctl; здесь - страховка. -sysctl -qw net.ipv4.ip_forward=1 >/dev/null 2>&1 || true - -export WG_PROCESS_FOREGROUND=1 -# amneziawg-go знает только verbose/debug/error/silent, прочее для него = error. -export LOG_LEVEL="${AWG_LOG_LEVEL:-error}" -amneziawg-go -f "$IFACE" & -GO_PID=$! - -_shutdown() { - kill "$GO_PID" 2>/dev/null || true - wait "$GO_PID" 2>/dev/null || true - exit 0 -} -trap _shutdown TERM INT - -# UAPI-сокет появляется позже процесса - до него setconf не с кем говорить. -for _ in $(seq 1 100); do - [ -S "/var/run/amneziawg/$IFACE.sock" ] && break - kill -0 "$GO_PID" 2>/dev/null || { echo "ft-awg: daemon died on startup" >&2; wait "$GO_PID"; exit 1; } - sleep 0.1 -done - -_sync -# Без -4: конфиг может нести и v6. -for addr in $ADDRS; do - ip address add "$addr" dev "$IFACE" -done -ip link set mtu "$MTU" up dev "$IFACE" - -# Подсеть берём из connected-роута, а не из парсинга Address - маска любая. -# NAT только v4: masquerade для v6 требует ip6tables, его в образе нет. -NET=$(ip -4 route show dev "$IFACE" proto kernel scope link | awk '{print $1; exit}') -WAN=$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i/dev/null; then - iptables "${tbl[@]}" -A "$@" - fi -} - -if [ -n "$WAN" ] && [ -n "$NET" ]; then - _ipt FORWARD -i "$IFACE" -j ACCEPT - _ipt FORWARD -o "$IFACE" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT - _ipt -t nat POSTROUTING -s "$NET" -o "$WAN" -j MASQUERADE - # -o WAN: ответный SYN-ACK из интернета несёт MSS 1460, режем по PMTU пути. - # -o IFACE: страховка от клиента, забывшего MTU. - _ipt -t mangle FORWARD -o "$WAN" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu - _ipt -t mangle FORWARD -o "$IFACE" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss "$((MTU - 40))" -else - echo "ft-awg: WAN or subnet not detected (wan='$WAN' net='$NET'); NAT skipped" >&2 -fi - -echo "ft-awg: $IFACE up, addr $(echo "$ADDRS" | tr '\n' ' '), mtu $MTU, nat via ${WAN:-none}" -wait "$GO_PID" diff --git a/scripts/install.sh b/scripts/install.sh index 5e34b16..de1793d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,73 +1,51 @@ #!/usr/bin/env bash # Free Turn Proxy - установщик и контроллер сервера. -# Единый файл: устанавливает и обслуживает сервер, служит бэкендом JSON RPC для мобильного приложения. set -Eeuo pipefail umask 077 -PROTO_VERSION=2 +PROTO_VERSION=3 -# Пути PREFIX="${FT_PREFIX:-/opt/free-turn-proxy}" -STATE_FILE="$PREFIX/state" -PIDFILE="$PREFIX/proxy.pid" +CONF_FILE="$PREFIX/install.conf" LOCKFILE="$PREFIX/control.lock" -PEERS_LOCK="$PREFIX/peers.lock" -LOGFILE="$PREFIX/server.log" +BIN="$PREFIX/server" VERFILE="$PREFIX/version" -ARGSFILE="$PREFIX/run.args" ENVFILE="$PREFIX/run.env" -LAUNCHER="$PREFIX/launch.sh" -CLIENTSFILE="$PREFIX/auth/clients.json" -OWNERCIDFILE="$PREFIX/owner.cid" -SHARE_DIR="$PREFIX/share" -APP_DIR="$PREFIX" -CONF_FILE="${PREFIX}/install.conf" AUTH_DIR="$PREFIX/auth" +CLIENTSFILE="$AUTH_DIR/clients.json" +# Раздаётся по вебу - метаданные и allowlist держим вне его. +CLIENTS_DIR="$PREFIX/clients" +CLIENTS_META="$PREFIX/clients.list" +OWNER="owner" -# Веб-раздача: QR-коды AWG 3.1 (~600 Б конфига) не влезают в 80 колонок терминала, -# картинку надо снять телефоном. Сервер поднимается по требованию и умирает через TTL - -# порт с приватными ключами не должен стоять открытым между добавлениями клиентов. +# Веб-раздача: 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" -FT_WEB_PORT="${FT_WEB_PORT:-8080}" -FT_WEB_TTL="${FT_WEB_TTL:-900}" -# Службы и контейнеры SERVICE="free-turn-proxy.service" -UNIT_NAME="$SERVICE" -UNIT_FILE="/etc/systemd/system/${SERVICE}" -UNIT_PATH="$UNIT_FILE" -COMPOSE_FILE="${PREFIX}/docker-compose.yml" +UNIT_FILE="/etc/systemd/system/$SERVICE" 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_IFACE="ft-wg0" +WG_CONF="$WG_DIR/$WG_IFACE.conf" +WG_UNIT="wg-quick@$WG_IFACE" WG_MARKER="# managed-by: free-turn-proxy" -AWG_MTU_DEFAULT=1280 # тот же фоллбэк, что в start.sh контейнера AWG -# Каталог артефактов клиента (ключи, QR). Раздаётся наружу - метаданные и allowlist держим вне его. -CLIENTS_DIR="${PREFIX}/clients" -CLIENTS_META="${PREFIX}/clients.list" +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="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" +IMAGE="ghcr.io/$REPO" +RELEASES_URL="https://github.com/$REPO/releases" -# Цвета (Material Design 3 + ANSI fallback) +# Material Design 3 + ANSI fallback MD_PRIMARY="#D0BCFF" MD_SECONDARY="#CCC2DC" MD_TERTIARY="#EFB8C8" @@ -76,66 +54,81 @@ 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 -INSTALL_FREETURN=1 # 1 = ставить релей FreeTurn -INSTALL_AWG=1 # 1 = ставить AmneziaWG 3.1 +BACKEND="new" # new - свой WG ft-wg0 | external - «мой VPN» по CONNECT +CONNECT="" +PUBLIC_HOST="" # адрес VPS в ссылках клиентов VERSION="latest" -PROVIDER="vk" -PROXY_MODE="udp" # udp | tcp -BACKEND_PORT="51820" +PROXY_MODE="udp" # udp | tcp (tcp только для external) LISTEN_PORT="56000" -AWG_DIRECT_PORT=1 # 1 = открыть BACKEND_PORT/udp в файрволе +WG_PORT="51820" +WG_NET="10.13.13.0/24" OBF_PROFILE="rtpopus3" # rtpopus3 | rtpopus2 | rtpopus | none OBF_KEY="" -CLIENTS_FILE_CONF="${AUTH_DIR}/clients.json" -WG_ENDPOINT="127.0.0.1:9000" -AWG_LOG_LEVEL="${FT_AWG_LOG_LEVEL:-error}" +KCP_ARGS="" # argv-токены -kcp-* сервера, только tcp +FT_WEB_PORT="${FT_WEB_PORT:-8080}" +FT_WEB_TTL="${FT_WEB_TTL:-900}" -# 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 -NEW_CLIENT="" # клиент, созданный этим прогоном - только ему печатаем ссылки -OVERRIDES=() +FORCE_UPDATE=0 +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" -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}$ ]]; } +# ───────────────────────────────────────────────────────────────────────────── +# Валидаторы. Всё, что уходит в 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|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 v2 для Android ServerControl.kt (буферизация в 1 JSON-объект, trap EXIT) +# JSON RPC: буферизация в один объект, trap EXIT ловит неожиданный выход. -_DATA=() # "key": -_LOGS=() # заковыченные JSON-строки -_EMITTED=0 # защита от двойной печати +_DATA=() +_LOGS=() +_EMITTED=0 _STAGE="init" -_IS_RPC=0 # 1 = режим JSON RPC для Android / API +_IS_RPC=0 stage() { _STAGE="$1"; } @@ -155,14 +148,6 @@ 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; } @@ -171,12 +156,17 @@ 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 @@ -185,6 +175,8 @@ fail() { exit 1 } +die() { fail internal "$1"; } + _on_exit() { local rc=$? [ "$_EMITTED" -eq 1 ] && return @@ -196,20 +188,13 @@ _on_exit() { trap _on_exit EXIT # ───────────────────────────────────────────────────────────────────────────── -# UI-слой: обёртки над gum с plain-fallback при отсутствии gum. +# UI: обёртки над gum с plain-fallback. В RPC сообщения копятся в logs[]. + gum() { - local sub="${1:-}" - case "$sub" in + case "${1:-}" in style|join|format|log) - if [ -t 0 ]; then - command gum "$@" "$log" 2>&1 ) & + ( "$@" >"$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=0 || rc=$? - if [ "$rc" -eq 0 ]; then log_success "$title" + wait "$pid" 2>/dev/null || 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" + tail -n 40 "$out" | 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 + "$@" >"$out" 2>&1 || rc=$? + [ "$rc" -eq 0 ] || tail -n 40 "$out" >&2 fi - rm -f "$log" + rm -f "$out" return "$rc" } -# Генерация файла картинки QR-кода (PNG) с высоким разрешением -generate_qr_png_file() { - local conf_file="$1" png_file="$2" - [ -f "$conf_file" ] || return 0 - command -v qrencode >/dev/null 2>&1 || pkg_install qrencode || true - if command -v qrencode >/dev/null 2>&1; then - qrencode -s 8 -m 2 -o "$png_file" < "$conf_file" 2>/dev/null || true - [ -f "$png_file" ] && chmod 0600 "$png_file" 2>/dev/null || true - fi -} - -generate_qr_png_text() { - local text="$1" png_file="$2" - [ -n "$text" ] || return 0 - command -v qrencode >/dev/null 2>&1 || pkg_install qrencode || true - if command -v qrencode >/dev/null 2>&1; then - printf '%s' "$text" | qrencode -s 8 -m 2 -o "$png_file" 2>/dev/null || true - [ -f "$png_file" ] && chmod 0600 "$png_file" 2>/dev/null || true +# 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" "$@" } # ───────────────────────────────────────────────────────────────────────────── -# Системные примитивы: блокировки 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 + mkdir -p "$PREFIX" 2>/dev/null || 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 -} +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 - if command -v "$m" >/dev/null 2>&1; then echo "$m"; return 0; fi + command -v "$m" >/dev/null 2>&1 && { echo "$m"; return 0; } 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 \ @@ -456,57 +400,30 @@ pkg_install() { 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 + for b in curl jq openssl; 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") + step "Установка зависимостей: ${missing[*]}" pkg_install "${missing[@]}" || true + for b in "${missing[@]}"; do + command -v "$b" >/dev/null 2>&1 || fail internal "не удалось установить $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" ;; + case "$(uname -m 2>/dev/null || true)" 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 + 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 @@ -515,1165 +432,45 @@ gum_download() { } 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 + gum_download "$GUM_VERSION" && { HAS_GUM=1; return 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 +_mips_is_le() { [ "$(printf '\1\0' | od -An -tx2 -N2 2>/dev/null | tr -d ' \n')" = "0001" ]; } -_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 ;; +# Имя ассета релиза (.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 - - 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 + 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 - # Плейсхолдер уехал бы в Endpoint конфига и в ссылку - лучше явная ошибка. - return 1 -} - -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 -} - -# ───────────────────────────────────────────────────────────────────────────── -# Сохранение, загрузка и валидация конфигурации сервера (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" ] -} - -# Установки до 3.4.1 держали allowlist и метаданные внутри раздаваемого наружу clients/. -migrate_layout() { - [ -d "$PREFIX" ] || return 0 - mkdir -p "$AUTH_DIR" 2>/dev/null || return 0 - chmod 0700 "$AUTH_DIR" 2>/dev/null || true - local old_json="${CLIENTS_DIR}/clients.json" old_meta="${CLIENTS_DIR}/clients.list" - if [ -f "$old_json" ] && [ ! -f "$CLIENTSFILE" ]; then - mv -f "$old_json" "$CLIENTSFILE" 2>/dev/null || true - chmod 0600 "$CLIENTSFILE" 2>/dev/null || true - fi - [ -f "$old_json" ] && rm -f "$old_json" - if [ -f "$old_meta" ] && [ ! -f "$CLIENTS_META" ]; then - mv -f "$old_meta" "$CLIENTS_META" 2>/dev/null || true - fi - [ -f "$old_meta" ] && rm -f "$old_meta" - [ -d "$CLIENTS_DIR" ] && chmod 0700 "$CLIENTS_DIR" 2>/dev/null || true - migrate_client_dirs - return 0 -} - -# Те же установки держали артефакты плоско в clients/ - один секрет открывал всех клиентов. -migrate_client_dirs() { - [ -s "$CLIENTS_META" ] || return 0 - local name suf src dst - while IFS='|' read -r name _; do - valid_client_name "$name" || continue - dst="${CLIENTS_DIR}/${name}" - [ -d "$dst" ] && continue - for suf in direct.conf relay.conf freeturn.txt freeturn-vpn.txt \ - direct.png relay.png freeturn.png freeturn-vpn.png; do - src="${CLIENTS_DIR}/${name}-${suf}" - [ -f "$src" ] || continue - mkdir -p "$dst" 2>/dev/null && chmod 0700 "$dst" 2>/dev/null || true - mv -f "$src" "$dst/" 2>/dev/null || true - done - done < "$CLIENTS_META" - return 0 -} - -# shellcheck disable=SC1090 -load_config() { - [ -f "$CONF_FILE" ] && . "$CONF_FILE" || true - if [ -n "${AWG_SETUP:-}" ]; then INSTALL_AWG="$AWG_SETUP"; fi - case "$CLIENTS_FILE_CONF" in - "${CLIENTS_DIR}/"*) CLIENTS_FILE_CONF="$CLIENTSFILE" ;; - esac - migrate_layout - 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" -} - -# ───────────────────────────────────────────────────────────────────────────── -# VPN-бэкенд AmneziaWG (AWG 3.1); ft-wg0 - только детекция legacy-установок. - -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 - # .1 - сервер, .255 - broadcast: за 254 клиента /24 кончается. - [ "$max_ip" -ge 254 ] && { log_error "Подсеть ${net_prefix}.0/24 исчерпана (254 клиента)."; return 1; } - 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 -} - -wg_reconcile() { - command -v wg >/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 - # n/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" -} - -# ───────────────────────────────────────────────────────────────────────────── -# Управление рантаймами (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)) + [[ "$ip" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] && { echo "$ip"; return 0; } 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" - local tok; 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" ] && [ -z "$key" ]; then - key="$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | xxd -p -c 32 2>/dev/null || true)" - OBF_KEY="$key" - fi - 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" - - local mode="${ARG_MODE:-$PROXY_MODE}" - local prof="${ARG_OBF_PROFILE:-$OBF_PROFILE}" - local key="${ARG_OBF_KEY:-$OBF_KEY}" - local listen_val="${ARG_LISTEN:-0.0.0.0:${LISTEN_PORT}}" - local connect_val="${ARG_CONNECT:-$(connect_addr)}" - if [ "$prof" != "none" ] && [ -z "$key" ]; then - key="$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | xxd -p -c 32 2>/dev/null || true)" - OBF_KEY="$key" - fi - - write_compose_file "$mode" "$prof" "$key" "$listen_val" "$connect_val" - - 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 -} - -# Отдельно от apply_docker: частичное удаление обязано перегенерировать файл, -# иначе следующий `compose up` воскресит снесённый сервис. -write_compose_file() { - local mode="${1:-$PROXY_MODE}" prof="${2:-$OBF_PROFILE}" key="${3:-$OBF_KEY}" - local listen_val="${4:-0.0.0.0:${LISTEN_PORT}}" connect_val="${5:-$(connect_addr)}" - - if [ "$INSTALL_FREETURN" != "1" ] && [ "$INSTALL_AWG" != "1" ]; then - rm -f "$COMPOSE_FILE" - return 0 - fi - - { - 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_val}" - echo " - LISTEN_ADDR=${listen_val}" - echo " - MODE=${mode}" - echo " - OBF_PROFILE=${prof}" - [ "$prof" != "none" ] && [ -n "$key" ] && echo " - OBF_KEY=${key}" - [ -n "${ARG_OBF_TIMING:-}" ] && echo " - OBF_TIMING=${ARG_OBF_TIMING}" - 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=${AWG_LOG_LEVEL:-error}" - 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" -} - -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 - if [ "$INSTALL_FREETURN" = "1" ]; then - 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 - else - systemctl disable --now "$UNIT_NAME" 2>/dev/null || true - fi -} - -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}" - if [ "$prof" != "none" ] && [ -z "$key" ]; then - key="$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | xxd -p -c 32 2>/dev/null || true)" - OBF_KEY="$key" - fi - [ "$prof" != "none" ] && [ -n "$key" ] && args+=(-obf-profile "$prof" -obf-key "$key") - [ -n "${ARG_OBF_TIMING:-}" ] && args+=(-obf-timing "$ARG_OBF_TIMING") - if [ -n "${ARG_CLIENT_ID:-}" ] || [ -n "${CLIENTS_FILE_CONF:-}" ]; then - args+=(-clients-file "${CLIENTS_FILE_CONF:-$CLIENTSFILE}") - fi - ( 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" +# Занят ли UDP-порт. Без ss проверить нечем - считаем свободным. +port_busy_udp() { + command -v ss >/dev/null 2>&1 || return 1 + [ -n "$(ss -Hlun "sport = :$1" 2>/dev/null || true)" ] } firewall_open_port() { - local port=${1:-} proto=${2:-udp} - [[ "$port" =~ ^[0-9]+$ ]] || return 0 + 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 command -v iptables >/dev/null 2>&1; then @@ -1682,567 +479,834 @@ firewall_open_port() { 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} - [[ "$port" =~ ^[0-9]+$ ]] || return 0 + 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 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 } -firewall_open() { - if [ "$INSTALL_FREETURN" = "1" ]; then - firewall_open_port "$LISTEN_PORT" "udp" - [ "$PROXY_MODE" = "tcp" ] && firewall_open_port "$LISTEN_PORT" "tcp" +# ───────────────────────────────────────────────────────────────────────────── +# 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 } -_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; } +ft_version() { + if [ "$INSTALL_METHOD" = systemd ]; then cat "$VERFILE" 2>/dev/null || true; else image_tag; fi } -_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 $? +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 - 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 $? +} + +# Рестарт-петля выглядит как 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 + step "Загрузка образа $ref" docker pull "$ref" \ + || docker image inspect "$ref" >/dev/null 2>&1 || fail docker_failed "docker pull $ref" + mapfile -t argv < <(server_argv) + docker run -d --name "$CONTAINER" --network host --restart unless-stopped \ + -v "$AUTH_DIR:$AUTH_DIR:ro" --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"; } + +binary_ensure() { + 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 } -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" - } +# Пир клиента: блок в 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" </dev/null 2>&1 || true +wg_cut_peer() { # conf name + awk -v name="$2" ' + function flush() { for (j = 0; j < n; j++) print buf[j]; n = 0 } + /^[ \t]*\[/ { if (insec) { if (drop) n = 0; flush() }; insec = 1; drop = 0; buf[n++] = $0; next } + { if (!insec) { print; next }; buf[n++] = $0; l = $0; gsub(/[ \t\r]/, "", l); if (l == "#ft-client:" name) drop = 1 } + END { if (insec) { if (drop) n = 0; flush() } } + ' "$1" } -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 +wg_peer_remove() { + wg_ours || return 0 + wg_cut_peer "$WG_CONF" "$1" > "$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" +} - local raw; raw=$(_run_clients_cmd list 2>/dev/null) || { printf '[]'; return 0; } +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" +} - 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" +# Хозяин есть всегда; при своём 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 } # ───────────────────────────────────────────────────────────────────────────── -# Обработчики команд JSON RPC v2, CLI управления клиентами и интерактивных мастеров. +# Применение конфигурации: общий путь 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 +} # ───────────────────────────────────────────────────────────────────────────── -# JSON RPC v2 парсер и команды -# ───────────────────────────────────────────────────────────────────────────── -ARG_LISTEN="" ARG_CONNECT="" ARG_MODE="" ARG_KCP=() ARG_OBF_PROFILE="" -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" +# RPC-команды -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 +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 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 - + local installed=false + ft_installed && installed=true 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_bool running "$([ "$installed" = true ] && ft_running && echo true || echo false)" 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)]}" + 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_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" +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 - - 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}" + ft_stop + d_bool stopped true 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" - load_config +cmd_restart() { + stage restart + require_root + ft_installed || fail not_installed "FreeTurn не установлен" 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##*:} - [ "${ARG_MODE:-$PROXY_MODE}" = "tcp" ] && proto=tcp - 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" "$proto" - fi - - rt_stop - rt_start + ft_restart + d_bool running true 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 + 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_share_info() { - stage share_info - load_config - local backend=false - if awg_present || { wg_present && wg_is_ours; }; then - backend=true +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_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 + 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 } -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 +_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 - [ -n "$ARG_CLIENT_ID" ] || fail bad_arg "--client-id required" - [ -n "$ARG_NAME_B64" ] || fail bad_arg "--name-b64 required" + require_root + require_conf + [ -n "$NAME" ] || fail bad_arg "--name обязателен" with_lock - clients_add "$ARG_CLIENT_ID" "$(printf '%s' "$ARG_NAME_B64" | base64 -d 2>/dev/null || echo "$ARG_CLIENT_ID")" + 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 - [ -n "$ARG_CLIENT_ID" ] || fail bad_arg "--client-id required" + require_root + require_conf + [ -n "$NAME" ] || fail bad_arg "--name обязателен" with_lock - clients_remove_soft "$ARG_CLIENT_ID" + client_delete "$NAME" + d_bool removed true ok } -awg_conf_mtu() { - local v - v=$(sed -n "/^[[:space:]]*\[[pP][eE][eE][rR]\]/q; s/^[[:space:]]*MTU[[:space:]]*=[[:space:]]*//Ip" "$AWG_CONF" 2>/dev/null \ - | head -n1 | sed 's/[#;].*//' | tr -d ' \r') - case "$v" in ''|*[!0-9]*) v="$AWG_MTU_DEFAULT" ;; esac - echo "$v" -} - -# start.sh контейнера AWG пишет правила в host netns (network_mode: host) - убирать за ним. -awg_firewall_cleanup() { - command -v iptables >/dev/null 2>&1 || return 0 - # MSS берём из того же conf, что и start.sh, иначе -D не совпадёт с поставленным правилом. - local wan mss=$(( $(awg_conf_mtu) - 40 )) - wan=$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i/dev/null || true - iptables -D FORWARD -o "$AWG_IFACE" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true - iptables -t mangle -D FORWARD -o "$AWG_IFACE" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss "$mss" 2>/dev/null || true - [ -n "$wan" ] || return 0 - iptables -t nat -D POSTROUTING -s "${AWG_NET}.0/24" -o "$wan" -j MASQUERADE 2>/dev/null || true - iptables -t mangle -D FORWARD -o "$wan" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true -} - -do_uninstall() { - local target=${1:-all} purge=${2:-0} - load_config - apply_overrides # load_config перечитал install.conf - вернуть флаги CLI поверх него - 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" "$LAUNCHER"; systemctl daemon-reload || true - fi - firewall_close_port "$LISTEN_PORT" "udp" - firewall_close_port "$LISTEN_PORT" "tcp" - INSTALL_FREETURN=0 - [ -f "$COMPOSE_FILE" ] && write_compose_file - save_config - 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 - awg_firewall_cleanup - firewall_close_port "$BACKEND_PORT" "udp" - rm -rf "$AWG_DIR" - stop_web_server - INSTALL_AWG=0 - [ -f "$COMPOSE_FILE" ] && write_compose_file - save_config - 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" "udp" - firewall_close_port "$LISTEN_PORT" "tcp" - [ -n "$BACKEND_PORT" ] && firewall_close_port "$BACKEND_PORT" "udp" - awg_firewall_cleanup - stop_web_server - rm -f "$COMPOSE_FILE" /etc/sysctl.d/99-free-turn-proxy.conf - if [ "$purge" = "1" ] || [ "$PURGE" = "1" ]; then - rm -rf "$APP_DIR" - rm -f /usr/local/bin/freeturn /usr/local/bin/free-turn-proxy 2>/dev/null || true - 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 + require_root + do_uninstall "$TARGET" + d_str target "$TARGET" ok } # ───────────────────────────────────────────────────────────────────────────── -# Веб-сервер раздачи файлов клиентов и QR-кодов +# Опции: один парсер для 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 ;; + --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 +} + # ───────────────────────────────────────────────────────────────────────────── -# Секрет живёт столько же, сколько установка: ссылки, выданные раньше, не должны протухать. -# Токен читается заново в каждом $(...) - subshell не вернёт кэш. Поэтому он обязан -# лечь на диск: незаписанный токен = новая ссылка на каждый вызов, т.е. нерабочая ссылка. +# Веб-раздача файлов клиента и 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 || head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n') - [ -n "$tok" ] || return 1 - mkdir -p "$PREFIX" 2>/dev/null || true + local tok; tok=$(openssl rand -hex 16 2>/dev/null) || return 1 printf '%s\n' "$tok" > "$WEB_TOKEN_FILE" 2>/dev/null || return 1 - chmod 0600 "$WEB_TOKEN_FILE" 2>/dev/null || true printf '%s' "$tok" } -# Токен на клиента, а не один на всех: ссылка на 'phone' не должна открывать конфиги 'laptop'. -# Выводится из мастер-секрета, поэтому отдельного состояния на клиента хранить не надо. +# Токен на клиента: ссылка '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}') - [ -n "$h" ] || h=$(printf '%s:%s' "$master" "$1" | openssl dgst -sha256 2>/dev/null | awk '{print $NF}') - case "$h" in [0-9a-f][0-9a-f]*) ;; *) return 1 ;; esac + 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_base_url() { - local tok; tok=$(client_token "$2") || return 1 - printf 'http://%s:%s/%s' "$1" "${FT_WEB_PORT:-8080}" "$tok" +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; каталог клиента - за симлинком с именем-секретом. @@ -2250,7 +1314,6 @@ _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 - chmod 0700 "$CLIENTS_DIR" 2>/dev/null || true printf 'freeturn\n' > "$WEB_ROOT/index.html" chmod 0644 "$WEB_ROOT/index.html" 2>/dev/null || true # Пустой маяк на неугадываемом имени: по нему _web_listening узнаёт свой сервер. @@ -2259,9 +1322,9 @@ _web_layout() { local keep=" " name tok link while IFS='|' read -r name _; do - [ -n "$name" ] && [ -d "${CLIENTS_DIR}/${name}" ] || continue + [ -n "$name" ] && [ -d "$(client_dir "$name")" ] || continue tok=$(client_token "$name") || continue - ln -sfn "${CLIENTS_DIR}/${name}" "$WEB_ROOT/$tok" 2>/dev/null || return 1 + ln -sfn "$(client_dir "$name")" "$WEB_ROOT/$tok" 2>/dev/null || return 1 keep="${keep}${tok} " done < <(cat "$CLIENTS_META" 2>/dev/null) @@ -2272,16 +1335,13 @@ _web_layout() { return 0 } -_web_systemd_active() { - has_systemd && systemctl is-active --quiet "${WEB_UNIT}.service" 2>/dev/null -} +_web_systemd_active() { has_systemd && systemctl is-active --quiet "${WEB_UNIT}.service" 2>/dev/null; } -# Пробой по маяку, а не по ss: ss может отсутствовать, и 200 на нём отдаём только мы - -# чужой слушатель на этом порту вернёт 404. +# Пробой по маяку, а не по 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:-8080}/$(web_token)${WEB_PROBE_EXT}" 2>/dev/null \ + 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 @@ -2289,9 +1349,8 @@ _web_listening() { done } -# Закрытие порта нужно уже вне этого скрипта (ExecStopPost юнита, детач-обёртка фоллбэка), -# поэтому строкой, а не функцией. Зеркалит firewall_open_port: ufw ИЛИ iptables, не оба, -# иначе снесём постоянное правило админа на этом порту. Пути абсолютные - PATH юнита чужой. +# Строкой, а не функцией: исполняется вне скрипта (ExecStopPost, детач-обёртка). Зеркалит +# firewall_open_port - ufw ИЛИ iptables, иначе снесём постоянное правило админа. _web_close_cmd() { local port=$1 ufw_bin ipt_bin ufw_bin=$(command -v ufw 2>/dev/null || true) @@ -2305,28 +1364,23 @@ _web_close_cmd() { fi } -# Чем поднимать раздачу, по одному аргументу на строку. Оба сервера отдают файлы через -# симлинк docroot и режут traversal; busybox идёт вторым, т.к. не умеет листинг каталога. -# Ставить python3 пакетом - только если нет ни того, ни другого. +# busybox вторым: не умеет листинг каталога. _web_server_argv() { - local port=$1 if command -v python3 >/dev/null 2>&1; then - printf '%s\n' python3 -m http.server "$port" --directory "$WEB_ROOT" + 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 "$port" -h "$WEB_ROOT" + 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 "$port" --directory "$WEB_ROOT" + printf '%s\n' python3 -m http.server "$1" --directory "$WEB_ROOT" fi } -# Транзиентный юнит: RuntimeMaxSec гасит сервер сам, ExecStopPost закрывает порт при любом -# исходе, включая SIGKILL. Файл юнита не создаётся, --collect убирает его после смерти. +# Транзиентный юнит: RuntimeMaxSec гасит сервер, ExecStopPost закрывает порт при любом исходе. _web_start_systemd() { local port=$1 ttl=$2; shift 2 - has_systemd || return 1 - command -v systemd-run >/dev/null 2>&1 || return 1 + 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" \ @@ -2334,43 +1388,32 @@ _web_start_systemd() { "$@" >/dev/null 2>&1 } -# Фоллбэк без systemd: одна отвязанная обёртка держит и таймер, и закрытие порта - -# отдельный сторож пришлось бы ждать через wait, а он потомок не наш. +# Без 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 & + 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:-8080}" ttl="${FT_WEB_TTL:-900}" + 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 - - local argv=() while IFS= read -r a; do argv+=("$a"); done < <(_web_server_argv "$port") - [ "${#argv[@]}" -gt 0 ] || { log_warn "нет ни python3, ни busybox - веб-раздача выключена."; return 1; } - - # Чужой слушатель на порту: выдавать его ответы за свои ссылки нельзя. - local owner; owner=$(port_owner tcp "$port") - case "$owner" in - free | unknown) ;; - *) log_warn "Порт ${port}/tcp занят процессом '${owner}' - веб-раздача выключена, задайте --web-port." - return 1 ;; - esac - + [ "${#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 @@ -2380,739 +1423,274 @@ ensure_web_server() { } stop_web_server() { - local port="${FT_WEB_PORT:-8080}" has_systemd && systemctl stop "${WEB_UNIT}.service" >/dev/null 2>&1 || true - pkill -f "http\.server ${port} --directory ${WEB_ROOT}" >/dev/null 2>&1 || true - pkill -f "httpd -f -p ${port} -h ${WEB_ROOT}" >/dev/null 2>&1 || true - firewall_close_port "$port" tcp - rm -rf "$WEB_ROOT" - rm -f "$WEB_LOG" + 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 cname="$1" title="${2:-Клиент '$1' готов!}" - local cdir; cdir="$(client_dir "$cname")" - local ext_ip; ext_ip="$(get_public_ip)" || { log_error "Не удалось определить внешний IP."; return 1; } - if ! ensure_web_server; then - log_warn "Файлы клиента '${cname}' лежат в ${cdir}." - log_warn "Заберите их так: scp -r root@${ext_ip}:${cdir} ." - return 0 + 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 - local base_url; base_url="$(web_base_url "$ext_ip" "$cname")" - local ttl_min=$(( ${FT_WEB_TTL:-900} / 60 )) - # busybox httpd листинга не отдаёт - ссылку на каталог показываем, только если она рабочая. - local dir_ok=0 - curl -fsS --max-time 2 -o /dev/null \ - "http://127.0.0.1:${FT_WEB_PORT:-8080}/$(client_token "$cname")/" 2>/dev/null && dir_ok=1 - - local direct_png="${cdir}/${cname}-direct.png" - local ft_vpn_png="${cdir}/${cname}-freeturn-vpn.png" - local ft_png="${cdir}/${cname}-freeturn.png" - local relay_png="${cdir}/${cname}-relay.png" - + lines+=("Файлы на сервере: $dir") echo if [ "$HAS_GUM" = 1 ]; then - local lines=() - lines+=("$(gum style --foreground "$MD_SUCCESS" --bold "✔ ${title}")") - lines+=("") - lines+=("$(gum style --foreground "$MD_PRIMARY" --bold "Ссылки на QR-коды и конфиги:")") - [ -f "$direct_png" ] && lines+=(" • AmneziaWG Direct: ${base_url}/${cname}-direct.png") - [ -f "$ft_vpn_png" ] && lines+=(" • FreeTurn App (VPN): ${base_url}/${cname}-freeturn-vpn.png") - [ -f "$ft_png" ] && [ ! -f "$ft_vpn_png" ] && lines+=(" • FreeTurn App: ${base_url}/${cname}-freeturn.png") - [ -f "$relay_png" ] && lines+=(" • AmneziaWG Relay: ${base_url}/${cname}-relay.png") - [ "$dir_ok" = 1 ] && lines+=(" • Каталог файлов: ${base_url}/") - lines+=("") - lines+=("$(gum style --foreground "$MD_TERTIARY" "Раздача живёт ${ttl_min} мин, потом порт закрывается. Снова: freeturn client qr ${cname}")") - lines+=("$(gum style --foreground "$MD_TERTIARY" "Ссылка содержит приватные ключи '${cname}' - открыта всем, у кого она есть.")") - lines+=("$(gum style --foreground "$MD_SECONDARY" --italic "Скачать на ПК (SCP): scp -r root@${ext_ip}:${cdir} .")") - - local body; body=$(printf '%s\n' "${lines[@]}") - gum style --border rounded --border-foreground "$MD_PRIMARY" --padding "1 2" "$body" + gum style --border rounded --border-foreground "$MD_PRIMARY" --padding "1 2" "$(printf '%s\n' "${lines[@]}")" else - echo "========================================================" - echo " ${title}" - echo "========================================================" - [ -f "$direct_png" ] && echo " AmneziaWG Direct: ${base_url}/${cname}-direct.png" - [ -f "$ft_vpn_png" ] && echo " FreeTurn App (VPN): ${base_url}/${cname}-freeturn-vpn.png" - [ -f "$ft_png" ] && [ ! -f "$ft_vpn_png" ] && echo " FreeTurn App: ${base_url}/${cname}-freeturn.png" - [ -f "$relay_png" ] && echo " AmneziaWG Relay: ${base_url}/${cname}-relay.png" - [ "$dir_ok" = 1 ] && echo " Каталог файлов: ${base_url}/" - echo " Раздача живёт ${ttl_min} мин, потом порт закрывается (freeturn client qr ${cname})." - echo " ВНИМАНИЕ: ссылка содержит приватные ключи '${cname}' - открыта всем, у кого она есть." - echo " Скачать на ПК (SCP): scp -r root@${ext_ip}:${cdir} ." - echo "========================================================" + printf ' %s\n' "${lines[@]}" fi } # ───────────────────────────────────────────────────────────────────────────── -# CLI управление клиентами -# ───────────────────────────────────────────────────────────────────────────── -# Имя уходит в пути файлов - допускаем только безопасный алфавит (ни '/', ни ведущей точки). -valid_client_name() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$ ]]; } +# CLI: клиенты -client_dir() { printf '%s/%s' "$CLIENTS_DIR" "$1"; } - -client_add() { - local cname="${1:-}" silent="${2:-0}" - mkdir -p "$CLIENTS_DIR" "$SHARE_DIR"; chmod 0700 "$CLIENTS_DIR" 2>/dev/null || true - local ext_ip; ext_ip="$(get_public_ip)" || die "Не удалось определить внешний IP сервера - конфиг клиента был бы нерабочим." - - if [ -z "$cname" ]; then - local next_num=1 - [ -f "$CLIENTS_META" ] && next_num="$(( $(wc -l < "$CLIENTS_META") + 1 ))" - while :; do - ui_input cname "Имя нового клиента" "client-${next_num}" - valid_client_name "$cname" && break - ui_note "Ошибка" "Имя: латиница, цифры, . _ - (до 32 символов). Получено: '$cname'" - done +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 \ - && die "Клиент '$cname' уже существует." - - local cdir; cdir="$(client_dir "$cname")" - mkdir -p "$cdir"; chmod 0700 "$cdir" 2>/dev/null || true - - 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 - local direct_png="${cdir}/${cname}-direct.png" - local relay_png="${cdir}/${cname}-relay.png" - generate_qr_png_file "$direct_conf" "$direct_png" - [ -f "$relay_conf" ] && generate_qr_png_file "$relay_conf" "$relay_png" - fi - - local ft_uri="" ft_vpn_uri="" ft_png="" ft_vpn_png="" - if [ "$INSTALL_FREETURN" = "1" ]; then - ft_uri="$(generate_freeturn_uri "${ext_ip}:${LISTEN_PORT}" "${PROXY_MODE}" "${OBF_PROFILE}" "${OBF_KEY}" "${cid}" "${cname}")" - ft_file="${cdir}/${cname}-freeturn.txt" - ft_png="${cdir}/${cname}-freeturn.png" - echo "$ft_uri" > "$ft_file"; chmod 0600 "$ft_file" - generate_qr_png_text "$ft_uri" "$ft_png" - - 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")")" - local ft_vpn_file="${cdir}/${cname}-freeturn-vpn.txt" - ft_vpn_png="${cdir}/${cname}-freeturn-vpn.png" - echo "$ft_vpn_uri" > "$ft_vpn_file"; chmod 0600 "$ft_vpn_file" - generate_qr_png_text "$ft_vpn_uri" "$ft_vpn_png" - fi - fi - - echo "${cname}|${client_ip}|${cid}|$(date '+%Y-%m-%d %H:%M')" >> "$CLIENTS_META" - chmod 0600 "$CLIENTS_META" 2>/dev/null || true - - # silent=1 - первый клиент в ходе установки: ссылки покажет print_summary. - if [ "$silent" = "1" ]; then - return 0 - fi - - log_success "Клиент '${cname}' добавлен!" - show_client_links "$cname" "Клиент '${cname}' готов!" } client_list() { - mkdir -p "$CLIENTS_DIR" - [ ! -s "$CLIENTS_META" ] && { log_warn "Список клиентов пуст."; return 0; } + [ -s "$CLIENTS_META" ] || { log_warn "Клиентов нет."; return 0; } + local name cid ip pub created 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 + { echo "ИМЯ,IP,CLIENT_ID,СОЗДАН" + while IFS='|' read -r name cid ip pub created; do echo "$name,${ip:--},$cid,$created"; done < "$CLIENTS_META" + } | gum table --border rounded --print 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" + printf '%-16s %-16s %-34s %s\n' "Имя" "IP" "Client ID" "Создан" + while IFS='|' read -r name cid ip pub created; do + printf '%-16s %-16s %-34s %s\n' "$name" "${ip:--}" "$cid" "$created" + done < "$CLIENTS_META" fi } -client_resolve_name() { - local input="${1:-}" - [ -z "$input" ] && return 1 - [ ! -s "$CLIENTS_META" ] && return 1 - # 1. Точное совпадение - if awk -F'|' -v n="$input" '$1 == n { found = 1 } END { exit !found }' "$CLIENTS_META" 2>/dev/null; then - echo "$input"; return 0 - fi - # 2. Совпадение без дефисов и подчеркиваний (например client1 -> client-1) - local clean_in; clean_in=$(printf '%s' "$input" | tr -d '-_') - local name - while IFS='|' read -r name _ _ _; do - local clean_name; clean_name=$(printf '%s' "$name" | tr -d '-_') - if [ "${clean_in,,}" = "${clean_name,,}" ]; then - echo "$name"; return 0 - fi - done < "$CLIENTS_META" - # 3. Подстрока без учета регистра - while IFS='|' read -r name _ _ _; do - if [[ "${name,,}" == *"${input,,}"* ]]; then - echo "$name"; return 0 - fi - done < "$CLIENTS_META" - return 1 -} - -client_qr() { - local cname="${1:-}" - 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 -rf "$(client_dir "$cname")" - local tok; tok=$(client_token "$cname" 2>/dev/null || true) - [ -n "$tok" ] && rm -f "$WEB_ROOT/$tok" - if [ -f "$CLIENTS_META" ]; then - local meta_tmp="$CLIENTS_META.tmp" - awk -F'|' -v n="$cname" '$1 != n' "$CLIENTS_META" > "$meta_tmp" && mv -f "$meta_tmp" "$CLIENTS_META" - chmod 0600 "$CLIENTS_META" 2>/dev/null || true - fi - 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 ;; +client_cli() { + [ -f "$CONF_FILE" ] || fail not_installed "Сервер не установлен: запустите freeturn без аргументов." + local sub=${1:-} name=${2:-} + case "$sub" in + add) + [ -n "$name" ] || ask name "Имя клиента" "client-$(( $(wc -l 2>/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 +} - ui_menu INSTALL_METHOD "Метод запуска:" "$INSTALL_METHOD" \ - docker "Docker Compose (удобно, изолированно)" \ - systemd "Systemd (прямо на хосте)" +# ───────────────────────────────────────────────────────────────────────────── +# TUI-мастер - 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)?" "Y" \ - && CLIENTS_FILE_CONF="${AUTH_DIR}/clients.json" || CLIENTS_FILE_CONF="" +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 - - 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}" - elif [ "$INSTALL_FREETURN" = "1" ]; then - ask_port BACKEND_PORT "Порт бэкенда (куда пересылать трафик)" "${BACKEND_PORT:-51820}" - fi - - ui_yesno "Открыть необходимые порты в файрволе сервера?" "Y" \ - && OPEN_FIREWALL=1 || OPEN_FIREWALL=0 + 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 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 - local lines=() - lines+=("$(gum style --foreground "$MD_PRIMARY" --bold "Настройки установки")") - lines+=("") - lines+=("$(printf '%-20s %s' "Компоненты:" "$comp_name")") - lines+=("$(printf '%-20s %s' "Метод запуска:" "$INSTALL_METHOD")") - [ "$INSTALL_FREETURN" = "1" ] && lines+=("$(printf '%-20s %s' "Порт FreeTurn:" "0.0.0.0:$LISTEN_PORT")") - [ "$INSTALL_FREETURN" = "1" ] && lines+=("$(printf '%-20s %s' "Режим релея:" "$PROXY_MODE")") - [ "$INSTALL_FREETURN" = "1" ] && lines+=("$(printf '%-20s %s' "Обфускация:" "$OBF_PROFILE")") - [ "$INSTALL_AWG" = "1" ] && lines+=("$(printf '%-20s %s' "Порт AmneziaWG:" "$BACKEND_PORT")") - [ "$INSTALL_AWG" = "1" ] && lines+=("$(printf '%-20s %s' "Прямой AWG:" "$([ "$AWG_DIRECT_PORT" = "1" ] && echo "да" || echo "нет")")") - lines+=("$(printf '%-20s %s' "Файрвол:" "$([ "$OPEN_FIREWALL" = "1" ] && echo "открыть" || echo "не трогать")")") - - local body; body=$(printf '%s\n' "${lines[@]}") - gum style --border double --border-foreground "$MD_PRIMARY" --padding "1 2" "$body" + local lines=("Настройки" "") + if [ "$BACKEND" = new ]; then + lines+=("$(printf '%-18s %s' "VPN:" "WireGuard $WG_IFACE, $WG_NET, порт $WG_PORT")") else - echo; log_info "Настройки: components=$comp_name method=$INSTALL_METHOD" + lines+=("$(printf '%-18s %s' "VPN:" "$CONNECT ($PROXY_MODE)")") fi - ui_drain_input - ui_yesno "Применить конфигурацию?" "Y" || ui_abort -} - -install_cli_symlink() { - mkdir -p "$PREFIX" 2>/dev/null || true - mkdir -p /usr/local/bin 2>/dev/null || true - - local script_target="$PREFIX/install.sh" - local cur_script="${BASH_SOURCE[0]:-$0}" - - if [ -f "$cur_script" ]; then - if [ "$(readlink -f "$cur_script" 2>/dev/null || true)" != "$(readlink -f "$script_target" 2>/dev/null || true)" ]; then - cp -f "$cur_script" "$script_target" 2>/dev/null || true - fi - elif [ ! -f "$script_target" ]; then - # Сюда попадаем только из `curl | bash`, т.е. текущий скрипт и есть master. - local repo_raw="https://raw.githubusercontent.com/${REPO}/master/scripts/install.sh" - if command -v curl >/dev/null 2>&1; then - curl -sSL "$repo_raw" -o "$script_target" 2>/dev/null || true - elif command -v wget >/dev/null 2>&1; then - wget -qO "$script_target" "$repo_raw" 2>/dev/null || true - fi - fi - - if [ -f "$script_target" ]; then - chmod 0755 "$script_target" 2>/dev/null || true - ln -sf "$script_target" /usr/local/bin/freeturn 2>/dev/null || true - ln -sf "$script_target" /usr/local/bin/free-turn-proxy 2>/dev/null || true - fi -} - -apply() { - save_config - [ "$INSTALL_AWG" = "1" ] && awg_bootstrap - if [ "$INSTALL_METHOD" = "docker" ]; then - apply_docker - else - apply_systemd - fi - [ "$OPEN_FIREWALL" = 1 ] && firewall_open - if [ ! -s "$CLIENTS_META" ]; then - client_add "client-1" 1 - NEW_CLIENT="client-1" - fi - install_cli_symlink - return 0 -} - -print_summary() { - ui_drain_input - local ext_ip; ext_ip="$(get_public_ip)" || ext_ip="?" + 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 - local lines=() - lines+=("$(gum style --foreground "$MD_SUCCESS" --bold "✔ Установка успешно завершена!")") - lines+=("") - lines+=("$(gum style --foreground "$MD_PRIMARY" --bold "Параметры сервера:")") - if [ "$INSTALL_FREETURN" = "1" ]; then - lines+=(" • Сервер FreeTurn: $(gum style --bold "${ext_ip}:${LISTEN_PORT}") $(gum style --foreground "$MD_TERTIARY" "(${OBF_PROFILE})")") - fi - if [ "$INSTALL_AWG" = "1" ]; then - local awg_info="" - [ "$AWG_DIRECT_PORT" = "1" ] && awg_info=" (прямой доступ открыт)" - lines+=(" • AmneziaWG 3.1: $(gum style --bold "порт ${BACKEND_PORT}")${awg_info}") - fi - - lines+=("") - lines+=("$(gum style --foreground "$MD_PRIMARY" --bold "Управление клиентами:")") - lines+=(" freeturn client add [name] - добавить клиента") - lines+=(" freeturn client list - список клиентов") - lines+=(" freeturn client qr [name] - показать ссылки на QR-коды") - lines+=(" freeturn client remove [name] - удалить клиента") - - local body; body=$(printf '%s\n' "${lines[@]}") - gum style --border rounded --border-foreground "$MD_SUCCESS" --padding "1 2" "$body" + gum style --border double --border-foreground "$MD_PRIMARY" --padding "1 2" "$(printf '%s\n' "${lines[@]}")" else - echo "========================================================" - echo " Установка успешно завершена!" - echo "========================================================" - [ "$INSTALL_FREETURN" = "1" ] && echo "FreeTurn: ${ext_ip}:${LISTEN_PORT} (${OBF_PROFILE})" - [ "$INSTALL_AWG" = "1" ] && echo "AmneziaWG: порт ${BACKEND_PORT}" - echo - echo "Управление клиентами: freeturn client " - echo "========================================================" - fi - - # Только свежесозданный клиент: --update и --reconfigure не должны заново - # открывать порт с чужими приватными ключами (для этого есть client qr). - if [ -n "$NEW_CLIENT" ]; then - show_client_links "$NEW_CLIENT" "Ссылки для клиента '${NEW_CLIENT}'" - else - log_info "Ссылки на конфиги и QR: freeturn client qr [name]" + printf ' %s\n' "${lines[@]}" fi ui_drain_input + ui_yesno "Применить?" "Y" || ui_abort } -flow_install() { wizard; validate_config; review_config; apply; print_summary; } -flow_reconfigure() { load_config; wizard; validate_config; review_config; apply; print_summary; } -flow_update() { load_config; validate_config; apply; print_summary; } +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 (сохранить AmneziaWG для прямого доступа)" \ - awg "Только AmneziaWG (сохранить FreeTurn)" \ - all "Всё полностью (FreeTurn + AmneziaWG + конфиги и ключи)" \ + freeturn "Только FreeTurn" \ + wg "Только WireGuard $WG_IFACE" \ + all "Всё" \ back "Отмена" - [ "$choice" = "back" ] && return 0 - ui_yesno "Вы уверены?" "N" || ui_abort - do_uninstall "$choice" 1 + [ "$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 - ui_menu choice "Сервер настроен. Действие:" "clients" \ - clients "Управление клиентами (добавить, список, QR/файлы)" \ - reconfigure "Изменить настройки (переконфигурировать)" \ - update "Обновить версию" \ - logs "Просмотреть последние логи" \ - uninstall "Удалить (полностью или раздельно)" \ - exit "Выход" - case "$choice" in - clients) - while :; do - local c; ui_menu c "Клиенты:" "add" add "Добавить" list "Список" qr "Ссылки на QR/файлы" remove "Удалить" back "Назад" - case "$c" in add) client_add "" 0 ;; list) client_list ;; qr) client_qr "" ;; remove) client_remove "" ;; back) break ;; esac - done; menu_existing ;; - reconfigure) flow_reconfigure ;; - update) flow_update ;; - logs) - case "$(current_runtime)" in - docker) docker logs --tail 40 "$CONTAINER" 2>&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 -} - -# ───────────────────────────────────────────────────────────────────────────── -# Точка входа: маршрутизация RPC, CLI управления пирами, non-interactive и TUI мастера. - -# Диспетчер цепляется за точные имена: проверка «по форме строки» ловила бы и CLI-флаги (-y, --update). -RPC_COMMANDS="probe install wg-setup start stop logs share-info share-list peer-add peer-conf peer-remove client-add client-remove uninstall" - -is_rpc_command() { - local c - for c in $RPC_COMMANDS; do [ "$1" = "$c" ] && return 0; done - return 1 + 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 < сгенерируется) - --clients-auth | --no-clients-auth авторизация по Client ID - --firewall | --no-firewall открывать порты FreeTurn и AWG в файрволе - -Файлы клиента отдаются по ссылке http://IP:PORT/<токен клиента>/ на --web-port. -У каждого клиента ссылка своя, живёт --web-ttl секунд и равносильна его конфигу. -Показать снова - freeturn client qr ; сбросить все - rm /opt/free-turn-proxy/web.token. - -Действия: - --reconfigure переконфигурировать сервер - --update обновить версию - --uninstall удалить сервер - --only freeturn|awg|all цель удаления (default: all) - --purge удалить каталог /opt/free-turn-proxy - -h, --help справка - -Машиночитаемый JSON RPC v2 (мобильное приложение): - freeturn <$(printf "%s" "${RPC_COMMANDS// /|}")> [flags] +JSON RPC (приложение, proto $PROTO_VERSION): + freeturn <${RPC_COMMANDS// /|}> [--ключ=значение ...] 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 ;; - --web-port) OVERRIDES+=("FT_WEB_PORT=${2:-8080}"); shift ;; - --web-ttl) OVERRIDES+=("FT_WEB_TTL=${2:-900}"); 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=${AUTH_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 + case "${1:-}" in -h|--help) usage; return 0 ;; esac - local rpc_cmd="" if is_rpc_command "${1:-}"; then - rpc_cmd="$1" - else - case "${1:-}" in - ""|-*|client) ;; - *) _IS_RPC=1; HAS_GUM=0; fail bad_arg "unknown subcommand: $1" ;; - esac - fi - if [ -n "$rpc_cmd" ]; then - _IS_RPC=1; HAS_GUM=0; shift - parse_rpc_args "$@" - case "$rpc_cmd" in - probe) cmd_probe ;; - install) cmd_install ;; - wg-setup) cmd_wg_setup ;; - start) cmd_start ;; - stop) cmd_stop ;; - logs) cmd_logs ;; - share-info) cmd_share_info ;; - share-list) cmd_share_list ;; - peer-add) cmd_peer_add ;; - peer-conf) cmd_peer_conf ;; - peer-remove) cmd_peer_remove ;; - client-add) cmd_client_add ;; - client-remove) cmd_client_remove ;; - uninstall) cmd_uninstall ;; - esac - return 0 - 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 + _IS_RPC=1 + local cmd=$1; shift load_config - case "$sub" in - add) client_add "${1:-}" 0 ;; - list) client_list ;; - qr|web) client_qr "${1:-}" ;; - remove) client_remove "${1:-}" ;; - *) die "Использование: freeturn client " ;; - esac + parse_opts "$@" + "cmd_${cmd//-/_}" return 0 fi + case "${1:-}" in + ""|-*|client) ;; + *) _IS_RPC=1; fail bad_arg "unknown command: $1" ;; + esac - 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 + 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" ]; then - flow_reconfigure - elif [ "$ACTION" = "uninstall" ]; then - flow_uninstall - elif is_installed; then - load_config - menu_existing - else + if [ "$ACTION" = reconfigure ] || ! [ -f "$CONF_FILE" ]; then flow_install + else + menu_existing fi ui_drain_input } diff --git a/scripts/tests/install.bats b/scripts/tests/install.bats new file mode 100644 index 0000000..24218a5 --- /dev/null +++ b/scripts/tests/install.bats @@ -0,0 +1,383 @@ +#!/usr/bin/env bats +# White-box тесты install.sh: sandbox FT_PREFIX/FT_WG_DIR, без root, wg застаблен. + +setup() { + SCRIPT="$BATS_TEST_DIRNAME/../install.sh" + export FT_PREFIX="$BATS_TEST_TMPDIR/prefix" + export FT_WG_DIR="$BATS_TEST_TMPDIR/wg" + mkdir -p "$FT_PREFIX" "$FT_WG_DIR" + # shellcheck disable=SC1090 + source "$SCRIPT" + set +e + trap - EXIT + PUBLIC_HOST="203.0.113.7" + wg_up() { return 1; } + wg() { + case "$1" in + genkey) printf 'priv%04d%s=\n' "$RANDOM" "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" | cut -c1-44 ;; + pubkey) local k; read -r k; printf 'pub-%s\n' "${k:4:39}" ;; + esac + } +} + +decode_uri() { + local b=${1#freeturn://} + b=$(printf '%s' "$b" | tr -- '-_' '+/') + while [ $(( ${#b} % 4 )) -ne 0 ]; do b="$b="; done + printf '%s' "$b" | base64 -d +} + +need_jq() { command -v jq >/dev/null 2>&1 || skip "нет jq"; } + +@test "valid_port" { + valid_port 1 + valid_port 65535 + ! valid_port 0 + ! valid_port 65536 + ! valid_port 080 + ! valid_port "1 2" +} + +@test "valid_hostport: v4, имя, [v6]; без скобок v6 нельзя" { + valid_hostport 127.0.0.1:51820 + valid_hostport vpn.example.com:443 + valid_hostport '[2001:db8::1]:51820' + ! valid_hostport '2001:db8::1:51820' + ! valid_hostport 'a;b:1' + ! valid_hostport 'host:70000' + ! valid_hostport 'host' +} + +@test "valid_net: сетевой адрес, префикс 16..29" { + valid_net 10.13.13.0/24 + valid_net 172.20.0.0/16 + ! valid_net 10.13.13.1/24 + ! valid_net 10.13.13.0/30 + ! valid_net 10.13.13.0/8 + ! valid_net 10.13.256.0/24 + ! valid_net 010.13.13.0/24 +} + +@test "valid_name и valid_version" { + valid_name owner + valid_name phone-1.a_b + ! valid_name ../x + ! valid_name .hidden + ! valid_name "a b" + valid_version latest + valid_version v3.5.0 + valid_version v3.5.0-rc.1 + ! valid_version 3.5.0 +} + +@test "valid_kcp: токены argv" { + valid_kcp "" + valid_kcp "-kcp-nodelay 1 -kcp-mtu 1200 -kcp-acknodelay=true" + ! valid_kcp "-kcp-nodelay x" + ! valid_kcp "-kcp-foo 1" + ! valid_kcp '-kcp-nodelay 1; rm -rf /' +} + +@test "ip2int/int2ip и пересечение подсетей" { + [ "$(int2ip "$(ip2int 10.13.13.254)")" = 10.13.13.254 ] + nets_overlap 10.13.13.0/24 10.13.0.0/16 + nets_overlap 10.13.13.0/24 10.13.13.5/32 + ! nets_overlap 10.13.13.0/24 10.13.14.0/24 +} + +@test "join_hostport: скобки для v6" { + [ "$(join_hostport 1.2.3.4 56000)" = 1.2.3.4:56000 ] + [ "$(join_hostport 2001:db8::1 56000)" = '[2001:db8::1]:56000' ] +} + +@test "parse_opts: kcp заменяет набор целиком, ошибки -> bad_arg" { + KCP_ARGS="-kcp-mtu 1000" + parse_opts --kcp-nodelay=1 --kcp-acknodelay=true + [ "$KCP_ARGS" = "-kcp-nodelay 1 -kcp-acknodelay=true" ] + valid_kcp "$KCP_ARGS" + _IS_RPC=1 + run parse_opts --connect='x;y:1' + [[ "$output" == *'"code":"bad_arg"'* ]] + run parse_opts --bogus + [[ "$output" == *'"code":"bad_arg"'* ]] +} + +@test "server_argv: new -> свой WG, obf, clients-file" { + BACKEND=new WG_PORT=51999 LISTEN_PORT=56001 OBF_PROFILE=rtpopus3 OBF_KEY=abc + run server_argv + [ "${lines[0]}" = -listen ] && [ "${lines[1]}" = 0.0.0.0:56001 ] + [ "${lines[3]}" = 127.0.0.1:51999 ] + [[ "$output" == *$'-clients-file\n'"$CLIENTSFILE"* ]] + [[ "$output" == *$'-obf-profile\nrtpopus3\n-obf-key\nabc'* ]] +} + +@test "server_argv: external tcp + kcp, obf none" { + BACKEND=external CONNECT=127.0.0.1:443 PROXY_MODE=tcp OBF_PROFILE=none KCP_ARGS="-kcp-mtu 1200" + run server_argv + [ "${lines[3]}" = 127.0.0.1:443 ] + [[ "$output" != *obf* ]] + [ "${lines[-2]}" = -kcp-mtu ] && [ "${lines[-1]}" = 1200 ] + PROXY_MODE=udp + run server_argv + [[ "$output" != *kcp* ]] +} + +@test "validate_config: tcp со своим WG запрещён, ключ генерится" { + _IS_RPC=1 + BACKEND=new PROXY_MODE=tcp + run validate_config + [[ "$output" == *'"code":"bad_arg"'* ]] + PROXY_MODE=udp OBF_KEY="" + validate_config + valid_hex64 "$OBF_KEY" + OBF_PROFILE=none + validate_config + [ -z "$OBF_KEY" ] +} + +@test "validate_config: external без connect -> bad_arg" { + _IS_RPC=1 + BACKEND=external CONNECT="" + run validate_config + [[ "$output" == *'"code":"bad_arg"'* ]] +} + +@test "save_config/load_config: круг и отпечаток ссылок" { + BACKEND=external CONNECT=10.0.0.2:443 PROXY_MODE=tcp KCP_ARGS="-kcp-mtu 1200" + save_config + BACKEND=new CONNECT="" KCP_ARGS="" + load_config + [ "$BACKEND" = external ] && [ "$CONNECT" = 10.0.0.2:443 ] && [ "$KCP_ARGS" = "-kcp-mtu 1200" ] + [ "$PREV_SHARE" = "$(share_fingerprint)" ] + [ "$PREV_LISTEN_PORT" = "$LISTEN_PORT" ] +} + +@test "meta_put/meta_get/meta_del: замена на месте" { + meta_put "a|1|||" + meta_put "b|2|||" + meta_put "a|9|||" + [ "$(head -n1 "$CLIENTS_META")" = "a|9|||" ] + [ "$(wc -l < "$CLIENTS_META")" -eq 2 ] + meta_del a + ! meta_has a + meta_has b +} + +@test "freeturn_uri: схема internal/uri" { + OBF_PROFILE=rtpopus3 OBF_KEY=$(printf 'a%.0s' {1..64}) PROXY_MODE=tcp LISTEN_PORT=56000 + json=$(decode_uri "$(freeturn_uri cid123 phone $'[Interface]\nA = 1')") + [[ "$json" == '{"v":1,"provider":"vk","peer":"203.0.113.7:56000","mode":"tcp","obf":"rtpopus3"'* ]] + [[ "$json" == *'"cid":"cid123","name":"phone","wg":"[Interface]\nA = 1"}' ]] + OBF_PROFILE=none PROXY_MODE=udp + json=$(decode_uri "$(freeturn_uri c n "")") + [[ "$json" != *obf* && "$json" != *mode* && "$json" != *wg* ]] +} + +@test "wg: конфиг, выдача адресов, вырез пира" { + WG_NET=10.13.13.0/24 WG_PORT=51820 + wg_write_conf + wg_ours + [ "$(wg_conf_net)" = 10.13.13.0/24 ] + grep -q '^Address = 10.13.13.1/24$' "$WG_CONF" + grep -q '^PostUp = iptables -t nat -A POSTROUTING -s 10.13.13.0/24 ! -d 10.13.13.0/24 -j MASQUERADE$' "$WG_CONF" + mkdir -p "$(client_dir a)" "$(client_dir b)" + wg_peer_add a + [ "$PEER_IP" = 10.13.13.2 ] + wg_peer_add b + [ "$PEER_IP" = 10.13.13.3 ] + grep -q '^Endpoint = 127.0.0.1:9000$' "$(client_conf_file b)" + grep -q '^MTU = 1280$' "$(client_conf_file b)" + wg_peer_remove a + ! grep -q 'ft-client: a$' "$WG_CONF" + grep -q 'ft-client: b$' "$WG_CONF" + [ "$(wg_next_ip)" = 10.13.13.2 ] +} + +@test "wg_next_ip: подсеть кончилась" { + WG_NET=10.13.13.0/29 + wg_write_conf + for i in 2 3 4 5 6; do printf '[Peer]\nAllowedIPs = 10.13.13.%s/32\n' "$i" >> "$WG_CONF"; done + ! wg_next_ip +} + +@test "wg_apply: чужой ft-wg0.conf не трогаем" { + _IS_RPC=1 + has_systemd() { return 0; } + wg_tools_ensure() { :; } + printf '[Interface]\nAddress = 10.0.0.1/24\n' > "$WG_CONF" + run wg_apply + [[ "$output" == *'"code":"wg_conflict"'* ]] +} + +@test "клиенты: external - только client_id, хозяина не удалить" { + need_jq + BACKEND=external + auth_init + clients_sync + meta_has owner + cid=$(meta_get owner | cut -d'|' -f2) + [[ "$cid" =~ ^[0-9a-f]{32}$ ]] + [ "$(jq -r --arg id "$cid" '.clients[$id].comment' "$CLIENTSFILE")" = owner ] + [ -s "$(client_dir owner)/owner.link" ] + [ ! -f "$(client_conf_file owner)" ] + client_create phone + _IS_RPC=1 + run client_create phone + [[ "$output" == *'"code":"exists"'* ]] + run client_delete owner + [[ "$output" == *'"code":"owner_protected"'* ]] + run client_delete nobody + [[ "$output" == *'"code":"not_found"'* ]] + pcid=$(meta_get phone | cut -d'|' -f2) + client_delete phone + ! meta_has phone + [ "$(jq -r --arg id "$pcid" '.clients[$id] // "gone"' "$CLIENTSFILE")" = gone ] + [ ! -d "$(client_dir phone)" ] +} + +@test "клиенты: переход external -> new даёт пиры всем, снос WG их убирает" { + need_jq + BACKEND=external + auth_init + clients_sync + client_create phone + BACKEND=new WG_NET=10.13.13.0/24 + wg_write_conf + clients_sync + [ "$(meta_get owner | cut -d'|' -f3)" = 10.13.13.2 ] + [ "$(meta_get phone | cut -d'|' -f3)" = 10.13.13.3 ] + [ -f "$(client_conf_file phone)" ] + json=$(decode_uri "$(cat "$(client_dir phone)/phone.link")") + [[ "$json" == *'"wg":"[Interface]'* ]] + clients_drop_peers + [ -z "$(meta_get phone | cut -d'|' -f3)" ] + [ ! -f "$(client_conf_file phone)" ] + json=$(decode_uri "$(cat "$(client_dir phone)/phone.link")") + [[ "$json" != *'"wg"'* ]] +} + +@test "rpc client-list / client-conf: JSON" { + need_jq + BACKEND=external CONNECT=127.0.0.1:443 OBF_PROFILE=none + save_config + auth_init + clients_sync + run bash "$SCRIPT" client-list + [ "$(jq -r .result <<< "$output")" = ok ] + [ "$(jq -r '.data.clients[0].name' <<< "$output")" = owner ] + [ "$(jq -r '.data.clients[0].self' <<< "$output")" = true ] + [ "$(jq -r '.data.share.host' <<< "$output")" = 203.0.113.7 ] + [ "$(jq -r '.data.share.backend' <<< "$output")" = external ] + run bash "$SCRIPT" client-conf --name=owner + [[ "$(jq -r .data.link <<< "$output")" == freeturn://* ]] + run bash "$SCRIPT" client-conf --name=ghost + [ "$(jq -r .code <<< "$output")" = not_found ] +} + +@test "rpc probe: без install.conf нет config" { + run bash "$SCRIPT" probe + [[ "$output" == '{"proto":3,"result":"ok"'* ]] + [[ "$output" != *'"config"'* ]] +} + +@test "rpc: мутирующие команды без root -> needs_root" { + [ "$(id -u)" -ne 0 ] || skip "запущено от root" + for c in apply client-add uninstall stop restart; do + run bash "$SCRIPT" "$c" --name=x + [[ "$output" == *'"code":"needs_root"'* ]] || [[ "$output" == *'"code":"not_installed"'* ]] + done +} + +@test "validate_config: web-port и web-ttl" { + _IS_RPC=1 + BACKEND=external CONNECT=127.0.0.1:443 + FT_WEB_PORT=99999 + run validate_config + [[ "$output" == *'"code":"bad_arg"'* ]] + FT_WEB_PORT=8080 FT_WEB_TTL=10 + run validate_config + [[ "$output" == *'"code":"bad_arg"'* ]] +} + +@test "диспетчер: CLI-флаги не RPC, все RPC-команды опознаются" { + for f in -y --yes --update --uninstall --reconfigure --purge client ""; do + ! is_rpc_command "$f" + done + for c in $RPC_COMMANDS; do + is_rpc_command "$c" + declare -F "cmd_${c//-/_}" >/dev/null + done +} + +@test "web_token стабилен, client_token свой у каждого клиента" { + t1=$(web_token); t2=$(web_token) + [ "$t1" = "$t2" ] && [ "${#t1}" -eq 32 ] && [ -f "$WEB_TOKEN_FILE" ] + a=$(client_token phone); b=$(client_token laptop) + [ "$a" = "$(client_token phone)" ] && [ "$a" != "$b" ] && [ "$a" != "$t1" ] && [ "${#a}" -eq 32 ] + WEB_TOKEN_FILE=/proc/nonexistent/web.token + ! web_token +} + +@test "_web_layout: каталог клиента только за его токеном, удалённый убирается" { + mkdir -p "$(client_dir phone)" "$(client_dir laptop)" + printf 'phone|a|||now\nlaptop|b|||now\n' > "$CLIENTS_META" + _web_layout + [ -f "$WEB_ROOT/index.html" ] && [ -f "$WEB_ROOT/$(web_token)$WEB_PROBE_EXT" ] + [ "$(readlink "$WEB_ROOT/$(client_token phone)")" = "$(client_dir phone)" ] + [ ! -L "$WEB_ROOT/$(web_token)" ] + gone=$(client_token laptop) + printf 'phone|a|||now\n' > "$CLIENTS_META" + rm -rf "$(client_dir laptop)" + _web_layout + [ ! -L "$WEB_ROOT/$gone" ] +} + +@test "ensure_web_server: порт открывает сам и закрывает при неудаче" { + fw="$BATS_TEST_TMPDIR/fw.log" + firewall_open_port() { echo "open $1/$2" >> "$fw"; } + firewall_close_port() { echo "close $1/$2" >> "$fw"; } + _web_listening() { return 1; } + _web_systemd_active() { return 1; } + _web_server_argv() { printf '%s\n' fake-httpd "$1"; } + _web_start_systemd() { return 1; } + _web_start_nohup() { return 1; } + ss() { :; } + ! ensure_web_server + grep -q 'open 8080/tcp' "$fw" + grep -q 'close 8080/tcp' "$fw" +} + +@test "_web_server_argv: python3, иначе busybox" { + HAVE="" + command() { + if [ "$1" = -v ]; then + case " $HAVE " in *" $2 "*) echo "/usr/bin/$2"; return 0 ;; esac + return 1 + fi + builtin command "$@" + } + pkg_install() { return 1; } + ! _web_server_argv 8080 + HAVE=busybox + [ "$(_web_server_argv 8080 | tr '\n' ' ')" = "busybox httpd -f -p 8080 -h $WEB_ROOT " ] + HAVE="python3 busybox" + [ "$(_web_server_argv 8080 | tr '\n' ' ')" = "python3 -m http.server 8080 --directory $WEB_ROOT " ] +} + +@test "get_public_ip: ошибка вместо плейсхолдера" { + curl() { return 1; } + ! get_public_ip +} + +@test "client_delete убирает веб-симлинк клиента" { + need_jq + BACKEND=external + auth_init + clients_sync + client_create phone + _web_layout + tok=$(client_token phone) + [ -L "$WEB_ROOT/$tok" ] + client_delete phone + [ ! -L "$WEB_ROOT/$tok" ] +} diff --git a/scripts/tests/installer.bats b/scripts/tests/installer.bats deleted file mode 100644 index e266f8e..0000000 --- a/scripts/tests/installer.bats +++ /dev/null @@ -1,785 +0,0 @@ -#!/usr/bin/env bats - -setup() { - export FT_TEST_DIR="$BATS_TEST_TMPDIR/ft_test" - export FT_PREFIX="$FT_TEST_DIR/opt" - export FT_WG_DIR="$FT_TEST_DIR/wireguard" - export SCRIPT="$BATS_TEST_DIRNAME/../install.sh" - mkdir -p "$FT_PREFIX" "$FT_WG_DIR" -} - -teardown() { - rm -rf "$FT_TEST_DIR" -} - -run_script() { run bash "$SCRIPT" "$@"; } - -@test "install.sh проходит проверку синтаксиса bash -n" { - run bash -n "$SCRIPT" - [ "$status" -eq 0 ] -} - -@test "help (-h и --help) выводит справку и завершается с кодом 0" { - run_script -h - [ "$status" -eq 0 ] - [[ "$output" == *"Free Turn Proxy & AmneziaWG"* ]] - [[ "$output" == *"--only-awg"* ]] - [[ "$output" == *"--only-freeturn"* ]] - - run_script --help - [ "$status" -eq 0 ] - [[ "$output" == *"Машиночитаемый JSON RPC v2"* ]] -} - -@test "probe возвращает proto 2, result ok и ровно одну строку JSON" { - run_script probe - [ "$status" -eq 0 ] - [ "${#lines[@]}" -eq 1 ] - [[ "$output" == *'"proto":2'* ]] - [[ "$output" == *'"result":"ok"'* ]] -} - -@test "probe содержит все обязательные поля контракта Android" { - run_script probe - [ "$status" -eq 0 ] - [[ "$output" == *'"installed":'* ]] - [[ "$output" == *'"running":'* ]] - [[ "$output" == *'"runtime":'* ]] - [[ "$output" == *'"euid":'* ]] - [[ "$output" == *'"wg":{'* ]] - [[ "$output" == *'"virt":'* ]] - [[ "$output" == *'"wg_kernel":'* ]] - [[ "$output" == *'"conflicts":{'* ]] -} - -@test "неизвестная сабкоманда возвращает err bad_arg" { - run_script unknowncmd - [ "$status" -eq 1 ] - [[ "$output" == *'"result":"err"'* ]] - [[ "$output" == *'"code":"bad_arg"'* ]] -} - -@test "неизвестный флаг в RPC возвращает err bad_arg" { - run_script start --unknown-flag=123 - [ "$status" -eq 1 ] - [[ "$output" == *'"code":"bad_arg"'* ]] -} - -@test "share-info возвращает валидный JSON с wg_backend" { - run_script share-info - [ "$status" -eq 0 ] - [[ "$output" == *'"result":"ok"'* ]] - [[ "$output" == *'"wg_backend":'* ]] -} - -@test "share-list возвращает пустые массивы peers и clients" { - run_script share-list - [ "$status" -eq 0 ] - [[ "$output" == *'"result":"ok"'* ]] - [[ "$output" == *'"peers":[]'* ]] - [[ "$output" == *'"clients":[]'* ]] -} - -@test "stop возвращает stopped: true" { - run_script stop - [ "$status" -eq 0 ] - [[ "$output" == *'"stopped":true'* ]] -} - -@test "генерация параметров AmneziaWG 3.1 соответствует спецификации" { - # Проверяем функцию init_awg_params напрямую - run bash -c " - source '$SCRIPT' - AWG_CONF='$FT_PREFIX/awg/awg0.conf' - init_awg_params - [ -n \"\$AWG_JC\" ] && [ \"\$AWG_JC\" -ge 4 ] && [ \"\$AWG_JC\" -le 6 ] || exit 1 - [ \"\$AWG_JMIN\" -eq 10 ] && [ \"\$AWG_JMAX\" -eq 50 ] || exit 1 - [ \"\$AWG_S4\" -eq 12 ] || exit 1 - [ -n \"\$AWG_S1\" ] && [ -n \"\$AWG_S2\" ] && [ -n \"\$AWG_S3\" ] || exit 1 - [ \"\$AWG_H1\" -eq 1 ] && [ \"\$AWG_H2\" -eq 2 ] && [ \"\$AWG_H3\" -eq 3 ] && [ \"\$AWG_H4\" -eq 4 ] || exit 1 - [ \${#AWG_HPK} -gt 20 ] || exit 1 - " - [ "$status" -eq 0 ] -} - -@test "alloc_client_ip корректно инкрементирует IP адреса клиентов" { - run bash -c " - source '$SCRIPT' - conf='$FT_PREFIX/test.conf' - : > \"\$conf\" - ip1=\$(alloc_client_ip \"\$conf\" '10.13.13') - [ \"\$ip1\" = '10.13.13.2' ] || exit 1 - echo 'AllowedIPs = 10.13.13.2/32' >> \"\$conf\" - ip2=\$(alloc_client_ip \"\$conf\" '10.13.13') - [ \"\$ip2\" = '10.13.13.3' ] || exit 1 - echo 'AllowedIPs = 10.13.13.5/32' >> \"\$conf\" - ip3=\$(alloc_client_ip \"\$conf\" '10.13.13') - [ \"\$ip3\" = '10.13.13.6' ] || exit 1 - " - [ "$status" -eq 0 ] -} - -@test "валидация портов, hex и endpoint отсекает некорректные значения" { - run bash -c " - source '$SCRIPT' - valid_port 51820 || exit 1 - ! valid_port 0 || exit 1 - ! valid_port 70000 || exit 1 - ! valid_port abc || exit 1 - valid_hex64 '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' || exit 1 - ! valid_hex64 'short' || exit 1 - valid_endpoint '127.0.0.1:9000' || exit 1 - valid_endpoint '[::1]:9000' || exit 1 - ! valid_endpoint 'invalid-endpoint' || exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "validate_config отсекает конфигурацию без компонентов" { - run bash -c " - source '$SCRIPT' - INSTALL_FREETURN=0 - INSTALL_AWG=0 - validate_config - " - [ "$status" -ne 0 ] - [[ "$output" == *"Не выбран ни один компонент"* ]] -} - -@test "validate_config валидирует режимы only-awg и only-freeturn" { - run bash -c " - source '$SCRIPT' - # Только FreeTurn - INSTALL_FREETURN=1 - INSTALL_AWG=0 - INSTALL_METHOD='docker' - PROVIDER='vk' - PROXY_MODE='udp' - LISTEN_PORT='56000' - OBF_PROFILE='none' - validate_config || exit 1 - - # Только AmneziaWG - INSTALL_FREETURN=0 - INSTALL_AWG=1 - BACKEND_PORT='51820' - WG_ENDPOINT='127.0.0.1:9000' - validate_config || exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "generate_freeturn_uri генерирует корректный URI со схемой freeturn://" { - run bash -c " - source '$SCRIPT' - uri=\$(generate_freeturn_uri '1.2.3.4:56000' 'udp' 'rtpopus3' '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' 'cid123' 'my-client') - [[ \"\$uri\" == freeturn://* ]] || exit 1 - b64=\"\${uri#freeturn://}\" - pad=\$(( (4 - \${#b64} % 4) % 4 )) - [ \"\$pad\" -eq 1 ] && b64=\"\${b64}=\" - [ \"\$pad\" -eq 2 ] && b64=\"\${b64}==\" - [ \"\$pad\" -eq 3 ] && b64=\"\${b64}===\" - json=\$(printf '%s' \"\$b64\" | tr '_-' '/+' | base64 -d 2>/dev/null) - [[ \"\$json\" == *'\"v\":1'* ]] || exit 1 - [[ \"\$json\" == *'\"provider\":\"vk\"'* ]] || exit 1 - [[ \"\$json\" == *'\"peer\":\"1.2.3.4:56000\"'* ]] || exit 1 - [[ \"\$json\" == *'\"obf\":\"rtpopus3\"'* ]] || exit 1 - [[ \"\$json\" == *'\"cid\":\"cid123\"'* ]] || exit 1 - [[ \"\$json\" == *'\"name\":\"my-client\"'* ]] || exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "peer-add требует --name-b64 и --endpoint в RPC режиме" { - run_script peer-add - [ "$status" -eq 1 ] - [[ "$output" == *'"code":"bad_arg"'* ]] - [[ "$output" == *"--name-b64 required"* ]] - - run_script peer-add --name-b64="Y2xpZW50" - [ "$status" -eq 1 ] - [[ "$output" == *'"code":"bad_arg"'* ]] - [[ "$output" == *"--endpoint required"* ]] -} - -@test "клиентский конфиг AWG 3.1 содержит все 18 параметров обфускации" { - run bash -c " - source '$SCRIPT' - AWG_JC=5 - AWG_JMIN=10 - AWG_JMAX=50 - AWG_S1=20 - AWG_S2=30 - AWG_S3=40 - AWG_S4=12 - AWG_H1=1 - AWG_H2=2 - AWG_H3=3 - AWG_H4=4 - AWG_HPK='dGVzdGtleTEyMzQ1Njc4OTA=' - ext_ip='1.2.3.4' - BACKEND_PORT=51820 - client_ip='10.13.13.2' - cli_priv='clientprivkey' - srv_pub='serverpubkey' - - conf=\$(cat </dev/null && exit 1 - - # Невалидный режим - ( INSTALL_FREETURN=1; INSTALL_AWG=0; INSTALL_METHOD='docker'; PROVIDER='vk'; PROXY_MODE='http'; LISTEN_PORT='56000'; OBF_PROFILE='none'; validate_config ) 2>/dev/null && exit 1 - - # Невалидный порт listen - ( INSTALL_FREETURN=1; INSTALL_AWG=0; INSTALL_METHOD='docker'; PROVIDER='vk'; PROXY_MODE='udp'; LISTEN_PORT='99999'; OBF_PROFILE='none'; validate_config ) 2>/dev/null && exit 1 - - # Невалидный профиль обфускации - ( INSTALL_FREETURN=1; INSTALL_AWG=0; INSTALL_METHOD='docker'; PROVIDER='vk'; PROXY_MODE='udp'; LISTEN_PORT='56000'; OBF_PROFILE='invalid_prof'; validate_config ) 2>/dev/null && exit 1 - - # Невалидный порт бэкенда - ( INSTALL_FREETURN=0; INSTALL_AWG=1; BACKEND_PORT='invalid'; WG_ENDPOINT='127.0.0.1:9000'; validate_config ) 2>/dev/null && exit 1 - - # Невалидный endpoint - ( INSTALL_FREETURN=0; INSTALL_AWG=1; BACKEND_PORT='51820'; WG_ENDPOINT='bad_endpoint'; validate_config ) 2>/dev/null && exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "state_set и state_get сохраняют и читают параметры состояния" { - run bash -c " - source '$SCRIPT' - state_set 'key1' 'val1' - res1=\$(state_get 'key1') - [ \"\$res1\" = 'val1' ] || exit 1 - - state_set 'key2' 'val2' - state_set 'key1' 'val1_updated' - res1_up=\$(state_get 'key1') - res2=\$(state_get 'key2') - [ \"\$res1_up\" = 'val1_updated' ] || exit 1 - [ \"\$res2\" = 'val2' ] || exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "save_config и load_config корректно сериализуют и восстанавливают настройки" { - run bash -c " - source '$SCRIPT' - INSTALL_METHOD='docker' - INSTALL_FREETURN=1 - INSTALL_AWG=1 - PROXY_MODE='tcp' - BACKEND_PORT=51821 - LISTEN_PORT=56001 - OBF_PROFILE='rtpopus3' - OBF_KEY='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' - WG_ENDPOINT='127.0.0.1:9001' - AWG_JC=4 - AWG_HPK='dGVzdA==' - - save_config - - # Сбрасываем переменные - PROXY_MODE='' - BACKEND_PORT='' - LISTEN_PORT='' - OBF_PROFILE='' - OBF_KEY='' - WG_ENDPOINT='' - AWG_JC='' - AWG_HPK='' - - load_config - - [ \"\$PROXY_MODE\" = 'tcp' ] || exit 1 - [ \"\$BACKEND_PORT\" = '51821' ] || exit 1 - [ \"\$LISTEN_PORT\" = '56001' ] || exit 1 - [ \"\$OBF_PROFILE\" = 'rtpopus3' ] || exit 1 - [ \"\$OBF_KEY\" = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' ] || exit 1 - [ \"\$WG_ENDPOINT\" = '127.0.0.1:9001' ] || exit 1 - [ \"\$AWG_JC\" = '4' ] || exit 1 - [ \"\$AWG_HPK\" = 'dGVzdA==' ] || exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "relay и direct клиентские конфиги имеют разные Endpoint" { - run bash -c " - source '$SCRIPT' - mkdir -p '$FT_PREFIX/clients' - ext_ip='203.0.113.10' - BACKEND_PORT=51820 - WG_ENDPOINT='127.0.0.1:9000' - - direct_endpoint=\"\${ext_ip}:\${BACKEND_PORT}\" - relay_endpoint=\"\${WG_ENDPOINT}\" - - [ \"\$direct_endpoint\" != \"\$relay_endpoint\" ] || exit 1 - [ \"\$direct_endpoint\" = '203.0.113.10:51820' ] || exit 1 - [ \"\$relay_endpoint\" = '127.0.0.1:9000' ] || exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "logs RPC команда принимает tail и возвращает ok" { - run_script logs --tail=10 - [ "$status" -eq 0 ] - [[ "$output" == *'"result":"ok"'* ]] - [[ "$output" == *'"logs":'* ]] -} - -@test "клиентский CLI без аргументов или с неверной командой выводит ошибку" { - run bash -c " - # Без root при вызове сабкоманд client скрипт требует root - bash '$SCRIPT' client invalid_sub 2>&1 - " - [ "$status" -ne 0 ] -} - -@test "запуск через пайп (curl | bash) корректно обрабатывает параметры без ошибки unbound variable" { - run bash -c "cat '$SCRIPT' | bash -s -- -h" - [ "$status" -eq 0 ] - [[ "$output" == *"Free Turn Proxy & AmneziaWG"* ]] -} - -@test "compose_cmd делегирует вызовы в docker compose или docker-compose" { - run bash -c " - source '$SCRIPT' - docker() { - if [ \"\$1\" = 'compose' ]; then - echo \"MOCK_COMPOSE: \${*:2}\" - return 0 - fi - return 1 - } - export -f docker - compose_cmd version - " - [ "$status" -eq 0 ] - [[ "$output" == *"MOCK_COMPOSE: version"* ]] - - run bash -c " - source '$SCRIPT' - docker() { return 1; } - docker-compose() { - echo \"MOCK_STANDALONE: \$*\" - return 0 - } - export -f docker docker-compose - compose_cmd up -d - " - [ "$status" -eq 0 ] - [[ "$output" == *"MOCK_STANDALONE: up -d"* ]] -} - -@test "ensure_compose успешно завершается, если docker compose уже установлен" { - run bash -c " - source '$SCRIPT' - docker() { - if [ \"\$1\" = 'compose' ] && [ \"\$2\" = 'version' ]; then - return 0 - fi - return 1 - } - export -f docker - ensure_compose - " - [ "$status" -eq 0 ] -} - -@test "ensure_awg_image возвращает 0, если образ уже присутствует локально" { - run bash -c " - source '$SCRIPT' - INSTALL_AWG=1 - docker() { - if [ \"\$1\" = 'image' ] && [ \"\$2\" = 'inspect' ]; then - return 0 - fi - return 1 - } - export -f docker - ensure_awg_image - " - [ "$status" -eq 0 ] -} - -@test "web_token стабилен между вызовами и ложится на диск" { - run bash -c " - source '$SCRIPT' - t1=\$(web_token); t2=\$(web_token) - [ \"\$t1\" = \"\$t2\" ] || exit 1 - [ \"\${#t1}\" -eq 32 ] || exit 1 - [ -f \"\$WEB_TOKEN_FILE\" ] || exit 1 - " - [ "$status" -eq 0 ] -} - -@test "токен свой у каждого клиента и не равен мастер-секрету" { - run bash -c " - source '$SCRIPT' - a=\$(client_token phone); b=\$(client_token laptop) - [ \"\$a\" = \"\$(client_token phone)\" ] || exit 1 - [ \"\$a\" != \"\$b\" ] || exit 1 - [ \"\$a\" != \"\$(web_token)\" ] || exit 1 - [ \"\${#a}\" -eq 32 ] || exit 1 - [ \"\$(web_base_url 1.2.3.4 phone)\" = \"http://1.2.3.4:8080/\$a\" ] || exit 1 - " - [ "$status" -eq 0 ] -} - -@test "docroot прячет листинг, за токеном клиента виден только его каталог" { - ln -s "$FT_TEST_DIR" "$FT_TEST_DIR/lnprobe" 2>/dev/null - [ -L "$FT_TEST_DIR/lnprobe" ] || skip "ФС без симлинков (не Linux)" - run bash -c " - source '$SCRIPT' - mkdir -p \"\$CLIENTS_DIR/phone\" \"\$CLIENTS_DIR/laptop\" - printf 'phone|10.13.13.2||now\nlaptop|10.13.13.3||now\n' > \"\$CLIENTS_META\" - _web_layout || exit 1 - [ -f \"\$WEB_ROOT/index.html\" ] || exit 1 - [ -f \"\$WEB_ROOT/\$(web_token)\$WEB_PROBE_EXT\" ] || exit 1 - [ -L \"\$WEB_ROOT/\$(client_token phone)\" ] || exit 1 - [ \"\$(readlink \"\$WEB_ROOT/\$(client_token phone)\")\" = \"\$CLIENTS_DIR/phone\" ] || exit 1 - [ -L \"\$WEB_ROOT/\$(client_token laptop)\" ] || exit 1 - # мастер-секрет каталогом не раздаётся - [ -L \"\$WEB_ROOT/\$(web_token)\" ] && exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "_web_layout убирает симлинк удалённого клиента" { - ln -s "$FT_TEST_DIR" "$FT_TEST_DIR/lnprobe" 2>/dev/null - [ -L "$FT_TEST_DIR/lnprobe" ] || skip "ФС без симлинков (не Linux)" - run bash -c " - source '$SCRIPT' - mkdir -p \"\$CLIENTS_DIR/phone\" \"\$CLIENTS_DIR/laptop\" - printf 'phone|10.13.13.2||now\nlaptop|10.13.13.3||now\n' > \"\$CLIENTS_META\" - _web_layout || exit 1 - gone=\$(client_token laptop) - rm -rf \"\$CLIENTS_DIR/laptop\" - printf 'phone|10.13.13.2||now\n' > \"\$CLIENTS_META\" - _web_layout || exit 1 - [ -L \"\$WEB_ROOT/\$gone\" ] && exit 1 - [ -L \"\$WEB_ROOT/\$(client_token phone)\" ] || exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "migrate_client_dirs раскладывает плоские артефакты по каталогам клиентов" { - run bash -c " - source '$SCRIPT' - mkdir -p \"\$CLIENTS_DIR\" - printf 'phone|10.13.13.2||now\nphone-2|10.13.13.3||now\n' > \"\$CLIENTS_META\" - : > \"\$CLIENTS_DIR/phone-direct.conf\" - : > \"\$CLIENTS_DIR/phone-direct.png\" - : > \"\$CLIENTS_DIR/phone-2-direct.conf\" - migrate_client_dirs - [ -f \"\$CLIENTS_DIR/phone/phone-direct.conf\" ] || exit 1 - [ -f \"\$CLIENTS_DIR/phone/phone-direct.png\" ] || exit 1 - # префиксы не должны перетягивать чужие файлы - [ -f \"\$CLIENTS_DIR/phone-2/phone-2-direct.conf\" ] || exit 1 - [ -f \"\$CLIENTS_DIR/phone/phone-2-direct.conf\" ] && exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "allowlist и метаданные лежат вне раздаваемого каталога" { - run bash -c " - source '$SCRIPT' - case \"\$CLIENTS_FILE_CONF\" in \"\$CLIENTS_DIR\"/*) exit 1 ;; esac - case \"\$CLIENTS_META\" in \"\$CLIENTS_DIR\"/*) exit 1 ;; esac - true - " - [ "$status" -eq 0 ] -} - -@test "migrate_layout переносит clients.json из раздаваемого каталога" { - run bash -c " - source '$SCRIPT' - mkdir -p \"\$CLIENTS_DIR\" - echo '{\"clients\":{\"a\":1}}' > \"\$CLIENTS_DIR/clients.json\" - echo 'old|10.13.13.2||now' > \"\$CLIENTS_DIR/clients.list\" - migrate_layout - [ ! -f \"\$CLIENTS_DIR/clients.json\" ] || exit 1 - [ ! -f \"\$CLIENTS_DIR/clients.list\" ] || exit 1 - grep -q '\"a\":1' \"\$CLIENTSFILE\" || exit 1 - grep -q '^old|' \"\$CLIENTS_META\" || exit 1 - " - [ "$status" -eq 0 ] -} - -@test "valid_client_name отсекает traversal и метасимволы regex" { - run bash -c " - source '$SCRIPT' - valid_client_name 'phone-1' || exit 1 - valid_client_name 'a.b_c' || exit 1 - valid_client_name '../etc' && exit 1 - valid_client_name 'a/b' && exit 1 - valid_client_name 'a|b' && exit 1 - valid_client_name '.hidden' && exit 1 - valid_client_name '' && exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "alloc_client_ip упирается в границу /24" { - run bash -c " - source '$SCRIPT' - conf=\"\$FT_PREFIX/full.conf\" - for i in \$(seq 2 254); do echo \"AllowedIPs = 10.13.13.\$i/32\" >> \"\$conf\"; done - alloc_client_ip \"\$conf\" 10.13.13 - " - [ "$status" -ne 0 ] -} - -@test "write_compose_file убирает снятый сервис и удаляет файл без компонентов" { - run bash -c " - source '$SCRIPT' - INSTALL_FREETURN=1 INSTALL_AWG=1 - write_compose_file - grep -q 'free-turn-proxy:' \"\$COMPOSE_FILE\" || exit 1 - grep -q 'freeturn-awg:' \"\$COMPOSE_FILE\" || exit 1 - - INSTALL_FREETURN=0 - write_compose_file - grep -q 'free-turn-proxy:' \"\$COMPOSE_FILE\" && exit 1 - grep -q 'freeturn-awg:' \"\$COMPOSE_FILE\" || exit 1 - - INSTALL_AWG=0 - write_compose_file - [ ! -f \"\$COMPOSE_FILE\" ] || exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "compose AWG не включает verbose-логи демона" { - run bash -c " - source '$SCRIPT' - INSTALL_FREETURN=0 INSTALL_AWG=1 - write_compose_file - grep -q 'AWG_LOG_LEVEL=error' \"\$COMPOSE_FILE\" || exit 1 - grep -q 'AWG_LOG_LEVEL=verbose' \"\$COMPOSE_FILE\" && exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "get_public_ip возвращает ошибку вместо плейсхолдера" { - run bash -c " - source '$SCRIPT' - curl() { return 1; } - export -f curl - get_public_ip - " - [ "$status" -ne 0 ] - [[ "$output" != *"IP_СЕРВЕРА"* ]] -} - -@test "validate_config отсекает невалидный web-port" { - run bash -c " - source '$SCRIPT' - INSTALL_FREETURN=0 INSTALL_AWG=1 - BACKEND_PORT=51820 WG_ENDPOINT='127.0.0.1:9000' FT_WEB_PORT=99999 - validate_config - " - [ "$status" -ne 0 ] -} - -@test "CLI-флаги не уезжают в JSON RPC диспетчер" { - run bash -c " - source '$SCRIPT' - for f in -y --yes --update --uninstall --reconfigure --purge --only-awg --method; do - is_rpc_command \"\$f\" && { echo \"флаг \$f опознан как RPC\"; exit 1; } - done - for c in \$RPC_COMMANDS; do - is_rpc_command \"\$c\" || { echo \"команда \$c не опознана\"; exit 1; } - done - true - " - [ "$status" -eq 0 ] -} - -@test "веб-раздача не оставляет постоянного systemd-юнита" { - run grep -n 'WantedBy=multi-user.target' "$SCRIPT" - # единственный постоянный юнит - сам сервер free-turn-proxy - [ "$(printf '%s\n' "$output" | wc -l)" -eq 1 ] - - run bash -c "grep -c 'RuntimeMaxSec' '$SCRIPT'" - [ "$output" -ge 1 ] -} - -@test "firewall_open не держит веб-порт открытым постоянно" { - run bash -c " - source '$SCRIPT' - opened=() - firewall_open_port() { opened+=(\"\$1/\$2\"); } - INSTALL_FREETURN=1 INSTALL_AWG=1 AWG_DIRECT_PORT=1 - LISTEN_PORT=56000 BACKEND_PORT=51820 PROXY_MODE=udp FT_WEB_PORT=8080 - firewall_open - printf '%s\n' \"\${opened[@]}\" - " - [[ "$output" == *"56000/udp"* ]] - [[ "$output" == *"51820/udp"* ]] - [[ "$output" != *"8080"* ]] -} - -@test "ensure_web_server открывает порт сам и закрывает при неудаче запуска" { - run bash -c " - source '$SCRIPT' - log=\"\$FT_PREFIX/fw.log\" - firewall_open_port() { echo \"open \$1/\$2\" >> \"\$log\"; } - firewall_close_port() { echo \"close \$1/\$2\" >> \"\$log\"; } - port_owner() { echo free; } - _web_server_argv() { printf '%s\n' fake-httpd \"\$1\"; } - _web_start_systemd() { return 1; } - _web_start_nohup() { return 1; } - ensure_web_server && exit 1 - grep -q 'open 8080/tcp' \"\$log\" || exit 1 - grep -q 'close 8080/tcp' \"\$log\" || exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "_web_server_argv берёт python3, иначе busybox" { - # подменяем 'command -v', а не PATH: иначе тест зависел бы от того, стоит ли python3 в CI - run bash -c " - source '$SCRIPT' - HAVE='' - command() { - if [ \"\$1\" = '-v' ]; then - case \" \$HAVE \" in *\" \$2 \"*) echo \"/usr/bin/\$2\"; return 0 ;; esac - return 1 - fi - builtin command \"\$@\" - } - pkg_install() { return 1; } - - _web_server_argv 8080 && exit 1 - - HAVE='busybox' - [ \"\$(_web_server_argv 8080 | tr '\n' ' ')\" = \"busybox httpd -f -p 8080 -h \$WEB_ROOT \" ] || exit 1 - - HAVE='python3 busybox' - [ \"\$(_web_server_argv 8080 | tr '\n' ' ')\" = \"python3 -m http.server 8080 --directory \$WEB_ROOT \" ] || exit 1 - true - " - [ "$status" -eq 0 ] -} - -@test "validate_config отсекает слишком короткий web-ttl" { - run bash -c " - source '$SCRIPT' - INSTALL_FREETURN=0 INSTALL_AWG=1 - BACKEND_PORT=51820 WG_ENDPOINT='127.0.0.1:9000' FT_WEB_TTL=10 - validate_config - " - [ "$status" -ne 0 ] -} - -@test "web_token не выдаёт нестабильный токен, если его негде сохранить" { - run bash -c " - source '$SCRIPT' - WEB_TOKEN_FILE='/proc/nonexistent/web.token' - web_token - " - [ "$status" -ne 0 ] -}